5: CMS垃圾收集器

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

4: JVM GC垃圾收集器这篇博文中介绍了GC垃圾收集器中5种相对简单的垃圾收集器。这篇博文来介绍较为复杂的CMS垃圾收集器,也是目前的主流垃圾收集器。

CMS垃圾收集器

关键字:标记清除,并行,老年代
CMS(Concurrent Mark Sweep)垃圾收集器是一种采用标记清除算法的的垃圾收集器。其主要目标是获得更短的垃圾回收停顿时间。更短的停顿时间可以为交互性较强的程序提供更好的用户体验。
CMS垃圾收集的过程要经历两次Stop The World。虽然比Serial Old,Parallel Old这两种垃圾收集器多了一次Stop The World。但是CMS通过将耗时较严重的引用链分析(大部分的引用链分析都是并发完成的,但是有小部分引用链分析依然是在Stop The World期间完成)以及内存清理过程并发实现,使得其在停顿时间要比Serial Old, Parallel Old短的多。但是这种并发实现因为并发期间的对象在使用,无法进行移动,也就使得CMS无法使用标记整理算法,会出现堆内存碎片化的问题。
因为CMS的并发执行特性,使得CMS垃圾收集器不能等到老年代内存消耗完时才进行回收,它需要预留一部分内存用来存放GC过程中新产生的老年代对象。可以通过CMSInitiatingOccupancyFraction来控制老年代内存达到百分之多少时开始进行CMS GC。
CMS垃圾收集器通过三色标记法将对象标记为不同的状态,并根据标记状态进行相应的处理:

  • 白色:该对象未被标记过
  • 灰色:该对象自身被标记,但是内部引用未被处理(该对象可达,但是没做后续的引用链分析)。
  • 黑色:该对象自身被标记,且内部引用已被处理(该对象可达,且已分析该对象所在的引用链)。

CMS的垃圾收集有7个阶段,分别为:

初始标记(Initial-Mark)

初始标记阶段是CMS垃圾收集过程中第一个Stop The World产生的阶段,单线程执行。这个阶段标记(灰色标记)两类对象。一类是GC Roots对象。一类是有新生代对象引用的老年代对象。GC Roots对象的自无需多说,他是可达性分析法引用链的根节点。标记有新生代对象引用的老年代对象是因为CMS是老年代垃圾收集器,新生代的对象引用对于老年代对象来说依然属于老年代外部的引用,因此仍然有必要认为其引用链是有效的。
经历了初始标记:
在这里插入图片描述

并发标记(Concurrent-Mark)

并发标记的过程与用户线程同步进行。此阶段做两件事情,一件是以初始标记阶段所标记的对象为根节点进行引用链分析,并标记(黑色标记)引用链上的对象为存活。第二件是记录引用链分析标记过程中老年代引用关系产生变化的对象[注1]。因为并发标记和用户线程同步进行,这就意味着标记过程中可能会发生有新生代对象晋升入老年代,有新对象直接被分配入老年代,这种晋升的情况在重新标记阶段会重新扫描GC Roots对象以及新生代对象避免漏标。
对于老年代内部引用关系的变更,如下图A-C的新链,A已标位存活,所以GC Roots不会重复处理,这就会导致C的漏标,CMS处理这种漏标是采用增量更新的方式:标记的方法是为老年代堆内存设立一个Dirty Card表[注2],如果有对象在并发标记过程中出现了引用变更,那么就将其所在的Card标记为Dirty[注3],这样后面预清理和重新标记阶段就对Card中的所有对象都重新进行一遍引用分析(Dirty Card只标记了这个Card里有引用变更,而不是标记哪个对象的哪个链出现了变更,因此需要对所有对象的所有引用链都进行一次分析)。 G1采取起始快照算法解决这个问题,关于CMS为什么采用增量更新的方式,起始快照算法和增量更新的优缺点,为什么G1采取起始快照算法的原因及对比可见看下一节 G1垃圾收集器-5.2.3.1 SATB(起始快照算法 Snapshot at the beginning)
经历了并发标记:
在这里插入图片描述

