深入学习JVM垃圾回收器之CMS

先看一些问题:

  • CMS出现的初衷、背景和目的?

  • CMS的适用场景?

  • CMS的trade-off是什么?优势、劣势和代价

  • CMS会回收哪个区域的对象?

  • CMS的GC Roots包括那些对象?

  • CMS的过程?

  • CMS和Full gc是不是一回事?

  • CMS何时触发?

  • CMS的日志如何分析?

  • CMS的调优如何做?

  • CMS扫描那些对象?

  • CMS和CMS collector的区别?

  • CMS的推荐参数设置?

  • 为什么ParNew(并行GC)可以和CMS配合使用,而Parallel Scanvenge(并行回收GC)不可以?

【1】CMS基础知识

CMS收集器:Mostly-Concurrent收集器,也称并发标记清除收集器(Concurrent Mark-Sweep GC)。其是一种以获取最短回收停顿时间为目标的收集器。它管理新生代的方式与Parallel收集器和Serial收集器相同,而在老年代则是尽可能得并发执行,每个垃圾收集器周期只有2次短停顿。

CMS的初衷和目的:为了消除Throught收集器和Serial收集器在Full GC周期中的长时间停顿。

CMS的适用场景:如果你的应用需要更快的响应,不希望有长时间的停顿,同时你的CPU资源也比较丰富,就适合适用CMS收集器。

从名字(包含"Mark Sweep")上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程更复杂一些,整个过程分为四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要“Stop the World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程。而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿实际一般会比初始标记阶段稍长一些,但远比并发标记时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

【2】CMS垃圾收集的过程

① (STW)初始标记

这个阶段是标记从GcRoots直接可达的老年代对象、新生代引用的老年代对象,就是下图中灰色的点。这个过程是单线程的。
在这里插入图片描述
② 并发标记

由上一个阶段标记过的对象,开始tracing过程,标记所有可达的对象,这个阶段垃圾回收线程和应用线程同时运行。在并发标记过程中,应用线程还在跑,因此会导致有些对象会从新生代晋升到老年代、有些老年代的对象引用会被改变、有些对象会直接分配到老年代,这些受到影响的老年代对象所在的card会被标记为dirty,用于重新标记阶段扫描。

这个阶段过程中,老年代对象的card被标记为dirty的可能原因,就是下图中绿色的线:
在这里插入图片描述
预清理:

预清理,也是用于标记老年代存活的对象,目的是为了让重新标记阶段的STW尽可能短。这个阶段的目标是在并发标记阶段被应用线程影响到的老年代对象,包括:

  • 老年代中card为dirty的对象;
  • 幸存区(from和to)中引用的老年代对象。因此,这个阶段也需要扫描新生代+老年代。(PS:会不会扫描Eden区的对象,我看源代码猜测是没有,还需要继续求证)
    在这里插入图片描述
    可中断的预清理:

这个阶段的目标跟“预清理”阶段相同,也是为了减轻重新标记阶段的工作量。可中断预清理的价值是在进入重新标记阶段之前尽量等到一个Minor GC,尽量缩短重新标记阶段的停顿时间。另外可中断预清理会在Eden达到50%的时候开始,这时候离下一次minor gc还有半程的时间。

这个还有另一个意义,即避免短时间内连着的两个停顿,如下图资料所示:
在这里插入图片描述

如果进入可中断的预清理,可中断预清理可能会执行多次,那么退出这个阶段的出口有两个(源码参见下图):

  • 设置了CMSMaxAbortablePrecleanLoops,并且执行的次数超过了这个值,这个参数的默认值是0;

  • CMSMaxAbortablePrecleanTime,执行可中断预清理的时间超过了这个值,这个参数的默认值是5000毫秒。
    在这里插入图片描述
    如果是因为这个原因退出,gc日志打印如下:
    在这里插入图片描述
    有可能可中断预清理过程中一直没等到Minor gc,这时候进入重新标记阶段的话,新生代还有很多活着的对象,就回导致STW变长,因此CMS还提供了CMSScavengeBeforeRemark参数,可以在进入重新标记之前强制进行一次Minor gc。

  • Eden的使用空间大于“CMSScheduleRemarkEdenSizeThreshold”,这个参数的默认值是2M;

  • Eden的使用率大于等于“CMSScheduleRemarkEdenPenetration”,这个参数的默认值是50%。

但是在预清理步骤后,并非一定开启可中断的预清理,可以直接进入重新标记阶段。


③(STW)重新标记

重新扫描堆中的对象,进行可达性分析,标记活着的对象。这个阶段扫描的目标是:新生代的对象 + Gc Roots + 前面被标记为dirty的card对应的老年代对象。如果预清理的工作没做好,这一步扫描新生代的时候就会花很多时间,导致这个阶段的停顿时间过长。重新标记这个过程是多线程的

④ 并发清除

用户线程被重新激活,同时将那些未被标记为存活的对象标记为不可达;

⑤ 并发重置

CMS内部重置回收器状态,准备进入下一个并发回收周期。

