vlambda博客
学习文章列表

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

因为工作的需要,笔者前前后后分别接触了HBase RegionServer、HiveServer\Metastore以及HDFS NameNode这些大内存JVM服务。在和这些JVM系统打交道的过程中,GC优化始终是一个绕不过去的话题,有的是因为GC导致NameNode RPC请求耗时增大,有的是因为GC导致RegionServer/HiveServer/Metastore经常宕机。在优化的过程中,笔者花时间系统地学习并梳理了CMS、G1GC以及ZGC这几款垃圾回收器的原理,并基于这些原理进行了多次线上GC问题的定位以及优化。这个系列的文章初步安排了多篇:

  1. 【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

  2. 【大内存服务GC实践】- "ParNew+CMS"组合垃圾回收器实践案例(一)

  3. 【大内存服务GC实践】- "ParNew+CMS"组合垃圾回收器实践案例(二

  4. 【大内存服务GC实践】- 一文看懂G1垃圾回收器

  5. 【大内存服务GC实践】- G1垃圾回收器实践案例(一)

  6. 【大内存服务GC实践】- G1垃圾回收器实践案例(二)

  7. 【大内存服务GC实践】- 一文看懂ZGC垃圾回收器

  8. 【大内存服务GC实践】- ZGC垃圾回收器实践案例

这是这个系列的第一篇文章,我们来聊聊"ParNew+CMS"垃圾回收器是怎么工作的。Java开发工程师同学肯定对这个组合垃圾回收器有所了解,因为这通常是面试内容的一部分。根据笔者的面试经验,大多数面试者是停留在比较基础、初级的理论知识上面的。无论是关于ParNew还是CMS垃圾回收器,网上其实有很多相关的介绍,但笔者觉得大部分都比较零散,没有系统完整地对其进行介绍。希望这篇文章能够比较全方位地、逻辑清晰地、深入地将"ParNew+CMS"垃圾回收器介绍清楚。


从我们的认知说起

面试"ParNew+CMS"相关知识,面试官一般会从这几个简单的问题开始:

  1. JVM内存为什么要分代?

  2. 新生代GC触发条件是什么?简单介绍一下新生代GC算法。

  3. 在哪些条件下对象会从新生代晋升到老年代?

  4. 老年代GC触发条件是什么?简单介绍一下老年代GC算法。

  5. FGC触发条件是什么?

  6. 如果一个Java系统(CMS回收器,下同)新生代GC耗时长,可以考虑从哪些方面分析优化?

  7. 如果一个Java系统(CMS回收器,下同)老年代GC耗时长,可以考虑从哪些方面分析优化?

  8. 如果一个Java系统频繁发生FGC,可以考虑从哪些方面分析优化?

这些问题粗粗一看还是容易回答的,但是如果面试官深入地问其中的一些细节,比如"CMS回收算法中Card Table的作用主要有哪些?",估计会难倒不少同学。另外,诸如6、7、8这三个实践类的问题,更是Java系统优化诊断专家的试金石。那这篇文章我们就深入地看看前面5个理论性质的问题,接下来一两篇文章再通过几个真实大数据生产线上的案例介绍"ParNew+CMS"组合回收器的优化实践。


JVM堆为什么要分代?

分代的垃圾回收策略,是基于两个假设:

  1. 不同对象的生命周期是不一样的。可以大体分成两类,一类称为短寿对象,这类对象存活时间很短,比如局部变量、短链接对象。与之对应的称为长寿对象,比如数据缓存、session对象等。

  2. 大部分Java应用中短寿对象占比都占绝大多数,这类对象可以很快就会被回收。

基于这两个事实假设,大多数GC算法都采用分代回收机制。将JVM堆划分成两个区域,一个小的新生代,一个大的老年代。新生代放置短寿对象,可以采用比较频繁的回收策略,每次可以回收掉大量的垃圾对象。老年代放置长寿对象,可以采用与新生代不同的回收策略。


新生代GC触发条件是什么?简单介绍一下新生代GC算法?

应用程序在Eden区生成新对象,一旦Eden区满了之后就会触发新生代GC,新生代GC算法使用复制算法。复制算法对Eden区以及S0区的对象进行标记,标记出活跃对象,然后将活跃对象复制到S1区。复制算法不会产生内存碎片。对于标记算法中如何判断一个对象是活跃的还是不活跃的(垃圾对象),现在一般使用可达性分析算法。

对吧,90%以上的同学都会这么回答。但这里面有很多细节没有讲清楚,比如标记使用的可达性分析算法是什么算法?具体如何标记一个对象?将活跃对象复制到S1之后,引用这些对象的指针如何变化?这些问题再深入地问下去,能回答出来的就少之又少了,但是理解这些基本知识是接下来深入理解G1\ZGC的基础,所以非常有必要在这里进行一番介绍。


如何判断一个对象是否活跃?

判断对象是否活跃目前一般使用可达性分析算法。通过一系列称为"GC Roots"的元素作为起始点,从这些节点开始向下搜索,当一个对象到GC Roots没有任何引用链相连时,则说明此对象是不活跃的。

"GC Roots"是什么?它本质上是一组活跃的引用,注意不是对象。容易理解的有线程栈上的引用变量、静态变量到对象的引用,在分代算法中从非收集区域指向收集区域对象的引用等。

新生代GC是Stop-The-World(以下简称STW)的,即只有标记线程工作,这样就可以将新生代堆想象成一个引用链快照,不会有应用线程去修改这个引用链,这样就可以使用深度优先算法进行引用遍历。JVM中具体的实现算法是三色标记算法,演示图(来自网上,下面相关图相同)如下:

我们把遍历对象图过程中遇到的对象,按"是否访问过"这个条件标记成以下三种颜色:

  • 白色:尚未访问过。

  • 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了。

  • 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问后,会转换为黑色。

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在【白色集合】中。

  2. 将 GC Roots 直接引用到的对象挪到 【灰色集合】中。

  3. 从灰色集合中获取对象:

(1)将本对象引用到的其他对象全部挪到 【灰色集合】中。

(2)将本对象挪到【黑色集合】里面。

  1. 重复步骤3,直至【灰色集合】为空时结束。

  2. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收。

当STW时,对象间的引用是不会发生变化的,可以轻松完成标记(这句话重点记得,下文还会提到)。如下图所示,标记完成后为A、D、E、F和G节点都变成了黑色,B、C和H都是白色,表示B、C、H三个对象GC Roots不可达,为垃圾对象:

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

如何标记一个活跃对象?

通过三色标记法可以找到所有的活跃对象,那怎么标记这些活跃对象呢?目前主要使用位图标记活跃对象,堆中每个对象都有一个对应的位图,如果是活跃对象,该位图设置为1,否则设置为0。

如何跨区迁移对象?

到目前为止,通过三色标记法我们找到了新生代中所有活跃对象,并将对应的位图进行了标记。接着,我们需要将这些活跃对象从Eden/S0区移动到S1区。整个移动过程分为如下3个子步骤:

  1. 在S1区为对象申请特定大小的内存。

  2. 初始化对象,并将对象的字段进行赋值。

  3. 全部迁移完成之后将Eden/S0区的所有对象清除。

如何修改引用对象指针地址?

迁移完成之后还有一个问题,既然这些活跃对象从一个地方迁移到了另一个地方,那所有引用这些对象的对象指针就需要相应地修改,对吧?那这里就有一个问题,引用新生代对象的目标对象怎么找到呢?

有一个方案是扫描整个老年代的所有对象,但是这样的效率必然不高,尤其是在老年代内存设置非常大的情况下效率就会更差。于是JVM算法设计者设计了使用一个Card Table记录老年代对象到新生代对象的引用方案。

那Card Table是个什么数据结构呢?我们将老年代这个连续的堆内存空间划分成连续的512Byte内存块,Card Table是一个连续的数组,数组的每个元素大小是1Byte,分别映射老年代堆内存的512Byte空间。如果老年代的某个对象产生了跨代引用,就将对应Card Table上的数组元素标记为Dirty,这个Card就是所谓的Dirty Card。如下示意图所示:

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

有了Card Table,就不需要扫描整个老年代空间,而只需要扫描Dirty Cards对应的堆空间,遍历这些堆空间的对象进行必要的调整即可,这样可以大大提升扫描的效率,调整完成之后将对应的Dirty Card重置。


Card Table上的Card什么时候会被设置为Dirty?

这里需要引入一个非常关键的概念 - 写屏障。写屏障可以简单理解为一个钩子函数,对一个对象引用进行写操作(即引用赋值)之前或之后附加执行的逻辑。JVM通过写屏障维护Card Table,如果某一个老年代对象引用一个新生代对象,在将引用赋值写入到内存之前,会执行一段特定代码将老年代对象所在内存区域对应的Card设置为Dirty。没错,这听起来就像AOP。


在哪些条件下对象会从新生代晋升到老年代?

  1. 躲过15次新生代GC后晋升到老年代(15是默认情况)。

  2. 大对象直接进入老年代。

  3. 动态对象年龄判断机制:假如当Survivor区中,相同年龄的对象总大小大于这Survivor区域总大小的50%,那么大于等于这批对象年龄的对象,在下次YGC后就会晋升到老年代。

  4. YGC后存活对象太多超过Survivor区大小,通过分配担保机制晋升到老年代。

老年代GC触发条件是什么?简单介绍一下老年代GC算法?

老年代使用内存占老年代实际大小比例超过一定阈值(可以通过参数-XX:CMSInitiatingOccupancyFraction配置)之后会触发老年代GC。老年代GC使用并发标记清理算法,算法分为初始标记、并发标记、预清理、可中断的预清理、再标记、并发清理以及并发重置状态等7个步骤,其中初始标记、再标记以及并发清理3个阶段是STW。下面是一段完整老年代GC的日志片段:

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

现在我们深入地分析一下这些步骤:

  1. 初始标记。从GC Roots集合以及新生代对象出发,标记直接引用的对象。示意图如下所示:

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

  1. 并发标记。从初识标记阶段标记出来的对象开始基于"三色标记法"找出所有存活对象并标记。这个阶段应用线程会和标记线程并发执行。假如此时只有标记线程工作,那并发标记前后的示意图如下:

【大内存服务GC实践】- 一文看懂"ParNew+CMS"组合垃圾回收器

然而实际上,应用线程会和标记线程一起并发执行,这就可能出现如下几种情况:

  • 对象引用被删除。

  • 应用线程直接在老年代分配新对象。

  • 新生代对象晋升到老年代。

  • 老年代对象之间引用发生变更。

这样的话,并发标记完成后可能不是上面的示意图,而是如下这种示意图:

分别来看这几种情况:

  • 对象引用被删除(上图场景1):假如一个对象在被标记为活跃对象之后引用关系被删除。因为该对象已经被标记为活跃对象,所以它不会在本次GC中被回收。但是理论上来讲,这个对象是应该被回收的。应该被回收的对象没有被回收,这种情况不影响正确性,但会产生"浮动垃圾"。这种现象称为“多标”。

  • 直接在老年代分配新对象(上图场景2):如果在标记线程执行结束之后应用线程重新new了一些新对象(比如大对象)并产生了引用关系。这些对象本应该被标记为活跃对象但实际上没有被标记,就会出现正确性问题。这是“并发标记”引入的第一个问题。

  • 新生代对象晋升到老年代(上图场景3):因为应用线程在工作,所以Eden区就可能会满,进而触发YGC,YGC之后就会有新生代对象晋升到老年代。晋升到老年代的对象本应该被标记为活跃对象但实际上没有被标记,就会出现正确性问题。与场景2类似。

  • 老年代对象之间引用发生变更(上图场景4):这个场景稍微复杂一点,使用下面的示意图分析一下。

假设标记线程已经遍历到对象B(回想三色标记法,对象B变为灰色),这个时候应用线程执行了如下代码:


  
    
    
  
objB.fieldC = null;objA.filedC = C;

对应示意图中对象B和对象C之间的引用关系断掉,然后对象A和对象C之间建立新的引用关系。注意对象A和对象C之间虽然建立了新的引用关系,但是对象A已经是黑色了,不会再重新做遍历处理了。最终导致的结果就是:对象C会一直是白色,最后被当作垃圾清理掉。很显然,这直接影响到了应用程序的正确性,是不可接受的。这种现象称为“漏标”,这是“并发标记”引入的第二个问题。

可见,并发标记可以让应用线程与标记线程一起工作,不需要STW。但是会引入如下两个问题:

  • 新增老年代对象没有被标记。

  • 引用变更导致的漏标问题。

很显然,这些新增对象必须被重新标记上。那怎么重新标记这些新增的对象呢?

对于场景2,只能重新扫描GC Roots。

对于场景3,只能重新扫描新生代对象。

对于场景4,如果在引用变更的时候记录下对应的对象,比如上述场景如果能够记录下对象A,重新标记的时候从对象A开始重新标记,就可以相对快速的完成重新标记。实际实现中再次用到了上文介绍到的Card Table,当引用发生变更时,将对象A所在的Card标记为Dirty。后续只需扫描这些Dirty Cards的对象,避免扫描整个老年代。

这里有一个问题,如果在老年代并发标记的过程中同时发生了一次YGC。上文我们说过YGC会扫描Card Table中的Dirty Cards,找到跨代引用,同时在YGC完成后将Dirty Cards清空。很显然,增量更新和YGC这两个过程共用Card Table会产生冲突,一旦YGC完成之后将某个Dirty Card清空,但是这个Dirty Card刚好是"并发标记"过程中引用变更标记的,就会导致漏标。为了解决这个问题,CMS算法引入另一种数据结构Mod Union Table,它是一个位数组,数组中每个元素分别对应一个Card。基于这个新结构,在每次YGC处理完脏卡之前,会将该Dirty Card在Mod Union Table中对应的数组位置1。这样CMS在执行重新标记阶段的时候,扫描Mod Union Table和Card Table里面被标记的项,找到所有可能的Dirty Card。

  1. 并发预处理。

  2. 可中断预处理。

这两个阶段都是为了尽可能降低重标记阶段的耗时,采用增量更新的方式重新标记"并发标记"阶段新增的对象。这两个阶段依然是应用线程和标记线程并发执行的,所以还是会有新增对象产生,不过数量会降低很多。

  1. 重标记。

经过上面两个阶段的预处理之后,需要重新标记的新增对象理论上应该不是很多了。这个阶段采用STW模式,最后一次标记遗留的新增对象:

  • 遍历GC Roots,标记直接关联的没有被标记的老年代对象以及引用链上的对象。

  • 遍历新生代对象,标记直接关联的没有被标记的老年代对象以及引用链上的对象。

  • 遍历老年代的Dirty Cards,重新标记。

"并发标记-预处理-重标记"这个过程,类似于我们使用Distcp工具迁移一个不断写入的表。通常使用如下策略:

  • 使用distcp全量拷贝一次数据。这个过程distcp和业务写入并发进行。(对应并发标记)

  • 使用distcp -update增量拷贝一次或者多次数据。这个过程distcp和业务写入并发执行。(对应并发预处理)

  • 经过上述两个步骤之后,可以认为需要增量拷贝的数据已经不多了。这个时候暂停写入,再使用distcp -update增量拷贝一次就完成了表的迁移。(对应重标记)

通过这种方式迁移对业务的影响应该是最低的。

  1. 并发清理。

经过上述一系列的标记之后,没有被标记的对象就一定是垃圾对象。这些垃圾对象会被并发清理释放内存空间。

  1. 并发重置。

进行Card Table等数据结构的重置等,为下一次GC做准备。

FGC触发条件是什么?

CMS垃圾回收器中FGC一旦发生,就会暂停所有应用线程,并退化成单线程进行垃圾回收,整个暂停耗时非常之长。CMS垃圾回收器一般有两种FGC触发条件:

  1. Concurrent Mode Failure模式FGC。上文我们讲过老年代使用内存占总堆大小超过阈值-XX:CMSInitiatingOccupancyFraction的话就会触发老年代GC。老年代GC的"并发标记"阶段是应用线程和标记线程一起工作的,假如在并发标记的过程中,不断有对象晋升到老年代最终导致老年代内存放不下这些对象的话,就会触发Concurrent Mode Failure模式FGC。根据字面意思也可以猜到这种FGC和并发执行有关系。

  2. Promotion Failure模式FGC。从字面意思来看是晋升失败,是一次新生代GC之后部分对象要晋升到老年代,但是老年代没有足够内存容纳这些对象导致FGC。通常来说,是因为老年代存在大量的内存碎片导致这种模式的FGC。


本文总结

这篇文章系统介绍了CMS垃圾回收器相关的理论知识,主要从实现原理层面解释了如下几个常见问题:

  1. CMS算法为什么要分代?

  2. 其中新生代GC触发条件是什么?简单介绍一下新生代GC算法。

  3. 在哪些条件下对象会从新生代晋升到老年代?

  4. 老年代GC触发条件是什么?简单介绍一下老年代GC算法。

  5. FGC触发条件是什么?

接下来一两篇文章将会基于本篇文章介绍CMS垃圾回收器在大数据生产线上的多个优化实践案例。


参考文章

https://segmentfault.com/a/1190000037752307?utm_source=tag-newest

https://zhuanlan.zhihu.com/p/105495961

https://www.cnblogs.com/jmcui/p/14165601.html

https://www.zhihu.com/question/287945354/answer/458761494

https://zhuanlan.zhihu.com/p/71058481

https://www.jianshu.com/p/2a1b2f17d3e4