【JVM】十六、CMS垃圾回收器的秘密工作流程

在本文中,我们将深入探讨VM中最为核心的部分——老年代垃圾回收机制

1、前文回顾

在本文中,我们将深入探讨VM中最为核心的部分——老年代垃圾回收机制。在此之前,我们已对JVM的核心原理进行了阐述,并对年轻代的垃圾回收机制进行了详细的解读。我们知道,年轻代的垃圾回收主要通过复制算法来实现,相对而言,这一过程较为简单。

理想情况下,我们希望所有对象都分配在新生代的Eden区,经过垃圾回收后,存活的对象被迁移到Survivor区。在下一次垃圾回收时,这些存活的对象再次迁移到另一个Survivor区。这样,只有极少数对象会进入老年代,从而几乎不会触发老年代的垃圾回收。

然而,理想与现实往往存在差距。在实际编程过程中,很少有开发者会考虑到垃圾回收的问题,他们通常专注于编写代码,然后直接部署上线,而忽视了代码对垃圾回收的影响。有经验的工程师可能会在上线前,根据之前的案例分析,估算系统的内存压力和垃圾回收的运行模式,进而合理设置内存区域的大小,以减少对象进入老年代的数量。

然而,实际情况是,线上系统可能会因为各种原因,导致大量对象进入老年代,甚至频繁触发老年代的全面垃圾回收(Full GC)。例如,如果Survivor区过小,无法容纳每次Minor GC后的存活对象,就会导致对象频繁进入老年代,从而频繁触发老年代的全面垃圾回收。

类似的情况实际上还有很多,因此,我们不能过于理想化地期待永远没有老年代的垃圾回收。我们需要对老年代的垃圾回收器如何进行回收有一个清晰的理解和认识。

2、CMS垃圾回收的基本原理

一般老年代我们选择的垃圾回收器是CMS,它采用的是标记清理算法,其实非常简单,就是先用之前文章里讲过的标记方法去标记出哪些对象是垃圾对象,然后就把这些垃圾对象清理掉,如下图所示。

上面图里是一个老年代内存区域的对象分布情况,现在假设因为老年代内存空间小于了历次Minor GC后升入老年代对象的平均大小,判断Minor GC有风险,可能就会提前触发Full GC回收老年代的垃圾对象。
或者是一次Minor GC后的对象太多了,都要升入老年代,发现空间不足,出发了一次老年代的Full GC。
总之就是要进行Full GC了,此时所谓的标记-清理算法,其实就是我们之前给大家讲过的一个算法,先通过追踪GC Roots的方法,看看各个对象是否被GC Roots给引用了,如果是的话,那就是存活对象,否则就是垃圾对象。
先将垃圾对象都标记出来,然后一次性把垃圾对象都回收掉,如下图。

image-20240419094818984

这种方法其实最大的问题,就是会造成很多内存碎片
大家看下图的红圈处就是所谓的内存碎片,这种碎片不大不小的,可能放不小 任何一个对象,那么这个内存就被浪费了,之前我们聊过这个问题。

image-20240419095019674

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

3、Stop the World后,垃圾回收会引发什么?

如果采用先“Stop the World”(STW)再执行“标记-清理”算法,将会导致系统在垃圾回收期间无法处理用户请求,从而引发较长时间的停顿。这是因为在“Stop the World”阶段,所有的应用线程都会被暂停,以便进行垃圾回收操作。

为了减少“Stop the World”对系统性能的影响,现代垃圾回收器如CMS和G1采用了不同的策略和技术来最小化停顿时间,例如并发标记和增量收集等。这些技术允许垃圾回收线程和应用线程尽可能地同时运行,以减少对系统性能的影响。

4、CMS如何在繁忙工作中无缝执行垃圾回收?

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

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

我们一点一点来看。
首先,CMS要进行垃圾回收时,会先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入“Stop the World”状态,如下图。

image-20240419095339034

所谓的“初始标记”,他是说标记出来所有GC Roots直接引用的对象,这是啥意思呢?比如下面的代码。

public class User {
    private static UserManager userManager = new UserManager();
}
public class UserManager {
    private static UserInfoFetcher userFetcher = new UserInfoFetcher();
}

在垃圾回收的初始标记阶段,系统会通过“UserManager”这个类的静态变量代表的GC Roots,去标记出直接引用的UserManager对象,这就是初始标记的过程。

