程序员不秃头根本学不会的JVM垃圾回收之新生代回收和混合回收

新生代回收和混合回收

G1的Minor GC和Mixed GC采用的是同一复制回收算法,两者唯一的区别就是回收集(Collection Set,简称CSet)有所不同。Minor GC仅仅回收新生代空间;Mixed GC除了回收新生代空间之外,还会回收部分老生代分区。

G1的复制算法采用的是并行实现,与前面介绍的Parallel Scavenge基本类似。但因为G1是基于分区管理的,所以存在两个不同点:

1)内存不再连续,分配和回收都以分区为单位。

2)在对象分配时由于分区存在明确的边界,因此分配时需要考虑边界对齐。

在进行对象分配时,为了加速分配的效率,每个Mutator都有一个TLAB缓存。在TLAB中采用撞针分配方法,TLAB来自正在使用的分区。在G1中对分配的优化是:大对象不从Eden中分配。

这样做的原因在于:大对象存在的成员变量比较多(注意这里的成员变量是一个泛化的概念,具体还依赖于对象的类型。对一般的大对象来说,成员变量比较多;

对于特殊的大对象,比如数组类型对象,是数组元素比较多),产生的引用关系也多。在执行MinorGC、Mixed GC时,如果回收大对象,若大对象活跃,复制大对象的成本会比较高。所以Minor GC和Mixed GC尽量不回收大对象,除非大对象死亡。不回收大对象的简单办法就是把它直接分配在老生代空间中。

G1中大对象的默认定义是当对象的大小超过TLAB的最大值时就是大对象。而TLAB的最大值定义为超过分区大小的一半,即

大对象的定义仅仅与分区大小相关。

G1的回收过程可以分为串行执行部分和并行执行部分,其中串行执行部分主要是针对全局性的信息初始化等工作,例如确定GC的子类型(由于MinorGC、Mixed GC共享一份代码,为了在日志中进行区分,所以要确定子类型),将TLAB中未使用的内存填充为Dummy对象(前文提到的堆可解性),初始化CSet并选择待回收的分区。然后进入并行执行部分(这是整个垃圾回收的核心),使用多个GC工作线程同时处理根集合(root set),并借助标记栈完成对象的复制、标记工作。最后串行执行释放CSet空间、尝试释放大对象分区、启动并发标记等收尾工作。整个回收过程的流程图如图1所示。

图1 G1 Minor GC流程图

新生代回收和混合回收整个流程并不复杂,不展开介绍。这里着重介绍一下G1和前文提到的ParNew和Parallel Scavenge的不同之处。

回收过程中引用关系处理

回收过程中的一个特殊之处是需要重构引用集。ParNew或者ParallelScavenge的引用关系采用卡表的形式存储,在对象移动到Survivor或者晋升到老生代空间时,可以根据对象所在地址位置快速判断是否需要记录引用关系,如果需要,则在卡表中记录信息即可。

G1中引用关系保存在被引用对象相关的数据结构中,垃圾回收执行后,对象被移动到新的位置,需要重构引用集,以确保下一次垃圾回收的根集合正确。在上面的介绍中,为了解耦引用关系的生产者(指的是Mutator,简称Producer)和引用关系的消费者(指的是Refine线程、GC工作线程或者Mutator,统称为Consumer),使用了队列。

在垃圾回收过程中,对象发生了移动,移动实际上是复制对象到新的位置,复制前的对象称为老对象,复制后的对象称为新对象。移动过程除了复制对象以外,还需要把指向老对象的指针都更新到新对象。而这个指针更新的过程也有新的引用关系产生,这个引用关系也可以放入队列中,在垃圾回收完成后对引用集队列进行处理就能重构Survivor分区或者新晋升老生代分区的引用集。

混合回收导致停顿时间不符合预期的处理方法

G1的设计目标是提供一个暂停时间稳定的垃圾回收器,根据用户提供的期望停顿时间和应用运行的时间,使用历史的数据来预测新生代空间的大小。基于这样的理论基础,Minor GC的停顿时间大部分情况下都会满足用户的期望。

但混合回收完全无视用户需求。在混合回收时会额外回收一部分老生代分区,但是回收这一部分老生代分区所花费的时间成本完全没有考虑。为了尽量避免混合回收停顿时间过长,将可以回收的老生代分区划分成8次进行回收,而且回收的时候是按照分区中垃圾占比从多到少进行的。

