【JVM】十四、深入探讨JVM中令人头痛的‘Stop the World’难题

在本文中,我们将讨论一个让Java工程师们深感困扰的问题:当我们的Java系统基于JVM运行时,究竟什么是最让我们感到痛苦的问题?

1、前文回顾

上一篇文章通过一个实际案例,深入剖析了新生代的对象分配机制,以及对象如何被迁移到老年代。我们还探讨了一个会频繁触发Full GC的场景,并提供了针对性的优化策略,相信大家对JVM的核心运行原理已经理解得相当透彻。

在本文中,我们将讨论一个让Java工程师们深感困扰的问题:当我们的Java系统基于JVM运行时,究竟什么是最让我们感到痛苦的问题?

2、新生代GC场景回顾

大家先来看下面的图,新生代的内存大家都知道是分为Eden和两个Survivor的。

那么此时如果系统不停的运行,然后把Eden给塞满了呢?如下图所示:

image-20240415141432525

在这个阶段,必然会触发Minor GC。之前我们已经讨论过,垃圾回收是由专门的垃圾回收线程来完成的。而且,不同的内存区域会由不同的垃圾回收器来处理。大家还记得这个知识点吗?实际上,垃圾回收线程和垃圾回收器是紧密合作的。它们利用自己独特的垃圾回收算法,对指定的内存区域进行垃圾回收。大家看看下图。

image-20240415142032585

通过上面这个图,我们是否已经对垃圾回收线程、垃圾回收器以及垃圾回收算法之间的清晰关系有了更深入的了解?

确实,垃圾回收是通过后台运行的垃圾回收线程来执行其具体逻辑的。例如,对于新生代对象,我们会使用ParNew垃圾回收器进行回收。而ParNew垃圾回收器在处理新生代时,采用的是复制算法。在这个过程中,垃圾回收器会将Eden区域中的存活对象标记出来,然后将它们全部转移到Survivor1区。接下来,它会一次性清空Eden区中被标记为垃圾的对象。如下图。

image-20240415142249096

接着系统继续运行,新的对象继续分配在Eden中,如下图所示。

image-20240415142349635

当Eden区域再次被填满时,将会触发一次Minor GC。在这个过程中,垃圾回收线程将执行垃圾回收器的算法逻辑,其中采用了复制算法。该算法首先会标记出Eden和Survivor1区域中的存活对象,然后将这些存活对象一次性移动到Survivor2区域。随后,Eden和Survivor1区域中的垃圾对象将被清理掉。如下图。

image-20240415142443672

3、了解何时可以安全地在GC中新增对象!

在探讨GC和JVM的运行机制时,我们常常忽略了一个关键问题:在垃圾回收过程中,我们的Java系统是否还能继续在新生代中创建新的对象?

让我们深入思考一下这个问题。假设在垃圾回收期间,我们的系统仍然可以在新生代的Eden区中创建新的对象,那么会出现什么情况呢?大家看下图。

image-20240415143445739

根据上述描述,当垃圾回收器正在尝试将Eden和Survivor2中的存活对象标记并移动到Survivor1,并且清理Eden和Survivor2中的垃圾对象时,系统程序却在Eden中不断地创建新的对象。这些新的对象中,有些很快就变成了垃圾对象,有些则仍然被引用而成为存活对象。这就引发了一个问题:如何让垃圾回收器持续追踪这些新对象的状态?

在这种情况下,我们需要考虑如何在垃圾回收过程中处理这些新创建的对象。具体来说,我们需要想办法将新对象中的存活对象转移到Survivor2中,并回收新创建的垃圾对象。

然而,有的同学可能会认为,这个问题应该由垃圾回收器来解决。但是,我们需要理解JVM的运行原理,并不应随意质疑JVM的垃圾回收机制为什么没有采用这样的设计。

实现在JVM中同时进行垃圾回收和新对象的创建可能会非常复杂,成本极高,而且难以实现。因此,从目前的情况来看,在垃圾回收过程中允许Java系统继续在Eden中不断创建新对象是不合适的。

4、如何应对JVM的'Stop the World'痛点

所以现在大家就能明白,我们在日常使用JVM时,最主要的困扰就是垃圾回收过程。

