0%

Java - CMS GC

CMS(ConcMarkSweep)GC,是GC发展史上,第一款支持“并发”的收集器。

串行收集器:GC过程中,需要STW(stop the word),并且只有一个线程在执行收集工作,比如Serial GC;

并行收集器:GC过程中,需要STW,并且开启多个线程同时执行收集工作,比如Parallel GC;

并发收集器:GC过程中,不需要全程STW,GC线程和用户线程是可以同时工作的,比如CMS GC。

使用-XX:+UseConcMarkSweepGC参数来启用ParNew,CMS进行垃圾收集,其中:

  • ParNew GC:负责Young GC;

  • CMS GC:负责Old GC,并且在进行Old GC的时候,GC线程绝大部分时间可以跟用户线程并行执行;

    注意,其他分代收集的GC组合中,基本上都是一款Young GC搭配一款Full GC,而CMS GC是只收集老年代。

    再注意,这里说的CMS GC是只收集老年代特指标准的Background模式的CMS GC,Foreground模式的CMS GC还是会收集整个堆的,后面会讲这二者的区别,这里大家还是先认为标准的CMS GC只收集老年代。

CMS GC的优势就是在进行老年代收集的时候,只有两次很短的STW,可以大大降低应用的暂停时间。

触发条件

CMS组合中,一共有针对不同分代的两种GC,其中负责年轻代收集的ParNew GC我们在这里已经讲过,就不再赘述了。

CMS GC触发条件

由于CMS GC中,绝大部分时间是并发的,GC线程在工作的时候,用户线程也在工作,因此如果等到老年代分配失败的时候才开始执行CMS GC,那么在GC过程中,用户线程任然有可能会再次分配对象失败,因此CMS GC使用的是一种悲观策略,即是每隔一段(默认是2秒,可以通过参数-XX:CMSWaitDuration来控制)时间判断一下老年代的使用率是否达到阈值,如果达到了阈值,便开始执行CMS GC,阈值的判断取决于多方面因素,最常规的配置是-XX:+UseCMSInitiatingOccupancyOnly参数与-XX:CMSInitiatingOccupancyFraction=N参数,其中:

  • 已开启UseCMSInitiatingOccupancyOnly参数(默认为开启状态)

    开启此参数,表示HotSpot VM总是使用-XX:CMSInitiatingOccupancyFraction=N的值作为老年代的使用率的阈值,N是百分比,例如N=90表示90%。如果未设置-XX:CMSInitiatingOccupancyFraction=N参数,则默认值为-1,实际运行中,会根据一套公式计算出来默认情况下(-1)的内存使用率为92%。

  • 未开启UseCMSInitiatingOccupancyOnly参数

    未开启此参数时,会根据历史统计的CMS GC耗时数据,以及下一次老年代被可能被耗尽的时间,来动态判断是否需要进行CMS GC。第一次CMS GC时没有历史数据供计算,这时会跟据老年代的使用率来进行判断是否要进行GC,第一次的默认值为50%。

除了常规的老年代使用率判断之外,还要一些其他方式也会触发CMS GC,例如:

  • 显示调用System.gc();

  • 配置了CMSClassUnloadingEnabled参数的同时,发生Metaspace扩容;

    这种情况通常会发生在应用刚启动的时候,由于没有设置Metaspace的大小,导致加载类的时候发生了扩容,可以通过观察GC时,Metaspace的capacity大小变化来确定。

Background与Foreground模式的CMSGC触发条件

