【JVM】十二、揭秘大厂面试秘籍:掌握年轻代与老年代的垃圾回收策略!
在这篇文章中,我们将依次探讨在不同情况下,对象是如何被分配到老年代的,以及老年代的垃圾回收算法是如何工作的。
1、前文回顾
在上一篇文章中,我们已经详细解释了新生代的垃圾回收算法,以及与这个算法相配合的新生代内存区域的划分。大家应该已经明白了为什么我们需要一个Eden区域和两个Survivor区域。
接下来,本文将向大家介绍,在何种情况下,新生代中的对象会进入老年代。
首先,让我们通过下面的图示来理解这个过程。当我们编写的代码在运行过程中,会不断创建各种对象,这些对象会优先被放置在新生代的Eden区和Survivor1区。
接着假如新生代的Eden区和Survivor1区都快满了,此时就会触发Minor GC,把存活对象转移到Survivor2区去。如下图所示:

然后接着就会使用Eden区和Survivor2区,来分配新的对象,如下图所示。

在上篇文章中,我们已经详细讨论了这个过程。因此,在这篇文章中,我们将依次探讨在不同情况下,对象是如何被分配到老年代的,以及老年代的垃圾回收算法是如何工作的。
2、躲过15次GC之后进入老年代
在系统启动之初,我们创建的各类对象都会分配在新生代中。随着系统的运行,新生代会逐渐被填满,这时会触发Minor GC,可能只有1%的少量存活对象会被转移到空闲的Survivor区。然后,系统会继续在Eden区分配各类对象,这就是整个过程。
之前我们也提到过,我们的系统中有些对象是长期存在的,它们不会轻易地被回收,例如以下的代码所示:
public class User {
private static UserManager UserManager = new UserManager();
}
"User"类的静态变量"UserManager"会长期引用"UserManager"对象。这种引用方式会导致"UserManager"对象在垃圾回收过程中无法被回收,因为静态变量的生命周期与应用程序的生命周期相同,除非"User"类被卸载,否则"UserManager"会一直存在。
在JVM的垃圾回收机制中,新创建的对象首先会被分配到新生代空间。当新生代空间满时,会触发一次垃圾回收(GC)。在这个过程中,存活下来的对象(即没有被回收的对象)会被移动到Survivor区域,并且它们的年龄会增加1。
默认情况下,当一个对象的年两达到15岁时,也就是说它已经躲过了15次垃圾回收,它就会被移动到老年代空间。这个年龄阈值可以通过JVM参数"-XX:MaxTenuringThreshold"来设置。默认值是15,但可以根据实际需求进行调整。
这个过程可以通过下图来理解:

3、动态对象年龄判断
在Java的垃圾收集机制中,对象从年轻代晋升到老年代通常需要经历一定的生存周期。然而,除了常规的年龄阈值(如15次垃圾收集)之外,还存在一种特殊情况,允许对象提前进入老年代。
这一特殊规则基于对象所在的Survivor空间的使用情况。具体来说,如果在某个时刻,一个Survivor区域中所有对象的累计大小超过了该区域总内存大小的50%,那么在这个时刻,那些年龄大于或等于这批对象的对象将直接被移动到老年代。
这种机制确保了当年轻代中的某个Survivor区域内存使用达到一定水平时,较旧的对象能够更迅速地被转移到老年代,从而避免因为频繁的对象复制和垃圾收集而导致的性能损失。
虽然文字描述可能略显抽象,但通过图示可以帮助更好地理解这一规则的执行过程和影响。

假设在图中的Survivor2区有两个对象,这两个对象的年岑相同,均为2岁。当这两个对象的总大小超过50MB,即超过了Survivor2区的100MB内存容量的一半,此时,所有年龄大于等于2岁的对象都将被移至老年代。
这就是所谓的动态年龄判断规则,该规则会将一些新生代的对象迁移到老年代。需要明确的是,这条规则的实际运行逻辑是这样的:当一个或多个对象的年龄总和(例如年龄1+年龄2+年龄n)超过Survivor区域的50%,那么所有年龄大于等于n的对象都会被移动到老年代。
实际上,无论是15岁的规则,还是动态年龄判断的规则,其核心目标都是希望那些可能长期存活的对象尽早进入老年代。如果你是一个长期存活的对象,那么老年代才是你的归宿,不应该继续占据新生代的空间。
4、大对象直接进入老年代
在JVM中,有一个参数叫做“-XX:PretenureSizeThreshold”。这个参数允许你设置一个特定的字节数值,例如“1048576”,这等于1MB。
该参数的主要作用是设定一个阈值大小,用于控制对象的分配位置。当你想要创建一个超过这个阈值大小的大对象,比如一个巨大的数组或者其他大型数据结构时,这个对象会直接被分配到老年代内存区域,而不会经过新生代。
这种设计的背后逻辑是为了避免在新生代中出现大对象,这些大对象可能会多次逃避垃圾回收,并且在两个Survivor区域之间反复复制,才能最终进入老年代。对于一个大对象来说,在内存中反复复制确实会消耗大量的时间。
因此,通过使用“-XX:PretenureSizeThreshold”参数,我们可以有效地避免这种情况,这也是决定对象是否应该直接进入老年代的一个重要规则。
5、解决Survivor区装不下对象的窘境
目前,我们面临着一个较大的问题:在Minor GC(垃圾回收)执行之后,如果发现剩余的存活对象过多,以至于无法全部放入另一块Survivor(幸存者)区域,我们应该如何处理呢?如下图。

比如上面这个图,假设在发生GC的时候,发现Eden区里超过150MB的存活对象,此时没办法放入Survivor区中,此时该怎么办呢?
这个时候就必须得把这些对象直接转移到老年代去,如下图所示。

