CMS 阶段性了结

时间:2021-10-07 00:28:27

CMS 阶段性了结

接触 CMS 已经很长时间了,但是对 CMS 的了解似乎一直没有开始,直到最近模型平台项目用到时遇到了很多问题。网上查阅了很多资料,主要参考了 R大、寒泉子、占小狼的各种文章以及回帖。不过自己还是有些愚钝,对一些问题至今不是很清楚,主要原因在于一方面 JVM GC 知识点很多,划分并不清晰,而到如今资料层出不穷,很多信息甚至是错误的,对知识理解与掌握造成了不小的干扰。可是有些工作要继续下去,对于不是很确定的知识点也不能一直深入钻研下去。所以在此做一个阶段性总结,把自己确定的、不确定的地方梳理一下。如果后续有时间像那些 JVM 大神一样钻研 JVM 源码,再把坑填上。其实,到如今,无论谁怎么解释我都没法相信了,因为代码 -> 作者理解 -> 语言总结 -> 个人理解,经历了多层的知识转换,恐怕到我这一步知识已经变了原有的味道,所以最好还是要自己去看源码来理解,中间两步仅仅是为了帮助理解而存在的。


所以这里想谈一谈学习的路径

  • 专家、原作者表述 -> 代码 -> 个人理解
  • 学习者阅读代码 -> 学习者理解 -> 学习者语言总结 -> 个人理解 -> 代码 -> 个人理解

其实可以看到无论如何,最终的个人理解都是要以代码为基础的,而第一种路径是能够避免少走弯路,第二种稍有不慎,就会掉入其他学习者的坑,就像现在这样。但是由于知识体量庞大,而且很多时候工作中即便没有达到最终的个人理解,也可以使用,因此第二条路径的前半部分是最常采用的学习路径。

回到 CMS,CMS 是一种并发标记清理的垃圾回收器,它不负责整理,会在老年代产生内存碎片,但是由于它是并发的(注意,和 Par 那些并行收集器不同,它的并发是指可以伴随用户线程共同执行,而且只是在一些阶段上可以做到,它的做法其实就是把垃圾回收的步骤细化,一些可以不 STW 的步骤就并发执行,而 STW 的阶段可以串行也可以并行),在除新生代以外的回收上表现要比纯 STW 表现的好,所以在前几年是备受青睐的,而且现在很多系统依然使用 ParNew + CMS 这种组合。但是 CMS 就像是提供了一种好的思路,但是实现起来却发现有很多坑,也因此留了很多灵活的接口,打了很多补丁,可以看到 CMS 相关的参数配置有一大堆,想要配合好并不容易。


为了便于理解,这里采用一种场景分析的方式来总结 CMS 的各种特性。

Young

首先看 Young 区,young 区的触发条件很简单,当 Eden 区无法再分配对象时,就会触发 Young GC。Young GC 的基本步骤包括,标记 + 清除 + 整理(整理到 Survivor 区)。一般情况下,Young 区的对象,大部分都是可以被回收掉的,剩余的对象会被放到 Survivor 区,这时候涉及到对象的复制,这个过程相比于标记更加耗时。然而并非任何时候对象都会被放入 Survivor,当某个对象过大,超过设置的阈值,就会进入老年代,如果某个 survivor 区的对象已经存活了很多代,jvm 会让它晋升到老年代,或者在 Young 回收时,如果 survivor 区无法容纳剩余的对象,一些对象也会被放入老年代。young 区回收速度很快,GC roots + 老年代对象来做可达路径分析。

  • 疑问1:如果回收时发现 survivor 区无法容纳剩余的年轻代对象,是 survior 区那些代数高的进入老年代还是无法被分配的进入老年代呢?个人猜想,是代数高的进入老年代,一方面这种方式显然更合理,另一方面,也不难实现,就是清除后,先把 eden 区存活的对象放入 To 区,然后把 From 区存活的对象按代数的低到高放入 To,如果放不下,那么就晋升。

Old

CMS 回收实际上不等同于 Full GC。因为 CMS GC 本身是由自己的一个线程来触发的,检查条件是 Old 区剩余的对象是否大于了某个比例,这个比例可以通过CMSInitiatingOccupancyFraction设置,默认是 94,设置小了就会频繁的 CMS GC,而且如果回收不掉会很麻烦,可能导致一直 CMS GC,而设置大了,由于一直不 CMS GC,导致 Full GC 的触发门槛变低。只要是使用了 CMS GC,那么这个回收机制就会存在的。它有一个别名,叫 CMS background mode,也就是后台模式,注意,这种 GC 是不会去关新生代和堆内存的。因此如果出现了跨代引用或者循环引用,CMS GC 就没有能力去解决问题。为了提高这种 CMS GC 的效果,引入了一种机制,就是在 CMS GC 之前触发一次 Young GC,这样能解决跨代引用的问题,参数为CMSScavengeBeforeRemark,字面意思上可以看出,是在 remark 的时候先做一次清扫,既 young gc,一方面能够解决跨代引用的问题,提升回收效果,另一方面,也能减少 Old 区标记的成本,因为一些引用了老年代的年轻代会作为 GC Roots。

