zl程序教程

您现在的位置是:首页 >  Java

当前栏目

CMS垃圾回收器细节思考与补充

2023-03-15 22:00:44 时间

CMS垃圾回收器作为jdk6、jdk7、jdk8等jdk版本对老年代进行垃圾回收的首选,其重要性不言而喻。深入理解CMS垃圾回收器的各个阶段存在的价值对于性能调优非常关键。

CMS的正常过程

参考博客3给出了CMS垃圾回收器的7个步骤: 1. 初始标记(CMS-initial-mark) ,会导致stw; 2. 并发标记(CMS-concurrent-mark),与用户线程同时运行; 3. 预清理(CMS-concurrent-preclean),与用户线程同时运行; 4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行; 5. 重新标记(CMS-remark) ,会导致stw; 6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行; 7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行。

总的来说,CMS核心目标是缩短stop-the-world的时间,以快速响应业务。为此,CMS采用两次(初始标记和重新标记)短停顿来代替一次长停顿。上述步骤中的1和5即为两次stw,其他步骤则是用户线程和GC线程并发执行。接下来结合参考博客1逐一对上述步骤进行分析:

1、(STW)初始标记:这个阶段是标记从GCRoots直接可达的老年代对象,以及新生代直接引用的老年代对象,就是下图中灰色的点。这个过程是单线程的(JDK7之前单线程,JDK8之后并行,可以通过参数CMSParallelInitialMarkEnabled调整)。

由图可知,初始标记只是标记出了GCRoots直接可达的老年代对象,因而此次停顿时间通常很短。PS:在Java语言里,可作为GC Roots对象的包括如下几种:

  1.  虚拟机线程栈(栈桢中的本地变量表)中的引用的对象 ;
  2. 方法区中的类静态属性引用的对象 ;
  3. 方法区中的常量引用的对象 ;
  4.  本地方法栈中JNI的引用的对象。

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

上图中绿色的线其实对应了四种情况,这四种情况在参考博客2中已经进行了总结:

  1. 新生代对象晋升到老年代
  2. 新生代新创建的对象(Eden区)引用了原先老年代未被标记的对象
  3. 直接在老年代分配的对象
  4. 老年代对象的引用关系发生变化

为了提高重新标记的效率,该阶段会把上述对象所存在的card标记为dirty,后续只需要扫描dirty card对应的对象

其中情况1、3、4都非常容易理解,但不知道读者对于情况2是如何理解的呢?情况2对应的就是上图中被标记的Eden区的对象1指向老年代的2。试想一下,在经过初始标记(stw)之后,老年代的2既不是GCRoots直接可达,也不是新生代直接引用的对象,那么在并发标记期间,引用了对象1的那个线程(取名为:线程A)是如何获取到对象2的引用并赋值给对象1的呢?前面已经介绍过了,GC Roots包括了线程栈中引用的对象,如果线程A保存了对象2的引用,那么对象2就应该在初始标记时被标记出来,但现在的情况是对象2在初始标记时没有被标记为可达。

这个疑惑也困扰了我很久,尝试从各种博客或者介绍JVM虚拟机的书籍中寻找答案,最终都无功而返。一种可能的猜测是:线程A在初始标记前直接引用了老年代的对象3(图中未给出示意),而对象3又引用了图中的对象2。那么在初始标记之后,对象3被标记为直接可达,而对象2未被标记。在并发标记期间,线程A在Eden区新创建了一个对象1,然后对象3将对象2赋值给对象1,接着对象3取消了对于对象2的引用。在这样的情形下,我们可以说新生代新创建的对象引用了原先老年代未被标记的对象。

PS:图中还有一个连线被打了个叉,表示在并发标记期间,新生代取消了对老年代对象的引用。对于这样的对象,垃圾回收器可能有两种选择:第一,取消对老年代对象的标记;第二,仍然认为该对象可达,在本轮垃圾回收过程中不被回收,而是在下一轮垃圾回收时再确定是否回收。由于有可能不止一个年轻代的对象引用该老年代对象,不能因为单个引用取消就直接将老年代对象的标记取消,因而个人猜测垃圾回收器会采取方案二。

3、预清理:预清理,也是用于标记老年代存活的对象,目的是为了让重新标记阶段的STW尽可能短。这个阶段的目标是在并发标记阶段被应用线程影响到的老年代对象,包括:(1)老年代中card为dirty的对象;(2)幸存区(from和to)中引用的老年代对象引用的老年代对象。因此,这个阶段也需要扫描新生代+老年代。此外,参考博文1中还提到“根据源码猜测不会扫描Eden区的对象”。对此,我们来分析其合理性。由前面的猜测可知,在初始标记之后,Eden区新创建的对象在并发标记期间引用老年代未被标记过的对象的概率是非常小的。此外,由后面的介绍可知,在重新标记阶段,还会再次对Eden区进行扫描,因而预清理阶段对Eden区进行扫描不仅费时,且收益微乎其微。

