深入分析CMS垃圾收集器原理

前文已经讲过,CMS是老年代垃圾收集器,在收集过程中可以与用户线程并发操作。它可以与Serial收集器和Parallel New收集器搭配使用。CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。

CMS相关参数

参数 类型 默认值 作用
-XX:+UseConcMarkSweepGC boolean false 老年代采用CMS收集器收集
–XX:ParallelGCThreads=n int (ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8) 老年代采用CMS收集器收集
-XX:CMSInitiatingOccupancyFraction int 92 年代堆空间的使用率。比如value=75意味着第一次CMS垃圾收集会在老年代被占用75%时被触发。
-XX:+UseCMSInitiatingOccupancyOnly boolean false 只用设定的回收阈值(上面指定的70%),如果不指定,JVM仅在第一次使用设定值,后续则自动调整

触发条件

周期性GC

由后台线程ConcurrentMarkSweepThread循环判断(默认2s)是否需要触发。

如果没有设置-XX:+UseCMSInitiatingOccupancyOnly,虚拟机会根据收集的数据决定是否触发
老年代使用率达到阈值 CMSInitiatingOccupancyFraction,默认92%。
永久代的使用率达到阈值 CMSInitiatingPermOccupancyFraction,默认92%,前提是开启 CMSClassUnloadingEnabled
新生代的晋升担保失败。

主动触发

YGC过程发生Promotion Failed,进而对老年代进行回收
比如执行了System.gc(),前提是没有参数ExplicitGCInvokesConcurrent

收集过程

初始标记

这是CMS中两次stop-the-world事件中的一次。这一步的作用是标记存活的对象,有两部分:

标记老年代中所有的GC Roots对象
标记年轻代中活着的对象引用到的老年代的对象

CMS-initial-mark:961330K(1572864K)指标记时老年代的已用空间和总空间

并发标记

该阶段GC线程和应用线程并发执行,遍历InitialMarking阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象。

1
2
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 2.787/3.329 secs] [Times: user=12.12 sys=0.64, real=3.33 secs]

第一行CMS-concurrent-mark-start标识标记阶段开始。第二行中的“2.787/3.329 secs”表示标记阶段的耗时。
表示花费了2.787cpu时间,3.329系统时间。

预清理阶段

由于在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:

新生代的对象晋升到老年代;
直接在老年代分配对象;
老年代对象的引用关系发生变更;

http://static.cyblogs.com/20190702174034511.png

该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代。
标记dirty card 能够到达的对象

http://static.cyblogs.com/20190702180248905.png

1
2
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.342/0.477 secs] [Times: user=1.79 sys=0.10, real=0.48 secs]
可终止的预处理

该阶段发生的前提是,新生代Eden区的内存使用量大于参数CMSScheduleRemarkEdenSizeThreshold 默认是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。
在该阶段,主要循环的做两件事:

处理 FromTo 区的对象,标记可达的老年代对象
和上一个阶段一样,扫描处理Dirty Card中的对象

重新标记

暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。有了前面的基础,这个阶段的工作量被大大减轻,停顿时间因此也会减少。注意这个阶段是多线程的。

遍历新生代对象,重新标记
根据GC Roots,重新标记
遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过

并发清理

通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间;
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。

注意事项

  1. 减少remark阶段停顿

    一般CMSGC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:
    -XX:+CMSScavengeBeforeRemark。在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。

  2. 内存碎片问题

    CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要用到这个参数:-XX:CMSFullGCsBeforeCompaction=n
    意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0

  3. Concurrent mode failure

    这个异常发生在cms正在回收的时候。执行CMS GC的过程中,同时业务线程也在运行,当年轻带空间满了,执行YGC时,需要将存活的对象放入到老年代,而此时老年代空间不足,这时CMS还没有机会回收老年带产生的,或者在做Minor GC的时候,新生代救助空间放不下,需要放入老年代,而老年代也放不下而产生的。

  • 过早提升与提升失败
    Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步,如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。

  • 早提升的原因
    Survivor空间太小,容纳不下全部的运行时短生命周期的对象,如果是这个原因,可以尝试将Survivor调大,否则端生命周期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc
    对象太大,SurvivorEden没有足够大的空间来存放这些大对象。

  • 提升失败原因
    当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。为什么是没有足够的连续空间而不是空闲空间呢?老年代容纳不下提升的对象有两种情况:

    • 老年代空闲空间不够用了;
    • 老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象。
  • 解决方法
    如果是因为内存碎片导致的大对象提升失败,cms需要进行空间整理压缩;
    如果是因为提升过快导致的,说明Survivor 空闲空间不足,那么可以尝试调大 Survivor;
    如果是因为老年代空间不够导致的,尝试将CMS触发的阈值调低。

REF:https://juejin.im/post/5c39920b6fb9a049e82bbf94

参考地址

如果大家喜欢我的文章,可以关注个人订阅号。欢迎随时留言、交流。如果想加入微信群的话一起讨论的话,请加管理员简栈文化-小助手(lastpass4u),他会拉你们进群。

简栈文化服务订阅号