0%

Concurrent Mark Sweep(CMS)

前面 我们介绍了 Serial GC 与 Parallel GC 的使用与配置,今天我们来介绍Concurrent Mark Sweep(CMS) 收集器。

概述

CMS(Concurrent Mark and Sweep 并发-标记-清除),是一款基于并发、使用 mark-sweep(标记-清理) 算法的垃圾回收算法,只针对老年代进行垃圾回收。CMS收集器工作时,尽可能让GC线程和用户线程并发执行,以达到降低STW时间的目的。

CMS收集器的主要目的:减少老年代垃圾收集的停顿时间。主要通过两种手段来达成此目的:

  • 不对老年代进行整理,而是使用空闲列表(free-list)来管理内存空间的回收。
  • 在mark-sweep阶段采用并发方式与用户线程一起执行。

启动参数:

1
XX:+UseConcMarkSweepGC

控制参数

  • -XX:+UseConcMarkSweepGC:新生代使用并行收集器,老年代使用 CMS+串行收集器。

  • -XX:+ParallelCMSThreads:设定 CMS 的线程数量。

  • -XX:+CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发,默认为 68%。

  • -XX:+UseFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。

  • -XX:+CMSParallelRemarkEndable: 启用并行重标记。

  • -XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收。

  • -XX:CMSInitatingPermOccupancyFraction:当永久区占用率达到这一百分比后,启动 CMS 回收 。

    前提条件: -XX:+CMSClassUnloadingEnabled

  • -XX:UseCMSInitatingOccupancyOnly:表示只在到达阈值的时候,才进行 CMS 回收。

  • -XX:+CMSIncrementalMode:使用增量模式,比较适合单 CPU。

  • -XX:+UseCMSCompactAtFullCollection:(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。

新生代垃圾回收

能与CMS搭配使用的新生代垃圾收集器有Serial收集器和ParNew收集器。这2个收集器都采用标记复制算法,都会触发STW事件,停止所有的应用线程。不同之处在于,Serial是单线程执行,ParNew是多线程执行。

具体介绍,请看前一篇文章:https://wangwei.one/posts/jvm-gc-serial-and-parallel.html

老年代垃圾回收

CMS

CMS的主要分为5个阶段:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)、重置。

Minor GC
1
2016-06-18T11:20:33.960-0800: 4.118: [GC2016-06-18T11:20:33.961-0800: 4.119: [ParNew: 52480K->6528K(59008K), 0.0115760 secs] 52480K->6679K(255616K), 0.0120110 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]

日志分析和 前面 差不多,这里不细说

Full GC