上面那种触发条件,主要是触发的Background CMS GC,这种GC是并行执行的,效率比较高,但是还有两种情况可能会触发Foreground CMS GC:

  • Promotion Failed

    CMS GC执行Old GC的时候,不会进行内存碎片整理的工作,因此随着应用的运行,碎片化问题会更加严重。

    某个对象需要晋升到老年代之前,判断老年代未使用空间是否足够,如果判断空间足够,而真正晋升的时候却发现找不到一块足够大的连续内存空间来存放此对象,导致晋升失败,这种错误称之为Promotion Failed。

  • Concurrent Mode Failure(CMF)

    老年代的使用率达到阈值之后会触发一次CMS GC,如果在执行GC的过程中,应用线程又向老年代请求分配内存,并且请求分配的空间超过了老年代剩余的空间,就会触发CMF。触发这种错误之后,正在执行的CMS GC会被打断,对各种状态进行复位。

    另外,当Promotion Failed被触发时,CMS GC正在工作,则CMS GC会被打断,并输出Concurrent Mode Failure。

发生前者错误的原因是因为CMS GC的碎片化的问题,发生后者错误的原因是GC速度赶不上应用线程的内存申请速度,当触发这两种错误之后,会触发一次Foreground CMS GC,他会进行一次压缩式GC,此压缩式GC使用的是跟Serial Old GC一样的Lisp2算法,其使用Mark-Compact来做Full GC,一般称之为MSC(Mark-Sweep-Compact),它收集的范围是Java堆的Young区和Old区以及 MetaSpace。Compact的代价是巨大的,所以使用Foreground CMS GC时将会带来非常长的STW。

在JDK9中,彻底去掉了这两个参数以及foreground GC模式,具体见:JDK-8010202: Remove CMS foreground collection

步骤

