前面 我们介绍了 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” 失败,性能反而降低。

  • 会产生大量的内存碎片空间。标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS 也提供了参数 -XX:CMSFullGCsBeForeCompaction(默认 0,即每次都进行内存整理) 来指定多少次 CMS 收集之后,进行一次压缩的 Full GC。

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) 时间非常长,需要尽可能减少压缩时间。

参考资料