【JVM】二十四、系统无响应?直击JVM GC如何成为线上服务的致命弱点!

本文将对JVM 垃圾回收器的相关内容进行总结,梳理一些关键术语的确切含义,为后面的内容做好铺垫

1、前文回顾

我们已经深入分析了Java虚拟机(JVM)的核心运行机制,以及垃圾回收器(GC)的工作原理。我们还通过实际案例,探讨了在何种情况下JVM可能会遇到垃圾回收问题,以及当我们谈论优化JVM时,我们实际上是在优化哪些部分。我相信,大家已经对JVM有了更深入的理解,并掌握了相关知识。

接下来我们将对JVM 垃圾回收器的相关内容进行总结,梳理一些关键术语的确切含义,为后面的内容做好铺垫。

2、揭开JVM运行内幕:它竟然最怕这件事

首先,我们来梳理一下基于Java开发的系统在部署和运行过程中的关键考虑因素。当我们的系统基于JVM(Java虚拟机)启动并运行时,最需要关注的是什么呢?

通过之前的学习,我们应该都能理解一个核心概念,那就是在JVM运行过程中,最关键的内存区域是堆内存(Heap Memory)。在堆内存中,我们会存储各种由系统中创建的对象。

而且,在堆内存中,通常会划分为两个主要的区域:新生代(Young Generation)和老年代(Old Generation)。在对象的生命周期管理中,一般来说,新创建的对象会优先被分配到新生代中。如下图所示。

image-20240421194012166

随着系统的持续运行,年轻代中的对象数量会不断增加,直至接近内存容量的极限。在这种情况下,为了腾出空间,我们需要清理年轻代中的垃圾对象,即那些没有被GC Roots引用的对象。

众所周知,GC Roots包括类的静态变量和方法的局部变量。通常,我们创建对象的操作主要发生在方法内部。然而,当一个方法执行完毕后,其局部变量将不再存在,这意味着在方法内创建的对象失去了引用,成为垃圾对象。

因此,在我们年轻代(也称为新生代)中,实际上有99%的对象都是这种失去引用的垃圾对象。

当年轻代即将填满时,会触发年轻代垃圾回收(Young Generation GC),即对年轻代进行垃圾回收,以清除其中的垃圾对象。

那么,具体如何进行垃圾回收呢?

通过之前的学习,我们已经清楚,垃圾回收是通过复制算法进行的。通常情况下,年轻代会有一个Eden区域用于创建对象,默认占据80%的内存,以及两个Survivor区域用于存放经过垃圾回收后仍然存活的对象,每个Survivor区域占据10%的内存。如下图所示。

image-20240421194443387

而且大家要注意一点,一旦要对新生代进行垃圾回收了,此时一定会停止系统程序的运行,不让系统程序执行任何代码逻辑了,这个叫做“Stop the World”
此时只能允许后台的垃圾回收器的多个垃圾回收线程去工作,执行垃圾回收,如下图。

image-20240421194914986

所谓的复制算法,简单来说,就是对所有的GC Roots进行追踪,去标记出所有被GC Roots直接或者间接引用的对象。这些对象就是存活对象。

以一个例子来说明,假设有一个类的静态变量引用了一个对象,那么这个对象就被认为是存活对象。

接下来,复制算法会将存活对象都转移到一个称为Survivor的区域内。就像上图所示,把存活的对象迁移到一块特定的Survivor区域中。

然后,复制算法会直接回收Eden区里剩下的垃圾对象,释放内存空间。最后,系统程序将继续运行。如下图。

image-20240421195307964

在此,我们需要强调一个关键的问题,那就是,当我们的新生代空间被填满后,垃圾回收器开始工作时,系统程序的运行必须暂停。这个问题对于基于JVM运行的系统来说,是极其严重的问题,因为它会引发系统的卡顿问题。

设想一下,如果一次年轻代垃圾回收需要20毫秒,那么在这20毫秒的时间里,系统将无法进行任何操作。在这个期间,用户对系统的所有请求都无法得到处理,系统必须暂停20毫秒。这无疑是一种严重的性能瓶颈,严重影响了系统的响应速度和用户体验。如下图所示。

image-20240421195534531

3、揭秘年轻代垃圾回收的黄金周期

现在让我们来讨论一个问题:年轻代垃圾收集(GC)对系统性能的影响究竟有多大?

实际上,在大多数情况下,年轻代GC对系统性能的影响并不显著。大家或许已经注意到,年轻代GC的调优空间其实非常有限,这主要是因为其运行逻辑相对简单明了:当Eden区填满了无法再放置新对象时,就会触发一次GC操作。

如果我们真的需要对年轻代GC进行优化,那么关键在于合理分配堆内存和新生代内存。只要为系统分配了足够的内存,通常情况下,系统可能在几个小时内只会有一次新生代GC操作,而在高峰期,最多可能每几分钟就有一次新生代GC。