注1:引用关系更新是指老年代对象对老年代对象引用更新,新生代对老年代新的引用并不算引用更新,原因上面提过,CMS是老年代垃圾收收集器,来自新生代的引用对老年代来说是外部引用,会在后面重新标记阶段处理。
注2:Dirty Card表并不是使用的Card Table,而是一种类似数据结构被称为ModUnionTalble的数据表。具体行为是将老年代堆内存空间分为一个个相等的小区间,每一个区间被称为Card Page,每个Card Page在Dirty Card表中会有一个对应的bit位标识其状态,如果一个对象的引用关系改变,写屏障逻辑就会将其所在的Card Page就会被标记位Dirty。注意Dirty标记是不是标记在对象上面的,而是标记到对象所在的Card Page上面。扫描时是扫描Card Page中的所有对象。一个Card Page可能不只有一个对象,那么并发情况一个Card Page的多个对象发生改变,那么都需要对Dirty Card表的同一位进行标记修改,这时间就会发生伪共享(false sharding:缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享)的问题。为了解决这个问题,JDK1.7加入一个参数-XX:+UseCondCardMark来控制是否进行有条件的写屏障,开启时就会判断目标Card Page是否已经被标记为Dirty,如果是就不再重复进行标记了.
注3:需要标记的是引用对象所在的Card而不是被引用对象,比如老年代中新增A->B,那么标记在A的Card,而不是标记在B的Card。原因是A引用B,扫描A所在Card时如果A是存活节点那么B也就是存活节点,A是待回收节点B也是待回收节点。如果标记在B上,那么就会出现只知道B被别人引用了,但是引用他的是一个存活节点还是死亡节点却无法判断。

预清理(Concurrent-PreClean)

在并发标记阶段CMS标记出了那些在并发标记阶段发生改变的Card,预处理阶段就是对这些Card中的对象进行引用链分析,进行存活性标记。因为这个阶段不会Stop The World,所以也会持续进行Dirty Card标记。
对于Card中的对象,只对初始标记时标记为存活的对象(拥有黑色标记的对象)进行引用链分析即可。非存活(白色标记)无需再分析引用链,因为根节点死亡(不可达),那么整个引用链都属于无效引用链。如图扫描Dirty Card时发现A时存活对象,那么对A进行分析,引用的有C和E,然后走A->C的搜索发现C未被标记,那么就对这个链进行分析分析结束,C和D被标记为存活对象,然后分析A->E,发现E已经是存活对象(拥有黑色标记),那么这个链就直接结束,因为这个链已经分析过了。即使后面的链引用有引用更新,那么也会在对后续引用更新对象所在的Dirty Card扫描时进行修正。至于B->G->H链,因为B是非存活对象,所以这个链也就没有分析的必要了。分析之后Dirty Card标识会被重置。

在这里插入图片描述

为什么需要这个预处理阶段呢?目的还是尽量的减少Stop The World的时间,引用链分析是一个比较耗时的过程,所以要让下次Stop The World时分析的引用链尽量少。初始标记标记了整个老年代,因此引用链会比较多,而变更链则是堆的一部分,变更链相对较少。因此整个堆的引用链分析所需的时间一定是远远大于对变更链的分析所需的时间的,更少的时间意味着更少的变更链,那么预处理期间新产生的变更链数目也会小于并发标记时产生的变更链数据目。所需分析的变更链数目减少,耗时自然也会减少。Stop The World时间也就更短。
可以这么理解:假设每10ms会产生一条引用链,分析一条链耗时1ms,初始标记时标记了1000条链,那么并行分析1000条链耗时1000ms,期间会新产生100条链,如果没有预处理阶段,那么下次Stop The World对100条变更链的分析需要100ms。但是预处理阶段对这100条变更链提前分析,需要100ms,着100ms又会产生10条变更链。此时再进行Stop The World对变更链分析则只需要10ms。
预处理阶段可以通过参数CMSPrecleaningEnabled选择关闭该阶段,默认是启用的。

可中断的预清理(Concurrent-Abortable-PreClean)

可中断的预清理阶段就是一直循环的去做预清理所做的事情。这一步的目的是期望再这个循环过程中能够发生一次新生代GC(Minor GC)。因为新生代对老年的引用算作有效引用,所以在重新标记阶段,需要对整个新生代进行扫描。然而新生代的大多数对象都是朝生夕死的的死亡对象,如果发生了一次Minor GC会回收新生代的大多数对象,此时后续重新标记阶段对新生代的扫描对象就大大减少,所需要的时间也减少。
当然循环不能一直进行下去,退出循环的条件有以下几个:

  1. CMSAbortablePreCleanTime:控制最大循环时间,超过这个时间便退出循环,默认为5,即循环5S
  2. CMSScheduleRemarkSizeThreshold与CMSScheduleRemarkEdenPenetration: 两者共同控制可中断预清理的启动阀值,都满足才会启动可中断的预清理。前者默认2M,即预清理后新生代内存大于2M时才启动可中断的预处理。后者默认为50%,即新生代的内存使用率在50%以上才启动可中断的预清理。这些限制的一是因为新生代较小时可中断的预处理收益很低,二是新生代内存使用率较低时可能需要较长的时间才会等到Minor GC,超过最大循环时间做无用等待。
  3. CMSMaxAbortablePreCleanLoops:控制循环次数,默认为0,即不扫描跳过该阶段。

