面向面试编程:JVM的CMS垃圾回收器

90 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第15天,点击查看活动详情

面试官:JVM老年代垃圾回收器CMS你了解过吗?

CMS垃圾回收的基本原理

一般老年代我们选择的垃圾回收器是CMS,他采用的是标记清理算法,其实非常简单,就是标记方法去标记出哪些对象是垃圾对象,然后就把这些垃圾对象清理掉,

一个老年代内存区域的对象分布情况,现在假设因为老年代内存空间小于了历次Minor GC后升入老年代对象的平均大小,判断Minor GC有风险,可能就会提前触发Full GC回收老年代的垃圾对象。

或者是一次Minor GC后的对象太多了,都要升入老年代,发现空间不足,出发了一次老年代的Full GC。

总之就是要进行Full GC了,此时所谓的标记-清理算法,先通过追踪GC Roots的方法,看看各个对象是否被GC Roots给引用了,如果是的话,那就是存活对象,否则就是垃圾对象。先将垃圾对象都标记出来,然后一次性把垃圾对象都回收掉。这种方法其实最大的问题,就是会造成很多内存碎片。

这就是CMS采取的“标记-清理”算法。

面试官:如果Stop the World然后垃圾回收会如何?

如果停止一切工作线程,然后慢慢的去执行“标记-清理”算法,会导致系统卡死时间过长,很多响应无法处理。

所以CMS垃圾回收器采取的是垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。

面试官:CMS如何实现系统一边工作的同时进行垃圾回收?

CMS在执行一次垃圾回收的过程一共分为4个阶段:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清理

首先,CMS要进行垃圾回收时,会先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入“Stop the World”状态。所谓的“初始标记”,他是说标记出来所有GC Roots直接引用的对象。所以第一个阶段,初始标记,虽然说要造成“Stop the World”暂停一切工作线程,但是其实影响不大,因为他的速度很快,仅仅标记GC Roots直接引用的那些对象罢了。

接着第二个阶段,是并发标记,这个阶段会让系统线程可以随意创建各种新对象,继续运行,在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。但是这个过程中,在进行并发标记的时候,系统程序会不停的工作,他可能会各种创建出来新的对象,部分对象可能成为垃圾。第二个阶段,就是对老年代所有对象进行GC Roots追踪,其实是最耗时的,他需要追踪所有对象是否从根源上被GC Roots引用了,但是这个最耗时的阶段,是跟系统程序并发运行的,所以其实这个阶段不会对系统运行造成影响的。

接着会进入第三个阶段,重新标记阶段,因为第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾。所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的。所以此时进入第三阶段,要继续让系统程序停下来,再次进入“Stop the World”阶段。然后重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况。这个重新标记的阶段,是速度很快的,他其实就是对在第二阶段中被系统程序运行变动过的少数对象进行标记,所以运行速度很快。

接着重新恢复系统程序的运行,进入第四阶段:并发清理。这个阶段就是让系统程序随意运行,然后他来清理掉之前标记为垃圾的对象即可。这个阶段其实是很耗时的,因为需要进行对象的清理,但是他也是跟系统程序并发运行的,所以其实也不影响系统程序的执行。

CMS垃圾回收机制性能分析

其实看完CMS的垃圾回收机制之后,就会发现,他已经尽可能的进行了性能优化了。因为最耗时的,其实就是对老年代全部对相关进行GC Roots追踪,标记出来到底哪些可以回收,然后就是对各种垃圾对象从内存里清理掉,这是最耗时的。但是他的第二阶段和第四阶段,都是和系统程序并发执行的,所以基本这两个最耗时的阶段对性能影响不大。只有第一个阶段和第三个阶段是需要“Stop the World”的,但是这两个阶段都是简单的标记而已,速度非常的快,所以基本上对系统运行响应也不大。

并发回收垃圾导致CPU资源紧张

CMS垃圾回收器有一个最大的问题,虽然能在垃圾回收的同时让系统同时工作,但是大家发现没有,在并发标记和并发清理两个最耗时的阶段,垃圾回收线程和系统工作线程同时工作,会导致有限的CPU资源被垃圾回收线程占用了一部分。并发标记的时候,需要对GC Roots进行深度追踪,看所有对象里面到底有多少人是存活的。

但是因为老年代里存活对象是比较多的,这个过程会追踪大量的对象,所以耗时较高。并发清理,又需要把垃圾对象从各种随机的内存位置清理掉,也是比较耗时的。所以在这两个阶段,CMS的垃圾回收线程是比较耗费CPU资源的。CMS默认启动的垃圾回收线程的数量是(CPU核数 + 3)/ 4。

我们用最普通的2核4G机器和4核8G机器来计算一下,假设是2核CPU,本来CPU资源就有限,结果此时CMS还会有个“(2 + 3) / 4” = 1个垃圾回收线程,去占用宝贵的一个CPU。

所以其实CMS这个并发垃圾回收的机制,第一个问题就是会消耗CPU资源。

面试官:Concurrent Mode Failure问题是什么

在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象。但是这个阶段系统一直在运行,可能会随着系统运行让一些对象进入老年代,同时还变成垃圾对象,这种垃圾对象是“浮动垃圾”。在并发清理期间,系统程序可能先把某些对象分配在新生代,然后可能触发了一次Minor GC,一些对象进入了老年代,然后短时间内又没人引用这些对象了。这种对象,就是老年代的“浮动垃圾”。因为他虽然成为了垃圾,但是CMS只能回收之前标记出来的垃圾对象,不会回收他们,需要等到下一次GC的时候才会回收他们。

所以为了保证在CMS垃圾回收期间,还有一定的内存空间让一些对象可以进入老年代,一般会预留一些空间。CMS垃圾回收的触发时机,其中有一个就是当老年代内存占用达到一定比例了,就自动执行GC。

“-XX:CMSInitiatingOccupancyFaction”参数可以用来设置老年代占用多少比例的时候触发CMS垃圾回收,JDK 1.6里面默认的值是92%。也就是说,老年代占用了92%空间了,就自动进行CMS垃圾回收,预留8%的空间给并发回收期间,系统程序把一些新对象放入老年代中。

那么如果CMS垃圾回收期间,系统程序要放入老年代的对象大于了可用内存空间,此时会如何?这个时候,会发生Concurrent Mode Failure,就是说并发垃圾回收失败了,我一边回收,你一边把对象放入老年代,内存都不够了。此时就会自动用“Serial Old”垃圾回收器替代CMS,就是直接强行把系统程序“Stop the World”,重新进行长时间的GC Roots追踪,标记出来全部垃圾对象,不允许新的对象产生,然后一次性把垃圾对象都回收掉,完事儿了再恢复系统线程。

所以在生产实践中,这个自动触发CMS垃圾回收的比例需要合理优化一下,避免“Concurrent Mode Failure”问题。

内存碎片问题

上面说过内存碎片的问题,就是老年代的CMS采用“标记-清理”算法,每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生。如果内存碎片太多,会导致后续对象进入老年代找不到可用的连续内存空间了,然后触发Full GC。

所以CMS不是完全就仅仅用“标记-清理”算法的,因为太多的内存碎片实际上会导致更加频繁的Full GC。

CMS有一个参数是“-XX:+UseCMSCompactAtFullCollection”,默认就打开了。他意思是在Full GC之后要再次进行“Stop the World”,停止工作线程,然后进行碎片整理,就是把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片。

还有一个参数是“-XX:CMSFullGCsBeforeCompaction”,这个意思是执行多少次Full GC之后再执行一次内存碎片整理的工作,默认是0,意思就是每次Full GC之后都会进行一次内存整理。