接下来谈谈 Full GC,一般的 Full GC 条件是:

  1. 如果 JVM 往次统计的晋升对象的平均大小或者 young 区对象的大小超过了老年代剩余的空间,就会触发 Full GC,这种是 VM 线程判断并触发的。【MSC 压缩】
  2. System.gc() 或者外部 jmap -dump:live,jmap -histo: 【MSC 压缩】
  3. 如果有一个大对象,Old 区装不下
  4. permgen 或者 metaspace 空间不足
  5. young promotion failed,也就是说 survivor 空间不够了,而剩余的对象也不能全放到 Old 的时候

但是对于 CMS 来说,会相对复杂一些。因为 CMS 希望尽量减少 Full GC。那么它做了一些工作,这里先抛开主动触发 Full GC 这种方式,其他的几种方式自然是会触发 Full GC 的,CMS 默认的 Full GC 方式是 MSC,也就是标记、清理、压缩,它负责了新生代、老年代、堆外内存、永久代(一些参数可配,例如 permgen 以及 class 卸载)。而实际上,并非 Full GC 就会做压缩,因为做压缩(也就是整理)这个过程是耗时的,因此,它可以设置CMSFullGCsBeforeCompaction参数来控制 Full GC 多少次才进行压缩。有些资料说是多少次 CMS foreground GC 才被压缩,有些说是 MSC 才被压缩。这里是有点懵逼的,假设说是前者,那么就我个人的理解,CMS foreground GC 是针对于 System.gc() 触发的 GC 而设计的,它本身不会压缩,而且也比较局限。如果是 MSC 多少次再进行压缩,那么就有一种猜测,MSC 并非简单的 Serial Old GC,而是能控制不压缩的。。

如果理解为经历多少次 CMS background GC 之后,执行一次 Full GC 来做压缩,那工程里那么设置就不会有 CMS background gc 的情况了,因此起码指的不是 CMS Full GC,而 System.gc() 没有触发 Full GC,也就是说 CMS foreground mode GC 也会参与计数,那么唯一可能就是 Full GC 多少次才做压缩。。。也就是说,会有 Full GC 没有使用 MSC,可能只是 MS?

这里参考一下 R大的解答:

有一点需要注意的是:CMS并发GC不是“full GC”。HotSpot VM里对concurrent collection和full collection有明确的区分。所有带有“FullCollection”字样的VM参数都是跟真正的full GC相关,而跟CMS并发GC无关的。 

CMSFullGCsBeforeCompaction这个参数在HotSpot VM里是这样声明的: 
C++代码  收藏代码
product(bool, UseCMSCompactAtFullCollection, true,                     \  
        "Use mark sweep compact at full collections")                  \  
                                                                       \  
product(uintx, CMSFullGCsBeforeCompaction, 0,                          \  
        "Number of CMS full collection done before compaction if > 0") \  

然后这样使用的: 
C++代码  收藏代码
*should_compact =  
  UseCMSCompactAtFullCollection &&  
  ((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) ||  
   GCCause::is_user_requested_gc(gch->gc_cause()) ||  
   gch->incremental_collection_will_fail(true /* consult_young */));  

CMS GC要决定是否在full GC时做压缩,会依赖几个条件。其中, 
第一种条件,UseCMSCompactAtFullCollection 与 CMSFullGCsBeforeCompaction 是搭配使用的;前者目前默认就是true了,也就是关键在后者上。 
第二种条件是用户调用了System.gc(),而且DisableExplicitGC没有开启。 
第三种条件是young gen报告接下来如果做增量收集会失败;简单来说也就是young gen预计old gen没有足够空间来容纳下次young GC晋升的对象。 

上述三种条件的任意一种成立都会让CMS决定这次做full GC时要做压缩。 
(还有另一个参数,CMSCompactWhenClearAllSoftRefs,这个就先不说了,反正你没有配置它,而且默认也是true) 