⑥ CMS的异常情况

上面描述的是CMS的并发周期正常完成的情况,但是还有几种CMS并发周期失败的情况:

  • 并发模式失败(Concurrent mode failure):CMS的目标就是在回收老年代对象的时候不要停止全部应用线程,在并发周期执行期间,用户的线程依然在运行。如果这时候如果应用线程向老年代请求分配的空间超过预留的空间(空间分配担保失败),就回触发concurrent mode failure,然后CMS的并发周期就会被一次Full GC代替——停止全部应用进行垃圾收集,并进行空间压缩。如果我们设置了UseCMSInitiatingOccupancyOnlyCMSInitiatingOccupancyFraction参数,其中CMSInitiatingOccupancyFraction的值是70,那预留空间就是老年代的30%。

  • 晋升失败:新生代做minor gc的时候,需要CMS的担保机制确认老年代是否有足够的空间容纳要晋升的对象,担保机制发现不够,则报concurrent mode failure,如果担保机制判断是够的,但是实际上由于碎片问题导致无法分配,就会报晋升失败。

  • 永久代空间(或Java8的元空间)耗尽,默认情况下,CMS不会对永久代进行收集,一旦永久代空间耗尽,就回触发Full GC。


【3】CMS的调优

① 针对停顿时间过长的调优

首先需要判断是哪个阶段的停顿导致的,然后再针对具体的原因进行调优。使用CMS收集器的JVM可能引发停顿的情况有:

  • Minor gc的停顿;
  • 并发周期里初始标记的停顿;
  • 并发周期里重新标记的停顿;
  • Serial-Old收集老年代的停顿;
  • Full GC的停顿。其中并发模式失败会导致第(4)种情况,晋升失败和永久代空间耗尽会导致第(5)种情况。

② 针对并发模式失败的调优

  • 想办法增大老年代的空间,增加整个堆的大小,或者减少年轻代的大小

  • 以更高的频率执行后台的回收线程,即提高CMS并发周期发生的频率。设置UseCMSInitiatingOccupancyOnlyCMSInitiatingOccupancyFraction参数,调低CMSInitiatingOccupancyFraction的值,但是也不能调得太低,太低了会导致过多的无效的并发周期,会导致消耗CPU时间和更多的无效的停顿。通常来讲,这个过程需要几个迭代,但是还是有一定的套路,参见《Java性能权威指南》中给出的建议,摘抄如下:

对特定的应用程序,该标志的更优值可以根据 GC 日志中 CMS 周期首次启动失败时的值得到。具体方法是,在垃圾回收日志中寻找并发模式失效,找到后再反向查找 CMS 周期最近的启动记录,然后根据日志来计算这时候的老年代空间占用值,然后设置一个比该值更小的值。

  • 增多回收线程的个数
    CMS默认的垃圾收集线程数是(CPU个数 + 3)/4,这个公式的含义是:当CPU个数大于4个的时候,垃圾回收后台线程至少占用25%的CPU资源。举个例子:如果CPU核数是1~4个,那么会有1个CPU用于垃圾收集,如果CPU核数是5~8个,那么久会有2个CPU用于垃圾收集。

③ 针对永久代的调优

如果永久代需要垃圾回收(或元空间扩容),就会触发Full GC。默认情况下,CMS不会处理永久代中的垃圾,可以通过开启CMSPermGenSweepingEnabled配置来开启永久代中的垃圾回收,开启后会有一组后台线程针对永久代做收集。

需要注意的是,触发永久代进行垃圾收集的指标跟触发老年代进行垃圾收集的指标是独立的,永久代的阈值可以通过CMSInitiatingPermOccupancyFraction参数设置,这个参数的默认值是80%。开启对永久代的垃圾收集只是其中的一步,还需要开启另一个参数——CMSClassUnloadingEnabled,使得在垃圾收集的时候可以卸载不用的类。


【4】几个问题的解答

① 为什么ParNew可以和CMS配合使用,而Parallel Scanvenge不可以?

答:这个跟Hotspot VM的历史有关,Parallel Scanvenge是不在“分代框架”下开发的,而ParNew、CMS都是在分代框架下开发的。

② CMS中minor gc和major gc是顺序发生的吗?

答:不是的,可以交叉发生,即在并发周期执行过程中,是可以发生Minor gc的,这个找个gc日志就可以观察到。

③ CMS的并发收集周期合适触发?

由下图可以看出,CMS 并发周期触发的条件有两个:
在这里插入图片描述
阈值检查机制:老年代的使用空间达到某个阈值,JVM的默认值是92%(jdk1.5之前是68%,jdk1.6之后是92%),或者可以通过CMSInitiatingOccupancyFraction和UseCMSInitiatingOccupancyOnly两个参数来设置;这个参数的设置需要看应用场景,设置得太小,会导致CMS频繁发生,设置得太大,会导致过多的并发模式失败。

动态检查机制:JVM会根据最近的回收历史,估算下一次老年代被耗尽的时间,快到这个时间的时候就启动一个并发周期。设置UseCMSInitiatingOccupancyOnly这个参数可以将这个特性关闭。


