【JVM】十三、揭秘JVM垃圾回收器:面试必备知识,你掌握了吗?
本文通过一个真实的案例来深入理解整个对象分配以及转移到老年代的过程,以及Minor GC和Full GC的全过程
1、前文回顾
在上一篇文章中,我们详细分析了触发Minor GC的时机,以及对象何时会从新生代迁移到老年代。我们还讨论了为了确保新生代向老年代的内存迁移安全,需要在Minor GC之前如何检查老年代的内存空间,以及在什么情况下会触发老年代的Full GC,以及老年代的垃圾回收算法是什么。
在这篇文章中,我们将接着上篇文章的内容,通过一个真实的案例来深入理解整个对象分配以及转移到老年代的过程,以及Minor GC和Full GC的全过程。这个案例来自我们之前的一个生产系统,其中老年代频繁发生Full GC。通过这个案例,大家将更加透彻地理解整个过程。
2、项目背景介绍
让我们首先简要概述这个系统的案例背景。这是一个数据计算系统,它具备处理上亿条数据的强大能力。为了帮助大家更好地集中注意力理解这个系统在生产环境中与JVM相关的部分,我们会对系统本身的描述进行简化。
简而言之,这个系统的主要功能是不断地从MySQL数据库以及其他数据源中提取大量数据,然后加载到其自身的JVM内存中进行计算处理,如下图所示。
这个数据计算系统会持续地通过SQL语句和其他方式从各种数据存储中提取数据到内存中进行计算。在生产环境中,每分钟大约需要执行500次数据提取和计算的任务。由于这是一套分布式运行的系统,因此在生产环境中部署了多台机器。每台机器大致每分钟负责执行100次数据提取和计算的任务。
每次数据提取,大约会将1万条左右的数据加载到内存中进行计算,平均每次计算大约需要耗费10秒左右的时间。
每台机器的配置为4核8G,其中JVM内存分配了4G。在JVM内存中,新生代和老年代各占1.5G的内存空间。具体配置可参考下图。

3、新生代内存何时会不堪重负?
现在我们已经确定了一些关键的数据指标,接下来我们将探讨这个系统会在多长时间内填满新生代的内存。
考虑到在这个系统中,每台机器上部署的实例每分钟需要执行100次数据计算任务,每次处理1万条数据,耗时10秒。那么,我们将分析每次处理的1万条数据大约会占用多少内存空间。
假设每条数据都是相对较大的,平均包含20个字段,我们可以估计每条数据的大小约为1KB。因此,每次计算任务处理的1万条数据大约对应10MB的内存空间。
此时,我们可以思考一下,如果新生代内存按照8:1:1的比例分配给Eden区和两个Survivor区,那么大约会是Eden区占1.2GB,每个Survivor区约占100MB,如下所示。

每次当一个计算任务被执行时,系统会在新生代的Eden区分配大约10MB的对象空间。基于这样的分配模式,我们可以推断,在一分钟内,大约有100个此类计算任务被执行。进一步地,如果我们对这一过程进行细致的观察,会发现在经过一分钟的时间后,Eden区基本上会被对象填满,达到了其容量的饱和状态。
4、Minor GC后,哪些对象会晋升到老年代?
在当前的假设场景中,我们设想新生代的Eden区在1分钟后已经充满了对象。随着计算任务的继续执行,系统将不可避免地需要触发Minor GC来回收一部分无用的垃圾对象。
在上文的内容中,我们已经讨论了在进行Minor GC之前会进行的一项检查。首先,我们需要确认老年代的可用内存空间是否大于新生代的全部对象。
通过观察下图,我们可以看到,此时老年代是空的,大约有1.5GB的可用内存空间。而新生代的Eden区,我们可以大致估计其中有1.2GB的对象。