在第一次执行混合回收时,由于选择的分区垃圾回收最多,活跃对象最少,复制这些活跃对象花费的时间成本也是最低的,所以对用户预期的停顿时间影响也应该是最小的。在本次混合回收结束后,使用本次新生代的大小、应用运行的时间、垃圾回收的停顿时间等信息预测接下来新生代大小,预测得到的新生代的大小在一定程度上反映了多回收部分老生代分区的信息。

但这样的设计无法完全避免垃圾回收停顿时间超出用户预期停顿时间。

为了解决这个问题,在JDK 12中引入了一个新的JEP(JDK EnhancementProposals)。这里简单介绍一下这个增强,这个增强的基础来自对停顿时间预测值进一步的细化,在回收的时候将整个回收过程中的步骤细化,并尽可能单独收集数据,然后建立预测模型,这样就可以根据预测时间选择尽可能多的老生代分区。

注意,我们使用预测模型来预测新生代的大小,这里又使用预测模型来回收老生代分区。理论上这是一个悖论,使用相同的预测模型,得到的结果应该是一致的,即额外可以回收的老生代分区为0个才符合数学上的推断。但是预测模型实际上存在误差,这个增强就是利用模型的误差来回收更多的老生代分区。一方面,为了让回收老生代分区预测得更为准确,应把整个垃圾回收的步骤划分得更细,以便收集更细粒度的数据。另一方面,G1还会启动一个抽样线程,在抽样线程里面还会进一步对分区相关的卡表、对象复制等信息进行预测,并对分区的信息进行修正,确保预测模型更为准确。

首先对CSet进行拆分,拆分为3部分:

1)必选分区Ⅰ:新生代分区,包括Eden、Survivor分区。

2)必选分区Ⅱ:预测必选分区Ⅰ停顿时间后,预测还可以回收部分老生代分区才会达到目标值。

3)可选分区Ⅲ:预测必选分区Ⅰ和必选分区Ⅱ后,若停顿时间还未达到用户设置的目标值,可用剩余的时间继续回收部分老生代分区。

在执行垃圾回收时,CSet中必选分区和可选分区分开回收。CSet中必选分区必须回收,而可选分区则不一定会被回收。可选分区是否回收取决于必选分区在垃圾回收中真正花费的时间,如果执行完必选分区后还有时间,才会真正回收可选分区,且可选分区中可能只有部分分区真正被回收。

 NUMA-Aware支持

在前面介绍并行回收中提到,垃圾回收支持NUMA特性,可以充分利用硬件的特性,提高分配的性能。

在JDK 14中引入了一个新的JEP 346为G1提供NUMA特性的支持。除了在应用分配对象时考虑NUMA亲和性,在垃圾回收器过程中也会考虑NUMA亲和性。这里简单介绍一下这个特性。

1)为每个Node(节点)绑定一个分区,该分区用于处理应用的对象分配请求。

2)对大对象特殊对待,尽量按照内存均衡的原则在不同的Node上分配内存,防止某一个Node上的应用过多的分配大对象将本地内存耗尽。

3)在垃圾回收执行的过程中,优先保证复制前后的分区位于同一个Node上,这样就保证在垃圾回收结束后,应用访问的内存刚好位于本地Node上。

云场景的支持

面向服务器领域的垃圾回收器一直都是被设计为长时间运行,在应用部署时会考虑应用所需要的资源。这可能存在巨大的资源浪费问题,其中一个浪费是JVM本身设计带来的。JVM在启动时会预分配内存,并且在运行时扩展内存直到用户定义的堆空间,当没有服务请求时,JVM已经分配的内存不会释放,从而造成了大量的内存浪费。在云场景中,这个缺点被放大,流量高峰时,云用户要为使用的资源多付更多的费用;但是在流量低谷时,由于JVM本身的缺陷,内存资源并不释放,导致云用户需要在流量低谷时为峰值的流量付费,这一点和云场景中提倡的按需使用、按使用付费相矛盾。所以在JDK12中引入了一个新的JEP 345,为G1提供优雅的释放内存的机制。这里简单介绍一下这个特性。