在此过程中,系统并不会去关注UserInfoFetcher这种对象,因为UserInfoFetcher对象是被UserManager类的“UserInfoFetcher”实例变量引用的。

之前我们提到,方法的局部变量和类的静态变量都被视为GC Roots,这是因为它们在程序运行过程中一直存在,不会被垃圾回收器清理。然而,类的实例变量并不被视作GC Roots,因为它们的生命周期与实例对象相同,只有在实例对象被垃圾回收器清理时,这些实例变量才会被清理。如下图所示。

image-20240419100134946

在垃圾回收的第一个阶段,我们进行的是初始标记。这个阶段的目标是找出所有的GC Roots直接引用的对象,以便于后续的垃圾回收操作。虽然这个过程被称为“Stop the World”,意味着在这个过程中,所有的应用线程都会被暂停,但是实际上,由于这个阶段的操作非常快速,所以对于系统的影响并不大。

接下来,我们进入第二个阶段,也就是并发标记阶段。在这个阶段中,系统的应用线程可以自由地创建新的对象,并且继续执行。在这个过程中,可能会产生新的存活对象,也可能会导致一些原本的存活对象失去引用,从而成为垃圾对象。与此同时,垃圾回收线程会尽可能地对已有的对象进行GC Roots追踪。

所谓的GC Roots追踪,就是找出所有的老年代对象,比如“UserInfoFetcher”,然后查看这些对象被哪些其他对象引用。例如,如果 “UserInfoFetcher” 被 “UserManager” 的一个实例变量引用,那么我们就会进一步查看,“UserManager”又被谁引用。如果发现“UserManager”被“User”类的一个静态变量引用,那么我们就可以确定,“UserInfoFetcher”是被GC Roots间接引用的,因此在这个阶段,我们不需要对其进行回收。如下图所示。

image-20240419100456815

但是这个过程中,在进行并发标记的时候,系统程序会不停的工作,他可能会各种创建出来新的对象,部分对象可能成为垃圾,如下图所示。

image-20240419100649357

第二个阶段,即对老年代所有对象进行GC Roots追踪,实际上是最耗时的阶段。在这个阶段中,垃圾回收器需要追踪所有对象是否从根源上被GC Roots引用了。尽管这是一个耗时的阶段,但它是与系统程序并发运行的,因此实际上不会对系统运行造成影响。

接下来,垃圾回收过程将进入第三个阶段,即重新标记阶段。在第二阶段中,垃圾回收器一边标记存活对象和垃圾对象,一边系统在不停地运行并创建新对象,这可能导致一些原本是存活对象的老对象变成垃圾对象。

因此,在第二阶段结束之后,一定会有很多之前未被标记出的存活对象和垃圾对象。这些对象将在第三阶段进行重新标记,以确保准确识别出存活对象和垃圾对象,从而为垃圾回收器的后续操作提供准确的依据。如下图。

image-20240419101351693

所以此时进入第三阶段,要继续让系统程序停下来,再次进入“Stop the World”阶段。
然后重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况,如下图。

image-20240419101626618

在这个重新标记的阶段,速度非常快。实际上,它只是在第二阶段中被系统程序运行变动过的少数对象进行标记,因此运行速度非常迅速。
接下来,系统程序会重新开始运行,并进入第四阶段:并发清理。这个阶段允许系统程序自由运行,同时清理之前标记为垃圾的对象。
虽然这个阶段可能会比较耗时,因为需要进行对象的清理工作,但是它是与系统程序并发运行的,因此并不会影响到系统程序的执行。如下图。

image-20240419101825322

5、对CMS的垃圾回收机制进行性能分析

在深入研究CMS垃圾回收机制后,我们会发现它已经尽可能地进行了性能优化。其中最耗时的部分是对老年代的全局GC Roots追踪,以确定哪些对象可以回收,接着是清理内存中的各种垃存对象,这一过程确实非常耗费时间。

然而,值得注意的是,CMS垃圾回收的第二阶段和第四阶段可以与系统程序并发执行,因此,尽管这两个阶段较为耗时,但对整体性能的影响并不显著。只有第一阶段和第三阶段需要“停止世界”(即暂停所有应用线程),但这两个阶段仅仅涉及到简单的标记操作,速度极快,因此对系统的运行响应影响也微乎其微。

在接下来的文章中,我们将深入探讨CMS垃圾回收机制的各种细节,以及如何设置相关参数。