深入解析CMS垃圾回收器

发布时间 2023-08-29 16:27:16作者: Booksea

本文已收录至GitHub,推荐阅读 ? Java随想录

微信公众号:Java随想录

原创不易,注重版权。转载请注明原作者和原文链接

前面几篇文章都在介绍GC的工作原理,下面开始大家期待的垃圾回收器章节。一共有三篇:CMS、G1和ZGC。

本篇文章先来介绍CMS。

纵观全书《深入理解JVM虚拟机》第三版,在垃圾回收器这一篇章,对于CMS的笔墨是非常多的。

CMS也是JVM面试的一个重点,只要说起垃圾回收器,CMS可以说不得不问,聊好了,会让面试官觉得你有两把刷子。

话不多说,直接进入正题。

CMS简介

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS启用参数:-XX:+UseConMarkSweepGCCMS是老年代垃圾收集器,使用的是标记-清除算法。

在CMS之前的垃圾回收器,要么就是串行垃圾回收方式,要么就是关注系统吞吐量,而 CMS 垃圾回收器的出现,则打破了这个尴尬的局面。

CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,其开启了 GC 回收器关注 GC 停顿时间的历史。

CMS 垃圾回收器之所以能够实现对 GC 停顿时间的控制,其关键是「三色标记算法」(不了解的同学去翻我之前写的文章)。

通过三色标记算法,实现了垃圾回收线程与用户线程并发执行,从而极大地降低了系统响应时间。

如果在JDK9之后使用CMS垃圾收集器后,默认年轻代就为ParNew收集器,并且不可更改,同时JDK9之后被标记为不推荐使用,JDK14就被删除了。

可以说CMS是垃圾回收器的一个里程碑。

运作过程

CMS整个运作过程分为四个大阶段,包括:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

注意:这里说的是四个大阶段,中间还会有其他的过渡小阶段,但是最主要的时间损耗在这四个阶段上。

其中「初始标记」、「重新标记」这两个步骤仍然需要Stop The World,这点是需要注意的。

CMS在各个阶段都做了哪些事呢,别着急,听我慢慢道来。

初始标记

这一步仍然需要暂停所有的其他线程,但这个阶段会很快完成。它的目的是「标记所有的根对象,以及被根对象直接引用的对象,以及年轻代指向老年代的对象」。

也就是说初始标记阶段,只会标记第一层,不会向下追溯去深度遍历。

并发标记

在此阶段中,垃圾回收器将遍历对象图,从GC Roots「向下追溯」,标记所有可达的对象。这个过程是四个阶段中耗时最长的,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

在此阶段,应用线程与垃圾回收线程是并发运行的。如果应用线程产生了新的对象,并且修改了老年代中的对象引用,那么这些变化可能被并发进行的垃圾回收线程忽略掉,这就可能造成「漏标」问题,即有些本该被标记的对象没有被标记。

为了解决这个问题,CMS采用了卡表。

当应用线程试图修改老年代的某个对象引用时,把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。

关于卡表,之前在讲跨代引用的时候介绍过,忘记的同学去翻翻我之前写的文章。

并发预处理

并发预处理可以通过参数:-XX:-CMSPrecleaningEnabled控制,默认开启。

并发预处理阶段用户线程可以与垃圾回收线程一起执行。

并发预处理目的在于希望能尽可能减少下一个阶段「重新标记」所消耗的时间,因为下一个阶段重新标记是需要Stop The World的。

在前个并发阶段中,老年代的对象引用关系可能会发生变化,所以并发预处理这个阶段会扫描可能由于并发标记时导致老年代发生变化的对象,会再扫描一遍标记为Dirty的卡页,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。

可取消的并发预处理

此阶段也不停止应用程序,本阶段尝试在STW的最终标记阶段之前尽可能多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件。

在该阶段,主要循环去做两件事:

  1. 处理 From 和 To 区的对象,标记可达的老年代对象。
  2. 和上一个阶段一样,扫描处理Dirty Card中的对象。