这个特性可以简单总结为:引入额外的线程,该线程周期性地触发MinorGC,在Minor GC中触发并发标记,在并发标记的一个暂停应用的阶段(再标记阶段)释放内存。之所以这么设计,笔者推测主要有以下几方面考虑:

1)由于G1中空闲分区通过一个Free链表保存,而这个Free链表在对象分配时被访问,因此针对空闲内存的释放需要对Free链表的操作与应用同步。

从这个角度来说,把内存释放动作放在一个暂停应用的阶段是最为简单的方法。

2)在Minor GC中进行释放内存有问题,由于Minor GC仅仅回收新生代空间,如果仅仅依赖于Minor GC,则无法释放老生代空间中不再使用的分区。

3)在Mixed GC中释放内存需要一个机制触发并发标记,才能确保MixedGC能够执行。由于触发了并发标记,在并发标记过程中有两个暂停应用的阶段,因此完全可以在并发标记中进行,没有必要把逻辑放入Mixed GC中。在并发标记的Remark阶段,由于已经进行了一些动作(如释放空的分区),此时释放内存相对来说也比较自然。

4)实际上还可以在Full GC中释放,但是Full GC暂停时间较长,对应用并不友好,特别是如果遇到流量突然进入的情况,可能影响用户体验。但是Full GC中有一个优势,就是释放的内存可能更多一些,效果更为明显。

遗憾的是,在目前主流使用的JDK 8和JDK 11中并没有支持该特性。幸运的是有临时的方案可以解决这个问题。

1)可以使用华为公司提供的毕昇JDK和阿里公司的龙井JDK。其中毕昇JDK是将JDK 12的特性移植到JDK 8和JDK 11中,在移植过程中优化了释放的过程,将并行释放优化为并发释放;而龙井JDK与社区方案并不相同,在Minor GC中触发内存释放,也是进行并发释放。

2)社区版(OpenJDK)或者Oracle JDK版本中并未支持该特性,可以在应用程序代码中加入System.gc()。对于不想修改代码的应用,可以通过注入一个Agent的方式,在Agent中周期性地调用System.gc()。System.gc()可以触发Full GC,在Full GC中可以释放内存,从而达到内存释放的目的。

并发标记和Minor GC、Mixed GC的交互

在本节的开始,提到Minor GC在满足一定条件下可以触发并发标记,启动并发标记的Minor GC和一般的Minor GC在一些处理点上稍有不同(具体的不同之处在下一节介绍)。另外,Mixed GC共享Minor GC代码,为了区别这些不同的Minor GC,在日志中为这些不同的Minor GC使用不同的名字,这里稍微总结一下。

1)Pause Young(Normal):正常的Minor GC,仅仅回收新生代空间。

2)Pause Young(Concurrent Start)或者(initial-mark):正常执行Minor GC,仅仅回收新生代空间,在本次的Minor GC结束后会启动并发标记。

3)Pause Young(Prepare Mixed):正常的Minor GC,仅仅回收新生代空间,本次Minor GC表示并发标记已经结束,下一次会启动Mixed GC。

4)Pause Young(Mixed):Mixed GC,回收新生代空间和部分老生代分区。

上面的4种Minor GC之间实际上构成了一个状态机,从PauseYoung(Concurrent Start)启动到Pause Young(Mixed)未执行之前,不能再启动新的一轮并发标记。

但是这一部分逻辑在早期的JDK版本中并未理顺,存在一些潜在的问题。

例如发生了过多的GCLocker导致内存不足,更多内容可参见一些问题的讨论。直到JDK 9才对GC的状态变换做了修正,并在JDK 12中进行了完善,形成Minor GC的状态转换图,如图2所示。

图2多种GC执行的状态转换

这里需要说明的是,状态图仍在不断完善中,例如GCLocker在JDK 14之前可以触发并发初始标记,但在JDK 14中参数GCLockerInvokesConcurrent被移除,原因是应用同时触发GCLocker和System.GC之后,可能导致一些线程一直饥饿(即通常所说的活锁),但是问题又不容易修改,所以在JDK 14中直接取消了GCLocker触发并发标记的动作。

仅从GC执行的角度看来,GC活动状态如图3所示。

图3多种GC执行活动状态

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值