CMS收集器采用的是“标记-清除”算法,有以下几个步骤:

  • 初始标记(CMS-initial-mark)

    初始标记阶段会stop the word,然后标记GC Roots能直接关联到的对象(与GC Roots间接关联的对象在这一步不会被扫描)。这个过程,在JDK8之前是单线程的,JDK8之后是多线程并行的,可以通过-XX:+CMSParallelInitialMarkEnabled参数控制。

    注意,从GC堆的非收集部分指向收集部分的引用,也必须作为GC roots的一部分,见Java - GC算法

    1

    如上图,从GC Roots出发,标记直接关联到的对象(黑色实线箭头代表引用关系)。

  • 并发标记(CMS-concurrent-mark)

    并发标记阶段并不需要stop the word,GC线程将和用户线程并存。这一阶段的主要目标,还是做可达性分析,从第一步中找到的对象出发,继续查找所有相关联的对象。

    2

    由于这个阶段,GC线程和用户线程都在运行,所以可能会出现原本有引用关系的对象,在这个阶段扫描之前删除了引用关系;有可能会出现原本没有引用关系的对象,扫描之后建立了新的引用关系;年轻代的对象晋升到老年代;还有可能用户线程在老年代分配了新内存存储对象等等。

    CMS采用Incremental update的机制来解决并发阶段的冲突。

    并发GC都会面临这种并发冲突的问题,都会有各自的处理方式,比如G1的SATB(Snapshot At The Beginning)。

    • 三色标记算法

      一个对象可以被标记为三种颜色:

      • 黑色:对象已经被标记了,并且它的所有field也被标记完了;
      • 灰色:对象已经被标记了,但是它的field还没有被标记;
      • 白色:对象还没有被标记到,标记阶段结束后,白色对象会被当做垃圾回收掉。
    • Card Table

      CMS将老年代分为很多个Card,然后用一个Card Table(实际上就是一个字节数组)管理,每个下标对应到一个Card,当并发标记过程中,某个对象的引用关系发生了变化,则将对象所在的Card标记为Dirty Card,供下一步使用。

      另外,如果一个老年代对象引用了年轻代的对象,那么也会在Card Table中也会进行记录,这样可以辅助Young GC,快速找到老年代中,哪些对象引用了年轻代的对象(GC Roots的一部分),而不必扫描整个老年代。

      如下图中,右下角的黑色对象,即是在并发标记阶段,建立的引用关系,则黑色对象所在的Card会被标记。

    3

    • Write Barrier

      CMS利用Write Barrier拦截所有新插入的引用关系,然后记录新的引用关系,在Card Table中记录改变的引用的出发端对应的Card。

  • 并发预清理(CMS-concurrent-preclean)

    这个阶段会处理前一个阶段被标记为Dirty Card的部分,将其中变化了的对象作为Root再进行扫描并重新标记:

    4

  • 可被终止的并发预清理(CMS-concurrent-abortable-preclean)

    这个阶段是JDK1.5中才加入的,这并不是一个一定会发生的阶段,见此文中的描述:

    After ‘concurrent preclean’ if the Eden occupancy is above CMSScheduleRemarkEdenSizeThreshold, we start ‘concurrent abortable preclean’ and continue precleanig until we have CMSScheduleRemarkEdenPenetration percentage occupancy in eden, otherwise we schedule ‘remark’ phase immediately.

    即是当上一阶段完成之后,如果Eden区的内存占用大于CMSScheduleRemarkEdenSizeThreshold(默认2M)时,才会启动这个阶段,然后直到Eden的内存使用率达到CMSScheduleRemarkEdenPenetration(默认50%)时才会结束。

    不过这里还有两个参数没有提到:

    • CMSMaxAbortablePrecleanTime(默认5s),表示开始执行之后,这个阶段最多只执行这么长时间,时间到了之后也会结束,然后开启下一个阶段;
    • CMSMaxAbortablePrecleanLoops(默认为0),表示最大的循环次数限制,为0表示不限制循环次数。

    这个阶段主要的工作与上一阶段类似,也是执行预清理工作,并且一旦开启之后,会不断循环工作直到满足终止条件,其目的减少下一阶段remark的stop the word的耗时,尽量将工作在不需要stop the word的阶段,使用多线程并行执行。

  • 重新标记(CMS-remark)

    由于前面的几个预清理阶段都是与用户线程并存的,GC线程一边在标记,而用户线程一边在操作对象,无法做到完全无漏的标记存活对象,因此需要一个stop the word的阶段,再来一次完整的遗留对象的扫描,remark便是这个阶段。

    remark阶段,会重新遍历GC Roots进行标记,并且也会处理老年代中被标记为Dirty Card的内存。

    • CMSScavengeBeforeRemark参数

      这个阶段会重新遍历GC Roots,由于是老年代GC,因此GC Roots中包含了新生代对象中存活的对象,如果全量扫描新生代对象,那么效率会有点低,因此设计了CMSScavengeBeforeRemark参数,默认没有开启,如果开启此参数之后,会在remark之前来一次Young GC,Young GC之后,我们只需要扫描from区,就可以得到所有新生代中存活的对象。

      不过这个参数也有个弊端,因为Young GC之后会减少remark的停顿时间,但是Young GC本身也会stop the word,所以需要根据实际情况来考虑。

  • 并发清除(CMS-concurrent-sweep)

    多线程并发清楚阶段,这个阶段主要工作是清除所有没有被引用的对象,不需要stop the word。

    5

  • 并发重置状态等待下次CMS的触发(CMS-concurrent-reset)

    并发重置阶段,将清理并恢复在CMS GC过程中的各种状态,重新初始化CMS相关数据结构,为下一个垃圾收集周期做好准备。

日志分析