在预处理步骤后,如果满足下面这个条件,就会开启可中断的预处理:

  • Eden的使用空间大于-XX:CMSScheduleRemarkEdenSizeThreshold,这个参数的默认值是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。

如果满足下面的条件,就会退出循环:

  • 设置了CMSMaxAbortablePrecleanLoops循环次数,并且执行的次数大于或者等于这个值的时候,默认为0
  • CMSMaxAbortablePrecleanTime,执行可中断预清理的时间超过了这个值,这个参数的默认值是5000毫秒
  • Eden的使用率达到-XX:CMSScheduleRemarkEdenPenetration这个参数的默认值是50%

如果在可取消的并发预处理能够发生一次Minor GC,那样能够减轻重新标记阶段的工作。

如果一直没等到Minor GC,这个时候进行重新标记的话,可能会发生连续停顿,假设新生代在重新标记的时候发生了Minor GC(STW),重新标记又是STW的,因此可能会发生连续停顿。

CMS提供了参数CMSScavengeBeforeRemark,使重新标记前强制进行一次Minor GC。

这个参数有利有弊,利是降低了Remark阶段的停顿时间,弊的是在新生代对象很少的情况下也多了一次YGC,哪怕在可取消的并发预处理阶段已经发生了一次YGC,然后在该阶段又会去傻傻的触发一次。

重新标记

在重新标记(Remark)阶段,实际上是要扫描整个堆内存的,包括新生代和老年代

这是因为在并发标记阶段,应用程序线程还在运行,可能会有新对象被分配到新生代,并且可能会有引用关系的改变。如果不扫描新生代,就可能会漏掉一些被引用的对象,导致误删。

但是实际上,由于各种优化技术,比如增量更新(Incremental Update)和卡表(Card Table),重新标记阶段可以只扫描部分区域。例如,只需要扫描在并发标记阶段中被修改过的那部分堆内存区域,而无需全盘扫描整个堆内存。

上述对象中可能有一些已经在「并发预处理」阶段和「可取消的并发预处理」阶段被处理过,但总存在没来得及处理的。

这里有个小细节,其实重新标记也是可以并发执行的。

可以通过-XX:ParallelRemarkEnabled,参数启用并行重新标记,当设置为true时,它允许在重新标记阶段使用多线程。

请注意,这个选项不影响初始标记阶段,那个阶段仍将使用单线程执行。

启用-XX:ParallelRemarkEnabled参数并行执行CMS的重新标记阶段可以减少垃圾回收时应用的停止时间,但也有可能带来一些缺点:

  1. 资源消耗:并行执行需要更多的CPU资源,如果系统上运行着其他需要CPU的任务,这可能会降低它们的性能。
  2. 复杂性增加:并行化处理通常增加了系统的复杂性,可能会导致更难预测和调试的性能问题。
  3. 不稳定性:尽管并行重新标记通常可以提高效率,但在某些特定硬件和工作负载下,可能会得到相反的结果。

因此,是否使用-XX:ParallelRemarkEnabled取决于具体的应用和硬件环境。在开启这个选项之前,最好先在仿真环境中进行充分的测试,以评估它对性能的影响。

并发清除

最后是并发清除阶段,在此阶段中,垃圾回收器删除未被标记的对象,并回收他们占用的内存空间,同样,该步骤也是与应用线程并发执行的。

这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC 进行处理了,产生的这些垃圾被叫做「浮动垃圾」。

另外,CMS使用「空闲列表(free-list)」,在并发清除阶段结束后,CMS会将未被标记的内存(即垃圾对象占据的内存)收集起来,组成一个空闲列表。

这个空闲列表保存了可用于新对象分配的内存块信息。当需要分配新对象时,JVM可以直接从空闲列表中找到合适大小的内存块进行分配,而无需进行完整的垃圾回收。

但是,这种方法也有其缺点,例如可能会导致内存碎片化问题。如果连续的空闲内存块不足以满足新的内存请求,就需要触发一次完全的垃圾收集,此时则可能会引起较长时间的暂停。

这些阶段都走完了以后会重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备

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