④ CMS的并发收集周期会扫描哪些对象?会回收哪些对象?

答:CMS的并发周期只会回收老年代的对象,但是在标记老年代的存活对象时,可能有些对象会被年轻代的对象引用,因此需要扫描整个堆的对象

⑤ CMS的gc roots包括哪些对象?

答:首先,在JVM垃圾收集中Gc Roots的概念如何理解(参见R大对GC roots的概念的解释);第二,CMS的并发收集周期中,如何判断老年代的对象是活着?我们前面提到了,在CMS的并发周期中,仅仅扫描Gc Roots直达的对象会有遗漏,还需要扫描新生代的对象。如下图中的蓝色字体所示,CMS中的年轻代和老年代是分别收集的,因此在判断年轻代的对象存活的时候,需要把老年代当作自己的GcRoots,这时候并不需要扫描老年代的全部对象,而是使用了card table数据结构,如果一个老年代对象引用了年轻代的对象,则card中的值会被设置为特殊的数值;反过来判断老年代对象存活的时候,也需要把年轻代当作自己的Gc Roots,这个过程我们在第三节已经论述过了。

在这里插入图片描述

⑥ 如果我的应用决定使用CMS收集器,推荐的JVM参数是什么?

我自己的应用使用的参数如下,是根据PerfMa的xxfox生成的,大家也可以使用这个产品调优自己的JVM参数:

-Xmx4096M-Xms4096M-Xmn1536M

-XX:MaxMetaspaceSize=512M-XX:MetaspaceSize=512M

-XX:+UseConcMarkSweepGC

-XX:+UseCMSInitiatingOccupancyOnly

-XX:CMSInitiatingOccupancyFraction=70

-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses

-XX:+CMSClassUnloadingEnabled

-XX:+ParallelRefProcEnabled

-XX:+CMSScavengeBeforeRemark

-XX:ErrorFile=/home/admin/logs/xelephant/hs_err_pid%p.log

-Xloggc:/home/admin/logs/xelephant/gc.log

-XX:HeapDumpPath=/home/admin/logs/xelephant

-XX:+PrintGCDetails

-XX:+PrintGCDateStamps

-XX:+HeapDumpOnOutOfMemoryError

CMS相关的参数总结(需要注意的是,这里我没有考虑太多JDK版本的问题,JDK1.7和JDK1.8这些参数的配置,有些默认值可能不一样,具体使用的时候还需要根据具体的版本来确认怎么设置)

参数名称解释
UseConcMarkSweepGC启用CMS收集器
UseCMSInitiatingOccupancyOnly关闭CMS的动态检查机制,只通过预设的阈值来判断是否启动并发收集周期
CMSInitiatingOccupancyFration老年代空间占用到多少的时候启动并发收集周期,跟UseCMSInitiatingOccupancyOnly 一起使用
ExplicitGCInvokesConcurrentAndUnloadsClasses将System.gc()触发的Full GC转换为一次CMS并发收集,并且在这个收集周期中卸载Perm(Metaspace)区域中不需要的类
CMSClassUnloadingEnabled在CMS收集周期中,是否卸载类
ParalledRefProcEnabled是否开启并发引用处理
CMSScavengeBeforeRemark如果开启这个参数,会在进入重新标记阶段之前强制触发一次minor GC
UseCMSCompactAtFullCollection(默认开启)在要进行Full GC的时候进行内存碎片整理
CMSFullGCsBeforeCompaction每隔多少次不压缩的Full GC后,执行一次带压缩的Full GC。默认值为0,表示每次进入Full GC时都进行碎片整理

【5】CMS的优势和劣势

① 优势

低延迟的收集器:几乎没有长时间的停顿,应用程序只在Minor gc以及后台线程扫描老年代的时候发生极其短暂的停顿。概括来讲就是并发收集、低停顿。

② 劣势

CMS收集器对CPU资源非常敏感:在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次手机中处理掉他们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从来获取更好的性能。在JDK1.6中,CMS收集器的启动阈值已经提升到92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备方案临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。

CMS是基于“标记-清除”算法实现的收集器。CMS收集器对老年代收集的时候,不再进行任何压缩和整理的工作,意味着老年代随着应用的运行会变得碎片化。碎片过多会影响大对象的分配,虽然老年代还有很大的剩余空间,但是没有连续的空间来分配大对象,这时候就会触发Full GC。

CMS提供了两个参数来解决这个问题:

  • UseCMSCompactAtFullCollection(默认开启),在要进行Full GC的时候进行内存碎片整理。内存整理的过程是无法并发的,空间碎片问题没有了,但是停顿实际不得不变长。
  • CMSFullGCsBeforeCompaction,每隔多少次不压缩的Full GC后,执行一次带压缩的Full GC。默认值为0,表示每次进入Full GC时都进行碎片整理。

参考博文:
《深入理解Java虚拟机》
不可错过的CMS学习笔记

  • 1
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值