在当前的内存分配状况中,我们可以观察到老年代的可用内存空间为1.5GB,而新生代中的对象总和占据了1.2GB的空间。在这种情况下,即使进行一次Minor GC并且所有的对象都存活下来,这些对象也完全能够被容纳在老年代的剩余空间中。因此,系统会直接触发执行Minor GC。
接下来,我们需要关注的问题是在Eden区中,有多少对象在经过垃圾回收后仍然是存活的,即无法被回收的对象。
为了解决这个问题,我们可以参考之前的讨论:每个计算任务处理1万条数据需要10秒钟的时间。假设目前有80个计算任务已经完成,但还有20个计算任务正在处理中,这些任务共涉及到200MB的数据。这意味着,在这200MB的数据对应的对象仍在被计算任务处理,因此它们是存活的,无法在当前的垃圾回收过程中被清除。而剩余的1GB的对象则不再被使用,可以在垃圾回收过程中被清理。大家看下图。

在当前的垃圾回收场景中,假设触发了一次Minor GC。在这个过程中,系统会尝试回收1GB的内存空间。然而,如果有一个200MB大小的对象需要被分配到Survivor区,这个操作是行不通的。原因在于,每一个Survivor区的最大容量实际上只有100MB。由于200MB的大小超过了任何一块Survivor区的容量,因此无法直接将这个对象放入Survivor区。
在这种情况下,JVM的内存管理机制会启动一种叫做“空间担保”的策略。根据这个策略,这200MB的对象会被直接分配到老年代(Old Generation)区域。这样,这个对象就会占用老年代中的200MB内存空间。同时,Eden区在这个Minor GC过程中会被清空,释放所有的内存空间,以便用于后续的新对象分配。大家看下图。

5、揭秘你的系统何时会让老年代满载!
让我们思考一下,这个系统运行一段时间后,老年代的内存会被完全占用吗?
根据上述计算,每分钟都是一个循环。大致估算一下,每分钟都会将新生代的Eden区填满,然后触发一次Minor GC(垃圾回收)。在每次Minor GC之后,大约有200MB的数据会进入老年代。
那么,我们可以设想一下,如果系统已经运行了2分钟,此时老年代已经有400MB的内存被占用,只剩下1.1GB的可用内存。如果第3分钟结束时,再次进行Minor GC,它会执行哪些检查呢?如下图。

在进行垃圾回收时,首先会检查老年代的可用空间是否大于新生代的所有对象。当前,老年代的可用空间为1.1GB,而新生代的对象占用了1.2GB。如果假设在一次Minor GC之后,新生代的所有对象都存活,那么这些对象将无法全部放入老年代。
此时,我们需要检查一个参数“-XX:-HandlePromotionFailure”。这个参数通常默认是开启的,用于处理当老年代无法容纳所有存活对象时的情况。如果这个参数被打开了,垃圾回收器会进入第二步检查,即检查老年代的可用空间是否大于每次Minor GC后进入老年代的对象的平均大小。
根据我们的计算,大约每分钟会执行一次Minor GC,每次大约有200MB的对象会进入老年代。因此,我们可以看出,老年代的1.1GB空间是大于每次Minor GC后平均200MB对象进入老年代的大小的。
所以,我们可以推测,这次Minor GC后,大概率还是有200MB的对象进入老年代,而老年代的1.1GB可用空间是足够的。因此,垃圾回收器会放心地执行一次Minor GC,然后再次有200MB的对象进入老年代。
然而,转折点出现在运行了7分钟后,这时已经执行了7次Minor GC,大约有1.4GB的对象进入了老年代,老年代的剩余空间就不到100MB了,几乎快满了。如下图:

6、掌握老年代Full GC的触发时机!
在第8分钟的运行结束时,新生代再次充满。在执行Minor GC之前进行一次检查,发现老年代只剩下100MB的内存空间,这比每次Mnor GC后进入老年代的200MB对象要小。在这种情况下,将直接触发一次Full GC。
Full GC会回收老年代中的所有垃圾对象。假设此时老年代被占据的1.4G的空间全部由可以回收的对象组成,那么这些对象将在一次性操作中被全部回收。如下图。

然后接着就会执行Minor GC,此时Eden区情况,200MB对象再次进入老年代,之前的Full GC就是为这些新生代本次Minor GC要进入老年代的对象准备的,如下图。