此前我们介绍的GC日志格式都差不多,而CMS Full GC的格式则完全不同,如下是CMS对老年代进行垃圾收集各阶段的日志输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
2016-06-18T11:20:41.197-0800: 11.355: [GC [1 CMS-initial-mark: 98970K(196608K)] 106560K(255616K), 0.0020160 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2016-06-18T11:20:41.199-0800: 11.357: [CMS-concurrent-mark-start]
2016-06-18T11:20:41.263-0800: 11.421: [CMS-concurrent-mark: 0.063/0.063 secs] [Times: user=0.10 sys=0.01, real=0.07 secs]
2016-06-18T11:20:41.263-0800: 11.421: [CMS-concurrent-preclean-start]
2016-06-18T11:20:41.264-0800: 11.422: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2016-06-18T11:20:41.264-0800: 11.422: [CMS-concurrent-abortable-preclean-start]
2016-06-18T11:20:41.365-0800: 11.523: [GC2016-06-18T11:20:41.365-0800: 11.523: [ParNew2016-06-18T11:20:41.372-0800: 11.530: [CMS-concurrent-abortable-preclean: 0.007/0.108 secs] [Times: user=0.08 sys=0.02, real=0.11 secs]
: 59008K->6528K(59008K), 0.0084290 secs] 157978K->108370K(255616K), 0.0085350 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2016-06-18T11:20:41.374-0800: 11.532: [GC[YG occupancy: 7578 K (59008 K)]2016-06-18T11:20:41.374-0800: 11.532: [Rescan (parallel) , 0.0027870 secs]2016-06-18T11:20:41.377-0800: 11.535: [weak refs processing, 0.0027290 secs]2016-06-18T11:20:41.380-0800: 11.538: [scrub string table, 0.0007810 secs] [1 CMS-remark: 101842K(196608K)] 109420K(255616K), 0.0066720 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2016-06-18T11:20:41.382-0800: 11.540: [CMS-concurrent-sweep-start]
2016-06-18T11:20:41.412-0800: 11.570: [CMS-concurrent-sweep: 0.031/0.031 secs] [Times: user=0.05 sys=0.01, real=0.03 secs]
2016-06-18T11:20:41.413-0800: 11.571: [CMS-concurrent-reset-start]
2016-06-18T11:20:41.413-0800: 11.571: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

阶段 1: Initial Mark(初始标记 )。这是第一次STW事件。此阶段的目标是标记老年代中所有存活的对象,包括 GC Roots 的直接引用,以及由年轻代中存活对象所引用的对象。

04_06_g1-06

1
2016-06-18T11:20:41.197-0800: 11.355: [GC [1 CMS-initial-mark: 98970K(196608K)] 106560K(255616K), 0.0020160 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

CMS-initial-mark:表示CMS垃圾收集第一个阶段——初始标记。

98970K(196608K):表示老年代当前的使用总量为 98970K,老年代总的内存数量为:196608K

106560K(255616K):表示当前堆的使用总量为 106560K,堆内存总量为:255616K

阶段 2: Concurrent Mark(并发标记 )。在此阶段,垃圾收集器遍历老年代,标记所有的存活对象,从前一阶段 “Initial Mark” 找到的 root 根开始算起。 顾名思义,“并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。 请注意,并非所有老年代中存活的对象都在此阶段被标记,因为在标记过程中对象的引用关系还在发生变化。

04_07_g1-07

1
2
2016-06-18T11:20:41.199-0800: 11.357: [CMS-concurrent-mark-start]
2016-06-18T11:20:41.263-0800: 11.421: [CMS-concurrent-mark: 0.063/0.063 secs] [Times: user=0.10 sys=0.01, real=0.07 secs]

CMS-concurrent-mark-start:并发标记开始

CMS-concurrent-mark:CMS垃圾回收的并发标记阶段。

0.063/0.063 secs:此阶段的持续时间,分别是运行时间和相应的实际时间。

阶段 3: Concurrent Preclean(并发预清理)。此阶段同样是与应用线程并行执行的,不需要停止应用线程。 因为前一阶段是与程序并发进行的,可能有一些引用已经改变。如果在并发标记过程中发生了引用关系变化,JVM会(通过“Card”)将发生了改变的区域标记为“脏”区(这就是所谓的 卡片标记Card Marking )。

04_08_g1-08

在预清理阶段,这些脏对象会被统计出来,从他们可达的对象也被标记下来。此阶段完成后, 用以标记的 card 也就被清空了。

04_09_g1-09

此外,本阶段也会执行一些必要的细节处理,并为 Final Remark 阶段做一些准备工作。

1
2
2016-06-18T11:20:41.263-0800: 11.421: [CMS-concurrent-preclean-start]
2016-06-18T11:20:41.264-0800: 11.422: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]

CMS-concurrent-preclean:并发预清理阶段,统计此前的标记阶段中发生了改变的对象。

0.001/0.001 secs:此阶段的持续时间,分别是运行时间和对应的实际时间。

阶段 4: Concurrent Abortable Preclean(并发可取消的预清理)。 此阶段也不停止应用线程。本阶段尝试在 STW的 Final Remark 之前尽可能地多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件( 如迭代次数、有用工作量、消耗的系统时间等等)。

1
2
3
2016-06-18T11:20:41.264-0800: 11.422: [CMS-concurrent-abortable-preclean-start]
2016-06-18T11:20:41.365-0800: 11.523: [GC2016-06-18T11:20:41.365-0800: 11.523: [ParNew2016-06-18T11:20:41.372-0800: 11.530: [CMS-concurrent-abortable-preclean: 0.007/0.108 secs] [Times: user=0.08 sys=0.02, real=0.11 secs]
: 59008K->6528K(59008K), 0.0084290 secs] 157978K->108370K(255616K), 0.0085350 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

CMS-concurrent-abortable-preclean:并发可取消的预清理。

阶段 5: Final Remark(最终标记)。这是此次GC事件中第二次(也是最后一次)STW阶段。本阶段的目标是完成老年代中所有存活对象的标记。因为之前的 preclean 阶段是并发的,有可能无法跟上应用程序的变化速度。所以需要 STW暂停来处理复杂情况。

通常CMS会尝试在年轻代尽可能空的情况运行 final remark 阶段,以免接连多次发生 STW 事件。

Java7的日志如下

1
2016-06-18T11:20:41.374-0800: 11.532: [GC[YG occupancy: 7578 K (59008 K)]2016-06-18T11:20:41.374-0800: 11.532: [Rescan (parallel) , 0.0027870 secs]2016-06-18T11:20:41.377-0800: 11.535: [weak refs processing, 0.0027290 secs]2016-06-18T11:20:41.380-0800: 11.538: [scrub string table, 0.0007810 secs] [1 CMS-remark: 101842K(196608K)] 109420K(255616K), 0.0066720 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

Java8的日志如下

1
2016-06-18T16:10:41.353-0800: 11.046: [GC (CMS Final Remark) [YG occupancy: 7576 K (59008 K)]11.046: [Rescan (parallel) , 0.0048835 secs]11.051: [weak refs processing, 0.0007074 secs]11.052: [class unloading, 0.0145890 secs]11.067: [scrub symbol table, 0.0027903 secs]11.069: [scrub string table, 0.0023910 secs][1 CMS-remark: 104562K(196608K)] 112139K(255616K), 0.0303861 secs] [Times: user=0.05 sys=0.01, real=0.03 secs]

[YG occupancy: 7578 K (59008 K)]:年轻代使用量7578K,总量为 59008 K。

[Rescan (parallel) , 0.0027870 secs]:应用程序暂定,重新扫描,完成所有存活对象的标记。耗时:0.0027870秒

[weak refs processing, 0.0027290 secs]:第一个子阶段,处理弱引用的

[class unloading, 0.0145890 secs]:第二个子阶段,卸载不使用的类。(Java8多出了的一步)

[scrub string table, 0.0007810 secs]:第三个子阶段,清洗内部化字符串对应的 string tables

101842K(196608K):此阶段完成后老年代的使用量和总量

109420K(255616K):此阶段完成后整个堆内存的使用量和总量

阶段 6: Concurrent Sweep(并发清除 ) 。此阶段与应用程序并发执行, 不需要STW停顿。目的是删除未使用的对象,并收回他们占用的空间。

04_10_g1-10

1
2
2016-06-18T11:20:41.382-0800: 11.540: [CMS-concurrent-sweep-start]
2016-06-18T11:20:41.412-0800: 11.570: [CMS-concurrent-sweep: 0.031/0.031 secs] [Times: user=0.05 sys=0.01, real=0.03 secs]

CMS-concurrent-sweep: 0.031/0.031 secs :并发清理阶段,清理掉不再使用的垃圾对象。

阶段 7: Concurrent Reset(并发重置) 。此阶段与应用程序并发执行,重置CMS算法相关的内部数据,为下一次GC循环做准备。

1
2
2016-06-18T11:20:41.413-0800: 11.571: [CMS-concurrent-reset-start]
2016-06-18T11:20:41.413-0800: 11.571: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

CMS-concurrent-reset: 0.000/0.000 secs:并发重置阶段,以及使用时间。

缺点

  • 对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/4。CMS在并发阶段会占用部分CPU,导致用户应用程序执行速度变慢,影响用户体验。

  • 无法处理浮动垃圾(Floating Garbage)。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction 设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。

  • 会产生大量的内存碎片空间。可以通过 -XX:UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 这个两个参数来解决问题。

CMS常见问题

最终标记阶段停顿时间过长问题

CMS的GC停顿时间约80%都在最终标记阶段(Final Remark),若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发Young GC清理这些无效引用

通过添加参数:-XX:+CMSScavengeBeforeRemark。在执行最终操作之前先触发Young GC,从而减少新生代对老年代的无效引用,降低最终标记阶段的停顿,但如果在上个阶段(并发可取消的预清理)已触发Young GC,也会重复触发Young GC。

并发模式失败(concurrent mode failure)

1
2
3
4
5
6
[ParNew: 629120K ->629120K(629120K) , 9.0600200 secs]
[CMS-concurrent-mark: 2.683/2.804 secs]
[Times: user=4.81 sys=0.02, real=2.80 secs](concurrent mode failure):
1378132K->1366755K( 1398144K) , 5.6213320 secs ]2007252K->1366755K( 2027264K) ,
[CMS Perm : 57231K->57222K(95548K)], 5.6215150 secs]
[Times: user=5.63 sys=0.00, real=5.62 secs]

当CMS在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程的Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收

晋升失败(promotion failed)

1
2
3
4
[ParNew (promotion failed): 614254K->629120K(629120K), ©.1619839 secs]
[CMS: 1342523K->1336533K(2027264K), 30.7884210 secs ]2004251K- >1336533K(1398144K),
[CMS Perm : 57231K->57231K(95548K) ], 28.1361340 secs]
[Times: user=28.13 sys=0.38, real=28.13 secs]

当新生代发生垃圾回收,老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败,此时会触发单线程且带压缩动作的Full GC

并发模式失败晋升失败都会导致长时间的停顿,常见解决思路如下:

  • 降低触发CMS GC的阈值,即参数-XX:CMSInitiatingOccupancyFraction的值,让CMS GC尽早执行,以保证有足够的空间
  • 增加CMS线程数,即参数-XX:ConcGCThreads
  • 增大老年代空间
  • 让对象尽量在新生代回收,避免进入老年代

内存碎片问题

通常CMS的GC过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩,常见以下场景会触发内存碎片压缩:

  • 新生代Young GC出现新生代晋升担保失败(promotion failed)
  • 程序主动执行System.gc()

可通过参数 -XX:CMSFullGCsBeforeCompaction 的值,设置多少次Full GC触发一次压缩,默认值为0,代表每次进入Full GC都会触发压缩,带压缩动作的算法为前面提到的单线程Serial Old算法,暂停时间(STW)时间非常长,需要尽可能减少压缩时间。

参考资料

☕️😊