我们抓取一次CMS GC的日志来分析一下上述流程,完整日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
2020-03-04T17:53:42.110-0800: [GC (CMS Initial Mark) [1 CMS-initial-mark: 37888K(51200K)] 44442K(97280K), 0.0041126 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
2020-03-04T17:53:42.114-0800: [CMS-concurrent-mark-start]
2020-03-04T17:53:42.114-0800: [CMS-concurrent-mark: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-03-04T17:53:42.114-0800: [CMS-concurrent-preclean-start]
2020-03-04T17:53:42.115-0800: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-03-04T17:53:42.115-0800: [CMS-concurrent-abortable-preclean-start]
CMS: abort preclean due to time 2020-03-04T17:53:47.191-0800: [CMS-concurrent-abortable-preclean: 0.016/5.076 secs] [Times: user=0.02 sys=0.00, real=5.08 secs]
2020-03-04T17:53:47.191-0800: [GC (CMS Final Remark) [YG occupancy: 6554 K (46080 K)][Rescan (parallel) , 0.0047474 secs][weak refs processing, 0.0000262 secs][class unloading, 0.0007123 secs][scrub symbol table, 0.0006470 secs][scrub string table, 0.0002055 secs][1 CMS-remark: 37888K(51200K)] 44442K(97280K), 0.0065249 secs] [Times: user=0.03 sys=0.00, real=0.00 secs]
2020-03-04T17:53:47.198-0800: [CMS-concurrent-sweep-start]
2020-03-04T17:53:47.198-0800: [CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-03-04T17:53:47.198-0800: [CMS-concurrent-reset-start]
2020-03-04T17:53:47.198-0800: [CMS-concurrent-reset: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

其中部分固定格式的部分为:

  • 2020-03-04T17:53:42.110-0800:发生时间;
  • CMS Initial Mark、CMS-concurrent-mark…:标识不同的CMS阶段。

其他字段:

  • [GC (CMS Initial Mark) [1 CMS-initial-mark: 37888K(51200K)] 44442K(97280K), 0.0041126 secs]

    • 37888K(51200K):老年代的使用情况(老年代的总大小);
    • 44442K(97280K):整个堆的使用情况(整个堆的总大小);
    • 0.0041126 secs:当前阶段的耗时;
  • [CMS-concurrent-mark: 0.000/0.000 secs]

    • 0.000/0.000 secs:该阶段持续的时间和时钟时间;
  • [CMS-concurrent-preclean: 0.000/0.000 secs]

    • 0.000/0.000 secs:该阶段持续的时间和时钟时间;
  • CMS: abort preclean due to time 2020-03-04T17:53:47.191-0800: [CMS-concurrent-abortable-preclean: 0.016/5.076 secs]

    • 0.016/5.076 secs:该阶段持续的时间和时钟时间;

      It is interesting to note that the user time reported is a lot smaller than clock time. Usually we have seen that real time is less than user time, meaning that some work was done in parallel and so elapsed clock time is less than used CPU time. Here we have a little amount of work – for 0.016 seconds of CPU time, and garbage collector threads were doing a lot of waiting. Essentially, they were trying to stave off for as long as possible before having to do an STW pause. By default, this phase may last for up to 5 seconds

  • [GC (CMS Final Remark) [YG occupancy: 6554 K (46080 K)][Rescan (parallel) , 0.0047474 secs][weak refs processing, 0.0000262 secs][class unloading, 0.0007123 secs][scrub symbol table, 0.0006470 secs][scrub string table, 0.0002055 secs][1 CMS-remark: 37888K(51200K)] 44442K(97280K), 0.0065249 secs]

    • [YG occupancy: 6554 K (46080 K)]:年轻代的使用情况(年轻代的总大小);
    • [Rescan (parallel) , 0.0047474 secs]:整个Final Remark阶段中,扫描对象的耗时;
    • [weak refs processing, 0.0000262 secs]:处理弱引用的耗时;
    • [class unloading, 0.0007123 secs]:卸载无用的类的耗时;
    • [scrub symbol table, 0.0006470 secs]:
    • [scrub string table, 0.0002055 secs]:
    • [1 CMS-remark: 37888K(51200K)]:老年代的使用情况(老年代的总大小);
    • 44442K(97280K):整个堆的使用情况(整个堆的总大小);
    • 0.0065249 secs:整个Final Remark阶段的耗时;
  • [CMS-concurrent-sweep: 0.000/0.000 secs]

    • 0.000/0.000 secs:该阶段持续的时间和时钟时间;
  • [CMS-concurrent-reset: 0.001/0.001 secs]

    • 0.001/0.001 secs:该阶段持续的时间和时钟时间;

缺点

CPU资源敏感

虽然在两个并发阶段不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是(CPU数量+3)/4。

Concurrent Mode Failure(CMF)

你没办法保证,在发生CMS GC时候,预留的空间肯定足够应用线程使用,因此Concurrent Mode Failure错误会变成一个必须要考虑的场景。

最常见的优化这个场景的办法,是调整CMSInitiatingOccupancyFraction参数的值,即是控制触发CMS GC的时机,但是这个值如果太大了,那么会增大CMF发生的概率(因为预留给应用线程的内存空间不多);如果这个值设置小了,那么会增加CMS GC发生的频率,通常考虑如下两个因素来设置:

  • 老年代常驻内存大小:参数设置一定要比常驻内存更大,比如常驻内存大小为60%,那么这个值要考虑大于60%的范围。
  • 年轻代和老年代的比例:比如年轻代和老年代的比例为1:4,即新生代占用老年代的25%,那么老年代可以预留出至少25%的空间,至少容纳一次Young GC之后的晋升,即是比例要考虑小于75%的范围。

结合上面两个因素,则参数的合理范围为60%-75%之间。

内存碎片

由于CMS收集器是一个基于“标记-清除”算法的收集器,那么意味着收集结束会产生大量碎片,有时候往往还有很多内存未使用,可是没有一块连续的空间来分配一个对象,导致不得不提前触发一次Full GC。

而在做Full GC的时候,如果满足如下场景,则会进行内存碎片的压缩:

Full GC采用的是mark-sweep-compact算法,但compaction是可选的。

1
2
3
4
5
*should_compact = UseCMSCompactAtFullCollection && 
((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) ||
GCCause::is_user_requested_gc(gch->gc_cause()) ||
gch->incremental_collection_will_fail(true)
);
  • UseCMSCompactAtFullCollection参数

    此参数默认为true,表示启用在Full GC时候进行内存整理。

  • CMSFullGCsBeforeCompaction参数

    用来设置上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入Full GC的时候都会做压缩。

    _full_gcs_since_conc_gc参数在每次CMS GC的CMS-concrrent-sweep阶段会被重置为0。

  • System.gc()

    用户调用了System.gc(),而且DisableExplicitGC没有开启。

  • 增量收集是否会失败

    我们先看看incremental_collection_will_fail函数的源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    bool incremental_collection_will_fail(bool consult_young) {
    // Assumes a 2-generation system; the first disjunct remembers if an
    // incremental collection failed, even when we thought (second disjunct)
    // that it would not.
    assert(heap()->collector_policy()->is_two_generation_policy(),
    "the following definition may not be suitable for an n(>2)-generation system");
    return incremental_collection_failed() ||
    (consult_young && !get_gen(0)->collection_attempt_is_safe());
    }
    • incremental_collection_failed:表示增量收集已经失败了,通常是因为老年代没有足够的空间来容纳晋升的对象,比如常见的Promotion Failed。
    • !get_gen(0)->collection_attempt_is_safe():是指年轻代的晋升可能会失败,通过历史Young GC的晋升大小,以及老年代剩余的空间大小做对比,如果老年代不够,则表示晋升可能会失败。

偏方

另外还有一些偏方,比如在每天访问量最低的时候,比如凌晨3-4点,主动触发一次满足内存整理的Full GC,例如:

  • 调用System.gc(),注意DisableExplicitGC不能开启;

  • jmap -histo:live命令。

废弃

尽管在针对应用情况进行参数配置优化之后,CMS GC能够获得很好的GC效果,但是在JDK 9中,CMS GC仍然被废弃,如果使用-XX:+UseConcMarkSweepGC参数开启CMS GC的话,会打印告警信息:Java HotSpot(TM) 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.

CMS GC是一种高度可配置的复杂算法,除了通用的针对GC算法的50多个参数之外,CMS GC还有额外的72个参数(作为对比,G1 GC只有额外的26个,ZGC甚至只有8个),这会大大加重代码的复杂性,因此JDK团队选择废弃CMS GC,轻装上阵。

如果之前使用CMS GC的同学,也可以考虑转到JDK默认的G1 GC上。

参考



-=全文完=-