6、空间分配担保规则
在讨论JVM的内存管理时,我们经常会遇到一个关键问题:当新生代中有大量的对象在垃圾收集(GC)后仍然存活,而Survivor区无法容纳这些对象时,这些对象需要被转移到老年代。但是,如果老年代的空间也不足以存放这些对象,那么我们应该如何处理呢?
首先,我们需要理解JVM在执行任何一次Minor GC之前的一个关键步骤。JVM会先检查老年代的可用内存空间,以确定其是否大于新生代所有对象的总大小。这个步骤是至关重要的,因为在最极端的情况下,如果新生代的所有对象在经过Minor GC后都存活下来,那么这些对象都需要被转移到老年代。如下图。

当我们发现老年代的可用内存大于新生代所有对象的总大小时,我们可以安心地对新生代进行一次Minor GC(小规模垃圾回收)。这是因为在Minor GC之后,如果新生代中的所有对象都存活下来,且Survivor区无法容纳这些对象,我们仍然可以将它们转移到老年代。
然而,如果在执行Minor GC之前,我们发现老年代的可用内存已经小于新生代的全部对象大小,那么在Minor GC之后,新生代的对象全部存活并需要转移到老年代时,老年代的可用空间可能会不足。在这种情况下,理论上是有可能出现问题的。
为了解决这个问题,我们可以检查一个名为“-XX:-HandlePromotionFailure”的参数是否已经设置。如果设置了这个参数,那么我们将继续进行下一步的判断。
下一步判断是检查老年代的可用内存是否大于之前每次Minor GC后进入老年代的对象的平均大小。例如,如果之前每次Minor GC后,平均有10MB的对象会进入老年代,那么此时老年代的可用内存应大于10MB。这表明,在这次Minor GC之后,很可能也会有大约10MB的对象进入老年代,因此老年代的空间应该是足够的。看下图。

如果在某个步骤中判断失败,或者没有设置“-XX:-HandlePromotionFailure”参数,那么系统将直接触发一次“Full GC”。这是对老年代进行垃圾回收的操作,目的是尽量腾出一些内存空间,然后再执行Minor GC。
如果上述两个步骤都判断成功,那么我们可以尝试进行风险较高的Minor GC。在这种情况下,有几种可能的结果:
第一种情况是,经过Minor GC后,剩余的存活对象的大小小于Survivor区的大小,那么这些存活对象就可以直接进入Survivor区。
第二种情况是,经过Minor GC后,剩余的存活对象的大小大于Survivor区的大小,但是小于老年代可用内存的大小,那么这些存活对象就可以直接进入老年代。
第三种情况是,不幸的是,经过Minor GC后,剩余的存活对象的大小既大于Survivor区的大小,也大于老年代可用内存的大小。在这种情况下,老年代无法容纳这些存活对象,就会发生“Handle Promotion Failure”的情况,此时系统将触发一次“Full GC”。
Full GC是对老年代进行垃圾回收的过程,通常也会对新生代进行垃圾回收。这是因为在这个时候,我们必须回收老年代中没有被引用的对象,以便让Minor GC后剩余的存活对象能够进入老年代。
如果在进行Full GC后,老年代仍然没有足够的空间来存放Minor GC后剩余的存活对象,那么此时就会出现所谓的“OOM”(内存溢出)。因为内存实在不足,但还需要不断地向其中放入对象,这当然会导致系统崩溃。
7、老年代垃圾回收算法
在理解了上述内容之后,我们现在基本上已经掌握了Minor GC的触发时机。接下来,我们需要了解在Minor GC之前,需要对老年代空间大小进行检查的过程。这包括在检查失败时提前触发Full GC以腾出一些空间给老年代,或者在Minor GC之后,剩余的对象太多,无法全部放入老年代内存,此时也需要触发Full GC。这套规则以及触发老年代垃圾回收的Full GC时机都已经向大家进行了详细的解释。
简单来说,触发老年代垃圾回收的时机通常有两个:一个是在Minor GC之前,通过一系列检查发现在Minor GC之后可能有大量的对象需要进入老年代,而老年代的空间无法容纳,此时需要提前触发Full GC,然后再进行Minor GC;另一个是在Minor GC之后,发现剩余的对象太多,无法全部放入老年代。
那么,对于老年代的垃圾回收,我们采用的是什么算法呢?简单来说,老年代采用的是标记整理算法,这个过程相对来说比较简单。大家看下图,首先标记出来老年代当前存活的对象,这些对象可能是东一个西一个的。

接下来,系统会对这些存活对象进行内存中的移动操作,以尽可能将它们紧凑地排列在一起。这样可以确保在垃圾回收之后,避免产生过多的内存碎片。最后,系统将会一次性回收所有被识别为垃圾的对象。大家看下图。

在JVM中,老年代的垃圾回收算法的速度至少比新生代的垃圾回收算法慢10倍。因此,如果系统经常触发老年代的Full GC,那么系统性能将会受到严重影响,表现为频繁的卡顿和响应延迟。
接下来,将通过一系列案例,展示在各种业务系统的生产环境中,如何逐步分析并找出导致频繁Full GC的原因,以及如何调整JVM的各种参数进行优化。
如果你已经深入理解了我近期发布的关于JVM运行原理的文章,你会明白,JVM优化的核心就是尽可能地让对象在新生代中分配和回收,避免过多的对象进入老年代,从而减少对老年代的垃圾回收频率。同时,我们也需要为系统提供足够的内存空间,以防止新生代频繁进行垃圾回收。
在接下来的内容中,将会带领大家通过大量的实战案例,深入了解如何优化JVM。这些案例不仅包括模拟生产环境的代码,让大家看到模拟出的问题现场是如何导致JVM频繁GC的,也会展示如何影响系统性能,然后我会一步步教大家如何优化JVM参数,解决性能问题。