4、可中断的预清理:这个阶段的目标跟“预清理”阶段相同,也是为了减轻重新标记阶段的工作量。可中断预清理的价值有二:其一,在进入重新标记阶段之前尽量等到一个Minor GC,以缩短重新标记阶段的停顿时间(stw);其二,默认的可中断预清理会在Eden达到50%的时候开始,这时候离下一次minor gc还有半程的时间,这就可以避免避免短时间内连着的两个停顿(stw)。

在预清理步骤后,如果满足下面两个条件的任意一个,就不会开启可中断的预清理,而直接进入重新标记阶段:

  • Eden的使用空间小于“CMSScheduleRemarkEdenSizeThreshold”,这个参数的默认值是2M;
  • Eden的使用率大于等于“CMSScheduleRemarkEdenPenetration”,这个参数的默认值是50%。

这两个参数的含义是:预清理后,eden空间使用超过2M时才启动可中断的并发预清理(CMS-concurrent-abortable-preclean),低于2M则没有执行的必要,直到eden空间使用率达到50%时则中断“可中断的预清理”阶段,进入重新标记remark阶段。

如果上面两个条件都不满足,则进入可中断的预清理,可中断预清理可能会执行多次,那么退出这个阶段的出口有两个:

  • 设置了CMSMaxAbortablePrecleanLoops,并且执行的次数超过了这个值,这个参数的默认值是0,表示不限制执行次数;
  • CMSMaxAbortablePrecleanTime,执行可中断预清理的时间超过了这个值,这个参数的默认值是5000毫秒。

有可能可中断预清理过程中一直没等到Minor gc,这时候进入重新标记阶段的话,新生代还有很多活着的对象,就回导致STW变长,因此CMS还提供了CMSScavengeBeforeRemark参数,可以在进入重新标记之前强制进行一次Minor gc。

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

这里需要补充一下,为什么说预清理工作能减少重新标记的停顿时间:重新扫描新生代对象和GC Roots对象时,只需要关心其直接引用的老年代对象还没有被标记过的对象(及其子对象),而无须关心已经被标记的直接引用对象:因为已经被标记过的对象如果其对应的card不为dirty,则无须扫描;而已经被标记为dirty card的对象自然会被扫描。因而预清理确实可以缩短重新标记的停顿时长。

6、并发清除:用户线程被重新激活,同时清除那些未被标记为存活的对象。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然又会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾也称为“浮动垃圾”。

到此可知,虚拟机还需要存储一些特殊的信息,以区分出在标记周期以外新产生的对象(未标记)和在标记周期内产生的没有被标记的垃圾对象。

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

小结:CMS的核心思路是,先用一次stw标记新生代和GC Roots直接引用的老年代对象,因而停顿时间通常很短;对于间接引用的对象,则通过并发标记来完成,以减少GC对业务的影响;由于并发标记会导致初始标记的直接引用对象发生变化,因而需要预清理来处理并发标记引起的并发问题;而由于预清理也是并发的,因而最终还需要第二次stw来重新标记并发标记和预处理标记期间的并发问题;而为了缩短第二次stw标记的停顿时间,又增加了可中断的预清理阶段,并期待在该阶段发生一次Minor GC,从而极大地减少了第二次stw标记需要扫描的年轻代的对象。

CMS的异常情况

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

  • 并发模式失败(Concurrent mode failure):CMS的目标就是在回收老年代对象的时候不要停止全部应用线程,在并发周期执行期间,用户的线程依然在运行,如果这时候如果应用线程向老年代请求分配的空间超过预留的空间(担保失败),就回触发concurrent mode failure,然后CMS的并发周期就会被一次Full GC代替——停止全部应用,使用Serial-Old收集老年代,并进行空间压缩。如果我们设置了UseCMSInitiatingOccupancyOnlyCMSInitiatingOccupancyFraction参数,其中CMSInitiatingOccupancyFraction的值是70,那预留空间就是老年代的30%。
  • 晋升失败:新生代做minor gc的时候,需要CMS的担保机制确认老年代是否有足够的空间容纳要晋升的对象,担保机制发现不够,则报concurrent mode failure,如果担保机制判断是够的,但是实际上由于碎片问题导致无法分配,就会报晋升失败,从而执行Full GC。
  • 永久代空间(或Java8的元空间达到MaxMetaspaceSize)耗尽,默认情况下,CMS不会对永久代进行收集,一旦永久代空间耗尽,也会触发Full GC。

