在讲解G1垃圾回收器之前,我们再回顾下JDK9之前最常用的垃圾回收器CMS( @Deprecated )。
简介
Concurrent Mark Sweep简称CMS,是一种以获取最短回收停顿时间为目标的收集器,尤其重视服务的响应速度。
CMS是老年代垃圾回收器,基于标记-清除算法实现,只回收老年代和永久代(jdk 1.8开始为元数据区,需要设置CMSClassUnloadingEnabled),一般配合parNew使用。
CMS是一种预处理垃圾回收器,它不能等到old内存用尽时回收,需要在内存用尽前,完成回收操作,否则会导致并发回收失败;所以CMS垃圾回收器开始执行回收操作,有一个触发阈值,默认是老年代或永久代达到**92%**。
工作原理
垃圾回收阶段
- 初始标记(CMS-initial-mark),会导致STW;
- 并发标记(CMS-concurrent-mark),与用户线程同时运行;
- 预清理(CMS-concurrent-preclean),与用户线程同时运行;
- 可被终止的预清理(CMS-concurrent-abortable-preclean),与用户线程同时运行;
- 重新标记(CMS-remark),会导致STW;
- 并发清理(CMS-concurrent-sweep),与用户线程同时运行;
- 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;
其中初始标记、并发标记、重新标记和并发清理这四个比较重要。
流程图如下:
下面通过GC信息详细分析下,添加如下JVM参数:
1 | -XX:+PrintCommandLineFlags [1] |
先介绍下上面几个参数的作用:
[1]打印出启动参数行;
[2]参数指定使用CMS垃圾回收器;
[3]、[4]参数指定CMS垃圾回收器在老年代达到80%的时候开始工作,如果不指定那么默认的值为92%;
[5]开启永久代(jdk 1.8以下版本)或元数据区(jdk 1.8及其以上版本)收集,如果没有设置这个标志,一旦永久代或元数据区耗尽空间也会尝试进行垃圾回收,但是回收不会是并行的,而再一次进行Full GC;
[6]使用CMS时默认这个参数就是打开的,不需要配置,CMS只回收老年代,年轻代只能配合Parallel New或Serial回收器;
[7]减少Remark阶段暂停的时间,启用并行Remark,如果Remark阶段暂停时间长,可以启用这个参数
[8]如果Remark阶段暂停时间太长,可以启用这个参数,在Remark执行之前,先做一次ygc。因为这个阶段,年轻代也是CMS的gcroot,CMS会扫描年轻代指向老年代对象的引用,如果年轻代有大量引用需要被扫描,会让Remark阶段耗时增加;
[9]、[10]两个参数是针对CMS垃圾回收器碎片做优化的,CMS是不会移动内存的,运行时间长了,会产生很多内存碎片,导致没有一段连续区域可以存放大对象,出现”promotion failed”、”concurrent mode failure”,导致fullgc,启用UseCMSCompactAtFullCollection在Full GC的时候,对年老代的内存进行压缩。-XX:CMSFullGCsBeforeCompaction=0则是代表多少次FGC后对老年代做压缩操作,默认值为0,代表每次都压缩,把对象移动到内存的最左边,可能会影响性能,但是可以消除碎片;
106.641: [GC 106.641: [ParNew (promotion failed): 14784K->14784K(14784K), 0.0370328 secs]106.678: [CMS106.715: [CMS-concurrent-mark: 0.065/0.103 secs] [Times: user=0.17 sys=0.00, real=0.11 secs]
(concurrent mode failure): 41568K->27787K(49152K), 0.2128504 secs] 52402K->27787K(63936K), [CMS Perm : 2086K->2086K(12288K)], 0.2499776 secs] [Times: user=0.28 sys=0.00, real=0.25 secs]
[11]、[12] 定义并发CMS过程运行时的线程数。比如value=4意味着CMS周期的所有阶段都以4个线程来执行。尽管更多的线程会加快并发CMS过程,
但其也会带来额外的同步开销。因此,对于特定的应用程序,应该通过测试来判断增加CMS线程数是否真的能够带来性能的提升。
如果未设置这个参数,JVM会根据并行收集器中的-XX:ParallelGCThreads参数的值来计算出默认的并行CMS线程数( ncpus为cpu个数):𝑃𝑎𝑟𝑎𝑙𝑙𝑒𝑙𝐺𝐶𝑇ℎ𝑟𝑒𝑎𝑑𝑠=(𝑛𝑐𝑝𝑢𝑠<=8?𝑛𝑐𝑝𝑢𝑠:8+(𝑛𝑐𝑝𝑢𝑠−8)∗5/8)
,𝐶𝑜𝑛𝑐𝐺𝐶𝑇ℎ𝑟𝑒𝑎𝑑𝑠=(𝑃𝑎𝑟𝑎𝑙𝑙𝑒𝑙𝐺𝐶𝑇ℎ𝑟𝑒𝑎𝑑𝑠+3)/4,这个参数一般不要自己设置,使用默认就好,除非发现默认的参数有调整的必要;
[13]、[14]开启foreground CMS GC,CMS gc有两种模式,background和foreground,正常的CMS GC使用background模式,就是我们平时说的CMS GC;当并发收集失败或者调用了System.gc()的时候,就会导致一次Full GC,这个Full GC是不是CMS回收,而是Serial单线程回收器,加入了参数[12]后,执行Full GC的时候,就变成了CMS foreground gc,它是并行Full GC,只会执行CMS中stop the world阶段的操作,效率比单线程Serial full GC要高;需要注意的是它只会回收old,因为CMS收集器是老年代收集器;而正常的Serial收集是包含整个堆的,加入了参数[14],代表永久代也会被CMS收集;
[15]开启初始标记过程中的并行化,进一步提升初始化标记效率;
[16]、[17]、[18]、[19]、[20]是打印gc日志,其中[16]在jdk1.8之后无需设置;
[21]、[22]则是内存溢出时dump堆。
下面就是该参数设置打印出来的gc信息,一些非关键的信息已经去掉,如时间:
1 | //第一步 初始标记 这一步会停顿 |
初始标记
初始标记是CMS两次stop-the-world事件中的一次。这一步的作用是标记存活的对象,有两部分:
- 标记老年代中所有的
GC Roots对象,如下图节点1; - 标记年轻代中活着的对象引用到的老年代的对象(指的是年轻代中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;

ps:为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,
-XX:+CMSParallelInitialMarkEnabled,同时调大并行标记的线程数,线程数不要超过cpu的核数。
并发标记
从“初始标记”阶段标记的对象开始找出所有存活的对象;
因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代;
并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;
如下图所示,也就是节点1、2、3,最终找到了节点4和5。并发标记的特点是和应用程序线程同时运行。并不是老年代的所有存活对象都会被标记,因为标记的同时应用程序会改变一些对象的引用等。

这个阶段因为是并发的容易导致concurrent mode failure。
预清理阶段
前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Direty的Card。
如下图所示,在并标记理阶段,节点3的引用指向了6;则会把节点3的card标记为Dirty;

最后将6标记为存活,如下图所示:

可终止的预处理
这个阶段尝试着去承担下一个阶段Final Remark阶段足够多的工作。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生aboart的条件(比如:重复的次数、多少量的工作、持续的时间等等)之一才会停止。
ps:此阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次ygc,清理年轻代的引用,是的下个阶段的重新标记阶段,扫描年轻代指向老年代的引用的时间减少;
重新标记
这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。
这个阶段,重新标记的内存范围是整个堆,包含young_gen和old_gen。为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做CMS的gc root,来扫描老年代;因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作GC ROOTS:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次ygc,回收掉年轻代的对象无用的对象,并将对象放入幸存代或晋升到老年代,这样再进行年轻代扫描时,只需要扫描幸存区的对象即可,一般幸存代非常小,这大大减少了扫描时间。
由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻代的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。
另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled。
并发清理
通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间;

由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为浮动垃圾。
并发重置
这个阶段并发执行,重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用。
CMS并发GC不是Full GC。
HotSpot VM里对concurrent collection和full collection有明确的区分。所有带有full collection字样的VM参数都是跟真正的Full GC相关,而跟CMS并发GC无关的,CMS收集算法只是清理老年代。
CMS存在的问题
重新标记停顿时间长
一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:
1 | -XX:+CMSScavengeBeforeRemark |
在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销,如果添加该参数后𝑦𝑔𝑐停顿时间+𝑟𝑒𝑚𝑎𝑟𝑘时间 < 添加该参数之前的𝑟𝑒𝑚𝑎𝑟𝑘时间,说明该参数是有效的。
内存碎片
CMS是基于标记-清除算法的,只会将标记为为存活的对象删除,并不会移动对象整理内存空间,会造成内存碎片,这时候我们需要用到这个参数;
1 | -XX:+UseCMSCompactAtFullCollection (空间碎片整理) |
-XX:+UseCMSCompactAtFullCollection(默认开启,JDK9废弃),在进行Full GC 之前进行一次内存整理。虽然空间碎片解决了,但是停顿时间也增长了。-XX:CMSFullGCBeforeCompaction=n(默认为0,表示每次进入Full GC时都进行碎片整理),参数作用是当CMS执行n次不整理内存碎片后,下一次进入Full GC前先进行碎片整理。
无法处理浮动垃圾
在并发回收阶段时,当用户线程并发创建了一个对象年轻代放不下,直接晋升到老年代或者年轻代对象超过存活次数晋升到老年代,由于存在这种现象,因此CMS垃圾回收器就必须预留一部分空间给用户线程,不能等老年代满了才去回。
可通过 -XX:CMSInitiatingOccupancyFraction=n -XX:+UseCMSInitiatingOccupancyOnly 来设置(我这边都是设置成80: -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly )。
PS: 当只设置了-XX:CMSInitiatingOccupancyFraction,那么仅有第一次使用设定值,后续CMS自动调整占用率。此设置会失效,所以需要设置-XX:+UseCMSInitiatingOccupancyOnly来固定占用率多少开启回收。
当设置的-XX:CMSInitiatingOccupancyFraction 过大时,就可能会出现在垃圾回收过程中,无法分配对象的问题,导致 并发失败 (Concurrent Mode Failure),此时会临时启用Serial Old回收器来重新进行老年代回收,这样会导致停顿时间更长。
总结
CMS回收器只回收老年代,其是以吞吐量为代价换取回收速度
CMS回收过程分为:初始标记、并发标记、并发预处理阶段、可终止预处理、重新标记以及并发清理阶段。其中初始标记、重新标记需要
STW,CMS大部分时间花费在重新标记阶段,可以让虚拟机先进性一次Young GC,减少停顿时间。但CMS无法解决“浮动垃圾”问题。由于CMS回收线程与用户线程并发,可能回收过程中会出现并发模式失败 Concurrent mode failure,解决方法是让CMS尽早GC,可以通过参数在一定次数的
Full GC之后让CMS对内存做一次整理,减少内存碎片。CMS 在JDK9中被置为 Deprecate ,结束了它璀璨的一生。同版本G1成为了默认的垃圾回收器。