另外也可以通过CMSScavengeBeforeRemark来强制重新标记之前进行一个Minor GC。默认关闭。这样做的缺点是如果之前刚进行过一次Minor GC,短时间又进行一次就非常糟糕了,而且Minor GC的Stop The World加上重新标记的Stop The World会使得此时连续停顿更长的时间。

重新标记(Remark)

重新标记阶段需要进行第二次Stop The World。这个阶段会遍历GC Roots重新标记,遍历新生代重新标记,遍历Dirty Card重新标记。在Stop The World下进行最后的修正,标记出老年代所有的存活对象。
经历了重新标记后:

在这里插入图片描述

并发清理(Concurrent-Sweep)

经过以上5个阶段,老年代的所有存活对象都已经被标记出来。此阶段对不可达对象进行回收,释放堆内存空间。
因为此阶段和用户线程一起运行,而用户线程依然会不断的的产生垃圾,这部分垃圾产生的标记过程之后,CMS无法在本次清理掉它,只能留待下次GC进行回收,这部分垃圾被称为浮动垃圾。
但是如果在并发清理阶段如果有对象晋升怎么办呢?新晋升的对象未被标记但是确实是正在使用,岂不是也会被当做垃圾回收掉?当然不会,GC清理的是标记为死亡的对象,对于未标记的对象则不会清理,因此新晋升的对象不在清理范围内。(F_QUEUE)

并发重置(Concurrent-Reset)

此阶段重置CMS的数据结构,以便下次收集时使用。此阶段和用户线程同步进行。

整个CMS GC的流程

在这里插入图片描述

Concurrent Mode Failure与CMS如何应对空间碎片化

Concurrent Mode Failure是CMS垃圾收集器特有的错误。CMS垃圾收集过程中,如果空间不足那么CMS就会进行Stop The World的Full GC,如果在Full GC进行过程中依然出现无法分配的对象的,就会产生这个错误。它表示CMS在清理过程中老年代的空间不足以容纳新的老年代。产生这个问题的原因主要有两个:
一是老年代的回收速度跟不上新的老年代产生的速度导致的空间不足。
二是有大对象被直接放入老年代导致的,这种情况有可能是空间不足,也有可能是老年代内存空间碎片化严重,导致没有足够的连续内存存放大对象。
解决空间不足的问题可以通过降低CMS GC的阀值提升回收频率或者调大老年代空间来解决。而空间碎片化严重的问题CMS的解决方案是在执行一定次数的Full GC之后执行一次标记整理算法以此来保持内存碎片率在一个可以接受的范围。可以通过-XX:UseCMSCompactAtFullCollection设置启用并通过-XX:CMSFullGCBeforeCompaction设置经历几次(默认是0)Full GC后执行标记整理算法。注意是CMS GC不足时导致的Full GC次数,而不是CMS GC次数。
值得注意的是一旦出现Concurrent Mode Failure,老年代垃圾收集器就会由CMS退化为Serial Old。这个过程是不可逆的。也就是说一旦退化,在程序重启之前,老年代会一直采用会产生Stop The World的Serial Old。因此应该避免出现Concurrent Mode Failure错误,如果出现也要及时处理。



PS: 开发成长之旅 [持续更新中...]
上篇导航:4: JVM GC 垃圾收集器
下篇导航:6: G1垃圾收集器
欢迎关注...

参考资料:
一文了解JVM全部垃圾回收器,从Serial到ZGC
JVM调优:CardTable简介
JVM之卡表(Card Table)
伪共享(false sharing),并发编程无声的性能杀手
图解CMS垃圾回收机制,你值得拥有
CMS垃圾回收器详解
CMS之promotion failed&concurrent mode failure

Supongo que te gusta

Origin juejin.im/post/7076331472721805326
Recomendado
Clasificación