一般来说,业务系统通常部署在2核4G或4核8G的机器上,在这种情况下,分配给堆的内存通常不会超过3G,而分配给新生代中的Eden区的内存大约是1G左右。

此外,新生代采用的复制算法效率非常高,因为新生代中存活的对象数量很少。只需迅速标记出这些少量存活对象,将它们移动到Survivor区,然后回收其他所有垃圾对象,这个过程非常快速。

很多时候,一次新生代GC操作可能只消耗几毫秒到几十毫秒的时间。想象一下,如果你的系统正在运行,每隔几分钟或几十分钟执行一次新生代GC,系统卡顿几十毫秒,这段时间内的请求会卡顿几十毫秒,对于用户来说几乎是无感知的。因此,我们可以认为,在大多数情况下,新生代GC对系统性能的影响是微乎其微的。

4、新生代GC如何在特定时刻引发轰动效应?

在什么情况下,新生代垃圾回收(GC)会对系统产生重大影响呢?

当系统部署在具有大内存的机器上时,比如你的机器是32核64GB的配置,你为系统分配了几十GB的内存,其中新生代的Eden区可能占用30GB到40GB的内存。

对于大数据相关的系统,如User、Elasticsearch等,通常都会部署在大内存的机器上。在这种情况下,如果系统的负载非常高,例如每秒有几万次的访问请求发送到User或Elasticsearch,那么可能会导致Eden区的几十GB内存频繁填满,从而触发垃圾回收。假设每分钟会填满一次。

每次垃圾回收都需要暂停User、Elasticsearch的运行,并执行垃圾回收,这可能需要几秒钟的时间。这时你会发现,每隔一分钟,你的系统就会卡顿几秒钟。有些请求一旦卡顿几秒钟,就会超时报错,这可能导致你的系统频繁出错。

为了避免这种情况,可以考虑优化垃圾回收策略,例如调整新生代的大小,或者选择使用更适合大数据场景的垃圾回收器。此外,还可以通过优化系统负载和性能,减少垃圾回收的频率,从而提高系统的稳定性和响应速度。

5、告别迟钝:新生代GC的内存管理与性能突破策略

那么,如何优化解决在数十G大内存机器上新生代垃圾回收(GC)过慢的问题呢?

解决方案是使用G1垃圾回收器。

众所周知,对于G1垃圾回收器,我们可以设定一个预期的每次垃圾回收的停顿时间,例如,我们可以将其设定为20毫秒。

基于G1的Region内存划分原理,运行一段时间后,例如,可以对2G内存的Region进行垃圾回收,此时,系统只会停顿20毫秒,然后清除2G的内存空间,释放部分内存,之后,系统还可以继续运行。

G1天生就适合在大内存机器的JVM上运行,能够有效地解决大内存垃圾回收时间过长的问题。

6、老年代GC频发,我们该如何应对?

新生代垃圾回收通常不会遇到太大的问题。然而,真正的挑战在于频繁触发老年代的GC。让我们重新审视一下对象进入老年代的几个条件。

首先,当对象的年龄过大时,这些对象通常是系统中的核心组件,需要长期存在。这些对象很少需要被回收,因此在新生代经过默认的15次垃圾回收后,它们会进入老年代。

其次,动态年龄判断规则是另一个重要因素。如果在一次新生代GC之后,发现Survivor区域中不同年龄的对象的总大小超过了该区域的50%,例如年龄为1、2和3的对象的大小总和超过了Survivor区域的50%,那么年龄为3及以上的对象将被移动到老年代。

第三,如果新生代垃圾回收后存活的对象过多,无法放入Survivor区域,这些对象将直接进入老年代。

在上述条件中,第二个和第三个条件尤为关键。如果你的新生代中的Survivor区域内存过小,这将导致第二个和第三个条件频繁发生。这会导致大量对象迅速进入老年代,从而频繁触发老年代的GC。

因此,合理配置和管理新生代中的Survivor区域内存对于避免频繁触发老年代的GC至关重要。如下图。

image-20240421195709837

一般来说,老年代GC的耗时至少是新生代GC的10倍以上。例如,如果新生代GC每次耗时200毫秒,这对用户的影响可能并不明显。但是,如果老年代GC每次耗时2秒,那么用户可能会在页面上感受到明显的卡顿,这对用户体验的影响就非常大了。

如果你的JVM内存分配不合理,导致频繁进行老年代GC,例如每隔几分钟就有一次老年代GC,每次GC都会使系统暂停几秒钟,那么这将对你的系统造成致命的打击。在这种情况下,用户可能会发现在页面上或者APP上,点击按钮后经常性的出现卡顿几秒钟的情况。

如果你的系统出现了这种情况,那么相信我,用户体验会非常的不好。因此,合理地分配JVM内存,避免频繁的老年代GC,是提高系统性能,提升用户体验的重要任务。