CMS的缺陷

CMS并不完美,存在一些缺点。

处理器资源敏感

CMS收集器是比较消耗CPU资源的,对处理器资源是比较敏感的。

在并发阶段,它不会导致用户线程停顿,但会占用一部分线程(或者说处理器的计算能力)来进行垃圾回收,从而导致应用程序变慢,降低总吞吐量。

低延迟和高吞吐,往往无法同时达成,低延迟有时是牺牲高吞吐换得的,有得必有失。

CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源。

但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

CMS 回收线程数量可以通过-XX:ParallelCMSThreads=<N>这个JVM参数来设定,其中<N>代表期望的线程数。

请注意,这个参数只影响CMS中进行并发标记和清除的线程数量,并不影响其他部分(如初始标记和重新标记)的线程数量。

为了缓解这种情况,虚拟机还提供了一种称为「增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)」的CMS收集器变种。

增量式并发收集器在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些。

直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。

实践证明增量式的CMS收集器效果很一般,从JDK 7开始,i-CMS模式已经被声明为「deprecated」,即已过时不再提倡用户使用,到 JDK 9发布后i-CMS模式被完全废弃。

无法处理“浮动垃圾”

在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生。

但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为「浮动垃圾」。

就好比你妈一边在打扫房间,你一边在丢纸屑,房间永远也打扫不完。

由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集。

在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%,我们可以通过 -XX:CMSInitiatingOccupancyFraction 参数自行调节。

但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次「并发失败(Concurrent Mode Failure)」。

这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。

顺嘴提一句:Serial Old使用的是「标记-整理"(Mark-Compact)」算法。

总结:CMS收集器无法处理「浮动垃圾(Floating Garbage)」,甚至有可能出现「Con-current Mode Failure」失败进而导致另一次完全Stop The World的Full GC的产生。

内存碎片

CMS是一款基于「标记-清除」算法实现的收集器,在垃圾收集算法的时候我们说过,标记-清除会产生「内存碎片」。

空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃)。

用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,但是整理过程又必须移动存活对象

这样空间碎片问题是解决了,但停顿时间又会变长,属于是白忙活了。

因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBefore-Compaction(此参数从JDK 9开始废弃)。

这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

需要注意的是,虽然内存压缩可以减少内存碎片,提高内存利用效率,但同时也会增加GC的暂停时间,因此可能会对应用的响应性能产生负面影响。因此,在调整此参数时,需要考虑应用的特性和需求,进行适当的权衡。

总结

最后让我们对CMS做个总结:

CMS垃圾收集器在Java的垃圾回收历史上占据了重要的地位。它的出现为解决低暂停时间,高响应性的系统提供了一种新的可能。

可以说,CMS是那种将「用户体验」放在第一位的角色,它追求流畅的用户交互,避免因为垃圾收集导致的长时间停顿。

正如一个注重社交技巧,善于营造轻松气氛的人,CMS的优点在于它的并发处理能力,把大部分工作在线程间平滑处理,使得应用程序可以和垃圾收集同时进行,尽量减少了突然的停顿。这就像是在一场聚会中,「轻松愉快」是CMS的拿手好戏。

但是,每个人都有自己的短板,CMS也不例外。由于CMS为了降低暂停时间而不执行内存整理,所以在持续运行一段时间后,可能会产生很多内存碎片,影响系统的性能表现。

此外,它在并发清理时需要更多的CPU资源,这就像一个社交达人可能需要付出更多的时间和精力来处理人际关系一样。

总的来说,CMS的出现极大地推动了低延迟应用的发展,标志着垃圾收集器从单纯的内存管理进化到更加注重用户体验的阶段。尽管它也有一些问题,但没有人是完美的,我们都在不断自我改进中前进,CMS也一样。

本篇文章到这结束咯,好好消化下,觉得有收获点个赞,下篇文章继续卷。


感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。

老铁们,关注我的微信公众号「Java 随想录」,专注分享Java技术干货,文章持续更新,可以关注公众号第一时间阅读。

一起交流学习,期待与你共同进步!