CMS的调优

  1. 针对停顿时间过长的调优 首先需要判断是哪个阶段的停顿导致的,然后再针对具体的原因进行调优。使用CMS收集器的JVM可能引发停顿的情况有:(1)Minor gc的停顿;(2)并发周期里初始标记的停顿;(3)并发周期里重新标记的停顿;(4)Serial-Old收集老年代的停顿;(5)Full GC的停顿。其中并发模式失败会导致第(4)种情况,晋升失败和永久代空间耗尽会导致第(5)种情况。
  2. 针对并发模式失败的调优
    • 想办法增大老年代的空间,增加整个堆的大小,或者减少年轻代的大小
    • 以更高的频率执行后台的回收线程,即提高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用于垃圾收集。
  3. 针对永久代的调优 如果永久代需要垃圾回收(或元空间扩容),就会触发Full GC。默认情况下,CMS不会处理永久代中的垃圾,可以通过开启CMSPermGenSweepingEnabled配置来开启永久代中的垃圾回收,开启后会有一组后台线程针对永久代做收集,需要注意的是,触发永久代进行垃圾收集的指标跟触发老年代进行垃圾收集的指标是独立的,老年代的阈值可以通过CMSInitiatingPermOccupancyFraction参数设置,这个参数的默认值是80%。开启对永久代的垃圾收集只是其中的一步,还需要开启另一个参数——CMSClassUnloadingEnabled,使得在垃圾收集的时候可以卸载不用的类。

补充说明

1、执行Minor GC时,如果有老年代引用年轻代对象怎么办?

有研究表明,在所有的引用中,老年代引用新生代这种场景不足1%。

CMS将老年代的空间分成大小为512bytes的块,card table中的每个元素对应着一个块。

并发标记时,如果某个对象的引用发生了变化,就标记该对象所在的块为 dirty card。并发预清理阶段就会重新扫描该块,将该对象引用的对象标识为可达。

当有老年代引用新生代,对应的card table被标识为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的。所以,Minor GC通过扫描card table就可以很快的识别老年代引用新生代。

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

  • 阈值检查机制:老年代的使用空间达到某个阈值,JVM的默认值是92%(jdk1.5之前是68%,jdk1.6之后是92%),或者可以通过CMSInitiatingOccupancyFraction和UseCMSInitiatingOccupancyOnly两个参数来设置;这个参数的设置需要看应用场景,设置得太小,会导致CMS频繁发生,设置得太大,会导致过多的并发模式失败。
  • 动态检查机制:JVM会根据最近的回收历史,估算下一次老年代被耗尽的时间,快到这个时间的时候就启动一个并发周期。设置UseCMSInitiatingOccupancyOnly这个参数可以将这个特性关闭。

3、Minor GC会发生stop the world现象吗?

目前所有的新生代gc都是需要STW的,不过由于Minor GC只需要收集年轻代,所以一般只有几十毫秒的停顿,可以忽略不计:

  • Serial:单线程STW,复制算法
  • ParNew:多线程并行STW,复制算法
  • Parallel Scavange:多线程并行STW,吞吐量优先,复制算法
  • G1:多线程并发,可以精确控制STW时间,整理算法

Serial串行收集器

ParNew(并行GC)收集器

参考博客:

1、不可错过的CMS学习笔记 - 知乎 不可错过的CMS学习笔记

2、理解GC日志 | 码农网  理解GC日志

3、CMS垃圾回收器详解_zqz_zqz的博客-CSDN博客_cms垃圾回收器 CMS垃圾回收器详解

4、https://www.jianshu.com/p/08f0b85ad665  CMS垃圾回收器详解  老年代引用新生代

5、图解 CMS 垃圾回收机制原理,面试必知必会 !  图解 CMS 垃圾回收机制原理

6、Java finalize函数与软引用、弱引用、虚引用_Saintyyu的博客-CSDN博客  Java finalize函数与软引用、弱引用、虚引用

7、JVM源码分析之安全点safepoint - 简书 JVM源码分析之安全点safepoint

8、Java finalize函数与软引用、弱引用、虚引用_Saintyyu的博客-CSDN博客 Java finalize函数与软引用、弱引用、虚引用

9、JVM知识整理 - 简书 JVM知识整理   非常全面

10、G1垃圾收集器入门-原创译文 - OldTrafford - 博客园  G1垃圾收集器入门-译文

11、 JVM中的垃圾回收策略 - 大唐札记  JVM中的垃圾回收策略

12、 Java垃圾回收浅谈_wtopps的专栏-CSDN博客 Java垃圾回收浅谈

13、GC后,内存地址变了,对象引用变了吗? - 你看到过我的小熊吗? - OSCHINA - 中文开源技术交流社区  GC后,内存地址变了,对象引用变了吗?

14、不会还有人以为cms回收器只有4个阶段吧 - 知乎  不会还有人以为cms回收器只有4个阶段吧

15、CMS收集器几个参数详解 -XX:CMSInitiatingOccupancyFraction, CMSFullGCsBeforeCompaction_刘本龙的专栏-CSDN博客 CMS收集器几个参数详解 -XX:CMSInitiatingOccupancyFraction, CMSFullGCsBeforeCompaction

16、Minor GC、Major GC、Full GC、分配担保 - 反光的小鱼儿 - 博客园 Minor GC、Major GC、Full GC、分配担保

17、minor gc 会发生stop the world 现象吗? - 知乎 minor gc 会发生stop the world 现象吗

18、CMS、G1、ZGC的堆内存实现区别 - 简书