前面 我们介绍了 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的主要分为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 | 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] |
阶段 1: Initial Mark(初始标记 )。这是第一次STW事件。此阶段的目标是标记老年代中所有存活的对象,包括 GC Roots 的直接引用,以及由年轻代中存活对象所引用的对象。
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 根开始算起。 顾名思义,“并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。 请注意,并非所有老年代中存活的对象都在此阶段被标记,因为在标记过程中对象的引用关系还在发生变化。
1 | 2016-06-18T11:20:41.199-0800: 11.357: [CMS-concurrent-mark-start] |
CMS-concurrent-mark-start
:并发标记开始
CMS-concurrent-mark
:CMS垃圾回收的并发标记阶段。
0.063/0.063 secs
:此阶段的持续时间,分别是运行时间和相应的实际时间。
阶段 3: Concurrent Preclean(并发预清理)。此阶段同样是与应用线程并行执行的,不需要停止应用线程。 因为前一阶段是与程序并发进行的,可能有一些引用已经改变。如果在并发标记过程中发生了引用关系变化,JVM会(通过“Card”)将发生了改变的区域标记为“脏”区(这就是所谓的 卡片标记Card Marking )。
在预清理阶段,这些脏对象会被统计出来,从他们可达的对象也被标记下来。此阶段完成后, 用以标记的 card 也就被清空了。
此外,本阶段也会执行一些必要的细节处理,并为 Final Remark 阶段做一些准备工作。
1 | 2016-06-18T11:20:41.263-0800: 11.421: [CMS-concurrent-preclean-start] |
CMS-concurrent-preclean
:并发预清理阶段,统计此前的标记阶段中发生了改变的对象。
0.001/0.001 secs
:此阶段的持续时间,分别是运行时间和对应的实际时间。
阶段 4: Concurrent Abortable Preclean(并发可取消的预清理)。 此阶段也不停止应用线程。本阶段尝试在 STW的 Final Remark 之前尽可能地多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件( 如迭代次数、有用工作量、消耗的系统时间等等)。
1 | 2016-06-18T11:20:41.264-0800: 11.422: [CMS-concurrent-abortable-preclean-start] |
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停顿。目的是删除未使用的对象,并收回他们占用的空间。
1 | 2016-06-18T11:20:41.382-0800: 11.540: [CMS-concurrent-sweep-start] |
CMS-concurrent-sweep: 0.031/0.031 secs
:并发清理阶段,清理掉不再使用的垃圾对象。
阶段 7: Concurrent Reset(并发重置) 。此阶段与应用程序并发执行,重置CMS算法相关的内部数据,为下一次GC循环做准备。
1 | 2016-06-18T11:20:41.413-0800: 11.571: [CMS-concurrent-reset-start] |
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 | [ParNew: 629120K ->629120K(629120K) , 9.0600200 secs] |
当CMS在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程的Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收
晋升失败(promotion failed)
1 | [ParNew (promotion failed): 614254K->629120K(629120K), ©.1619839 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)时间非常长,需要尽可能减少压缩时间。
参考资料
- 《深入理解Java虚拟机(第2版)》第3章节「垃圾收集器与内存分配策略」
- https://github.com/cncounter/gc-handbook
- https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
- https://plumbr.io/handbook/garbage-collection-algorithms-implementations/concurrent-mark-and-sweep
- http://www.oracle.com/technetwork/tutorials/tutorials-1876574.html
- https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html
- https://juejin.im/post/5b6b986c6fb9a04fd1603f4a#heading-19