根据目前的运行模型,我们观察到系统平均每隔七到八分钟就会触发一次全垃圾回收(Full GC),这样的频率显然过高。众所周知,每次进行全垃圾回收时,处理速度都会显著下降,导致性能严重受损。明天,我们将在文章中详细解释为什么全垃圾回收会严重影响系统性能。
7、该案例应该如何进行JVM优化?
通过这个案例的分析和图示,我们深入理解了新生代和老年代的协同工作原理,以及在什么情况下会触发Minor GC和Full GC。我们也明白了导致频繁的Minor GC和Full GC的原因。
针对这个数据计算系统,优化方法相对简单。在每次发生Minor GC时,总会有一些数据尚未完成计算。根据现有的内存模型,最大的问题在于Survivor区域无法容纳所有存活的对象。
为了解决这个问题,我们对生产系统进行了调整。在约3GB的堆内存中,我们将2GB分配给新生代,1GB留给老年代。这样,Survivor区域的容量大约为200MB,足以容纳每次Minor GC后存活的对象。如下图所示。

在每次执行Minor GC之后,如果有200MB的存活对象能够被放置在Survivor区域,那么在下一次执行Minor GC时,这些位于Survivor区的对象所对应的计算任务应该已经全部完成,因此这些对象都是可以被回收的。
例如,在某个时刻,Eden区的1.6GB空间已经被完全占用,而Survivor1区中仍然存在着上一轮Minor GC后幸存的200MB对象。如下图。

紧接着,进行一次Minor GC操作,这将导致Eden区域中的1.6GB对象被回收。此外,Survivor1区域中的200MB对象也将被回收。随后,Eden区域中剩下的200MB存活对象将被转移到Survivor2区域中。如下图。

通过分析和优化,我们成功地将生产系统的老年代Full GC频率从几分钟一次降低到了几个小时一次,从而大幅提升了系统性能,避免了频繁的Full GC对系统运行的影响。
然而,大家可能会注意到之前提到的一个动态年龄判定规则,即如果Survivor区中的同龄对象大小超过Survivor区内存的一半,这些对象会直接升入老年代。因此,这里的优化方法仅仅是一个示例说明,其目的是要增加Survivor区的大小,以便在Minor GC后的对象能够进入Survivor区,而不是直接进入老年代。
实际上,为了避免动态年龄判定规则将Survivor区中的对象直接升入老年代,我们可以采取一些措施。首先,如果新生代内存有限,可以调整"-XX:SurvivorRatio=8"这个参数,默认情况下,Eden区的比例为80%。通过降低Eden区的比例,给两块Survivor区分配更多的内存空间,这样每次Minor GC后的对象就能够进入Survivor区,而不会因为动态年龄判定规则而被直接升入老年代。
综上所述,通过调整Survivor区的大小和比例,我们可以有效地避免对象过早地进入老年代,从而提高系统的性能,并减少频繁的Full GC对系统运行的影响。
8、垃圾回收器简介
在Java虚拟机中,垃圾回收是一个重要的环节,它负责自动回收不再使用的对象,以释放内存空间。垃圾回收器是实现这一功能的关键组件,不同的区域会使用不同的垃圾回收器。
接下来,我们将深入探讨几种常用的垃圾回收器:ParNew、CMS和G1。这些垃圾回收器的工作原理和优缺点将在后续的文章中详细分析。
首先,让我们简单了解一下Serial和Serial Old垃圾回收器。这两种回收器分别用于回收新生代和老年代的垃圾对象。它们的工作原理是通过单线程运行,在垃圾回收过程中会暂停其他工作线程,导致系统暂时无法响应。这种机制在现代后台Java系统中已经很少使用。
接下来是ParNew和CMS垃圾回收器。ParNew主要用于新生代的垃圾回收,而CMS则用于老年代。这两种回收器采用多线程并发的机制,性能更优,成为线上生产系统的常用组合。
最后,G1垃圾回收器。G1统一处理新生代和老年代的垃圾回收,采用了更先进的算法和设计机制。