CMSFullGCsBeforeCompaction 说的是,在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。 
把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的full GC才做一次压缩(而不是每10次CMS并发GC就做一次压缩,目前VM里没有这样的参数)。这会使full GC更少做压缩,也就更容易使CMS的old gen受碎片化问题的困扰。 
本来这个参数就是用来配置降低full GC压缩的频率,以期减少某些full GC的暂停时间。CMS回退到full GC时用的算法是mark-sweep-compact,但compaction是可选的,不做的话碎片化会严重些但这次full GC的暂停时间会短些;这是个取舍。

谈谈之前的误解,其实 CMSFullGCsBeforeCompaction 不是完全决定是否压缩,而它只是决定是否要压缩的一个条件,默认的情况下,在 System.gc 生效并且不是 foreground 模式的情况下,old 区剩余空间被判断为没有能力容纳 young 区回收后的结果的时候会触发压缩 Full GC,如果每种情况都触发压缩 Full GC,这个耗时是比较长的,为了减少这种情况,CMS 在 MSC 的时候,做了一个妥协策略,就是让一些 Full GC 触发条件触发非压缩的 Full GC,而一些去触发压缩的 Full GC,但是总触发非压缩的 Full GC 也不是个办法,于是就多了一个参数,多少次不压缩 Full GC 后再压缩。而 ExplictGCinvokesConcurrent 是对 System.gc()再做一次优化,这样 System.gc() 就不会触发 压缩的 MSC,而是 CMS foreground mode GC,而其他情况还是会执行不压缩的 Full GC 或者压缩的 Full GC。

CMS background GC ->

接下来谈主动触发 GC,这里 System.gc() 和 jmap 命令触发不同,有些 jmap 命令是会触发 Full GC 的,有些是不会的。System.gc() 默认是触发 Full GC 的,一些 jar 包用了 System.gc(),导致自己的服务变得不可控,因此很多时候会禁掉这种方式触发 GC,而这样又会导致一个问题,就是这些 jar 包如果用了堆外内存,导致这些堆外内存无法回收,有可能会因为空间不足触发 Full GC,因此还是希望他们生效。这里就引入了另一种方式,那就是ExplicitGCInvokesConcurrent,期望 GC 调用时是并发的,这个参数我理解是专门为 System.gc 准备的,也就是这时候不会调用 MSC,而是 CMS foreground mode,对堆外内存做了回收。但是它仍然无法解决循环引用的问题,实测。所以可以理解为这种模式是 CMS 和 Full GC 的中间体,它不是 CMS Old GC,同时也不是 MSC Full GC。

最后,从 CMS 的 GC 模式理解一下 CMS,CMS 在 foreground mode 和 background mode 这两种模式下,是会产生浮空垃圾的,而且需要压缩。这里思考一下,为什么会产生浮空垃圾,为什么不压缩,为什么不用指针碰撞法去做整理,而是用空闲链表法。1. 为什么 CMS 用的是空闲链表法,而不是用指针碰撞发,为什么它会产生浮空垃圾,为什么它不压缩

首先,浮空垃圾产生的原因在于,CMS 是并发标记清除,在 concurrent mark 阶段的过程中,有一些新的垃圾对象没有被标记,而 remark 阶段只是检查已经标记的垃圾是否又被引用,并没有处理这些新产生的垃圾,因此这些垃圾对象存活了下来,需要等到下一次 CMS GC 才可能会被回收。这是浮空垃圾的原因,这和是否压缩,是否采用空闲链表关系不大。而为什么空闲链表法,其主要原因在于它不会压缩,这种不去压缩的算法,必须用空闲链表法来提高内存分配的比率。而最终要解决的问题,就是为什么它不压缩。CMS 主要关注低延迟,因而采用并发方式,清理垃圾时,应用程序还在运行,也就是它在并发清除。如果采用压缩算法,则涉及到要移动应用程序的存活对象,此时不停顿,是很难处理的,一般需要停顿下,移动存活对象,再让应用程序继续运行,但这样停顿时间变长,延迟变大,所以 CMS 采用清除算法。https://segmentfault.com/a/1190000004707217

  • 疑问1:System.gc 触发的这种 foreground mode 在压缩方面会被认为是 Full GC 吗,也就是会触发压缩吗?个人理解不会。
  • 疑问2:ExplicitGCInvokesConcurrent 这种设置会导致 vm 线程触发的 Full GC 不是 MSC 吗?个人理解不会,感觉这种模式只是作用于 System.gc 上,并非作用于整体的 Full GC 上。

至此,CMS 总结暂告一段落,希望以后通过读源码了解。而后应该把重点放在 G1 上。