因为在进行垃圾回收时,我们需要确保垃圾回收器能够全神贯注地进行工作,不能让我们编写的Java系统继续创建对象。因此,在这个时刻,JVM会在后台直接进入“Stop the World”状态。换句话说,它会直接暂停我们编写的Java系统的所有工作线程,使我们编写的代码停止运行!然后,让垃圾回收线程可以全神贯注地进行垃圾回收的工作。如下图所示:

image-20240415143615714

这样的话,就可以让我们的系统暂停运行,然后不再创建新的对象,同时让垃圾回收线程尽快完成垃圾回收的工作,就是标记和转移Eden以及Survivor2的存活对象到Survivor1中去,然后尽快一次性回收掉Eden和Survivor2中的垃圾对象,如下图。

image-20240415143708222

接着一旦垃圾回收完毕,就可以继续恢复我们写的Java系统的工作线程的运行了,然后我们的那些代码就可以继续运行,继续在Eden中创建新的对象,如下图。

image-20240415143801039

5、Stop the World造成的系统停顿

现在我们已经清楚地认识到了“Stop the World”现象对系统造成的影响。假设在我们的系统中,Minor GC的运行时间为100毫秒,那么在这段时间内,系统将无法处理任何请求,直接导致系统停顿100毫秒。在这100毫秒的时间里,用户发起的所有请求都会经历短暂的卡顿,因为系统的工作线程暂停运行,无法处理这些请求。

假设你正在开发一个Web系统,那么当JVM执行Minor GC时,所有工作线程都会被暂停,可能导致用户在网页或APP上点击一个按钮后,需要等待几百毫秒才能收到响应,而在正常情况下,这个响应只需要几十毫秒。

让我们回想一下上篇文章中提到的案例,由于内存分配不合理,导致对象频繁进入老年代,平均每7到8分钟就会触发一次Full GC。而Full GC的速度是最慢的,有时一次垃圾回收可能需要几秒钟,甚至几十秒,极端情况下可能会达到几分钟。

在这种情况下,如果你的系统频繁地进行Full GC,那么你肯定不希望系统每隔7到8分钟就卡死30秒。在这30秒内,所有用户的请求都无法得到处理,用户只能看到系统超时的提示,这将极大地影响用户体验。

因此,无论是新生代GC还是老年代GC,我们都应该尽量避免让它们的频率过高,同时避免持续时间过长,以免影响系统的正常运行。这也是在使用JVM过程中最需要优化的地方,也是最大的一个痛点。

6、不同的垃圾回收器的不同的影响

在今天的话题中,我们将继续探讨昨天提到的垃圾回收器。首先,让我们回顾一下新生代的垃圾回收。在这其中,Serial垃圾回收器是一种使用单个线程进行垃圾回收的方式,它会导致系统工作线程在回收过程中暂停,因此,在服务器程序中,我们很少采用这种方式。

然而,我们在日常工作中更常使用的是ParNew垃圾回收器。这是一种针对多核CPU服务器进行优化的新生代垃圾回收器,它支持多线程进行垃圾回收,这可以显著提高回收性能,缩短回收时间。

后续我们将深入分析这一主题,并会向大家介绍如何优化ParNew垃圾回收器的众多参数。大致原理图如下:

image-20240415144031290

大家可以看到,不同的垃圾回收器具有各自的机制和原理,使用多线程或单线程都存在一定的区别。其中,之前提到的CMS垃圾回收器专门负责老年代的垃圾回收,它拥有一套独特的机制和原理,相对复杂。

后面我们将深入讲解CMS垃圾回收器的原理和参数优化。CMS垃圾回收器基于多线程,采用一套独特的机制,尽可能在垃圾回收过程中减少“Stop the World”的时间,以避免长时间卡死系统。

同时,我们也将进一步剖析目前许多公司广泛使用的最新的G1垃圾回收器。G1垃圾回收器采用了复杂的回收机制,以优化回收性能,并尽可能降低“Stop the World”的时间。

实际上,JVM本身的迭代演进就是不断优化垃圾回收器的机制和算法,以降低垃圾回收过程对系统运行的影响。