【JVM】二十九、亲身体验对象如何步入老年代

本文将继续之前的案例,进行实验,让大家亲手体验一下对象是如何从新生代进入老年代的。

1、前文回顾

在上篇文章中,我们带领大家进行了一次初步的Young GC日志分析,相信通过这次的讲解,大家已经对如何结合GC日志去分析一次Young GC执行的全过程有了深入的理解。

在这篇文章中,我们将继续之前的案例,进行实验,让大家亲手体验一下对象是如何从新生代进入老年代的。

2、动态年龄判定规则

之前我们给大家总结过对象进入老年代的4个常见的时机:
躲过15次gc,达到15岁高龄之后进入老年代;
动态年龄判定规则,如果Survivor区域内年龄1+年龄2+年龄3+年龄n的对象总和大于Survivor区的50%,此时年龄n以上的对象会进入老年代,不一定要达到15岁
如果一次Young GC后存活对象太多无法放入Survivor区,此时直接计入老年代
大对象直接进入老年代

在我们之前的讨论中,我们已经总结了对象进入老年代的四个常见时机:

  1. 当对象经历了15次垃圾回收(GC)并且年龄达到了15岁,它就会进入老年代。
  2. 我们采用了动态年龄判定规则。如果Survivor区域中的对象的总年龄(年龄1+年龄2+年龄3+...+年龄n)超过了Survivor区的50%,那么所有年龄大于或等于n的对象将会进入老年代。这并不意味着它们一定要达到15岁。
  3. 如果一次Young GC之后,存活的对象数量过多,以至于无法全部放入Survivor区,这些对象将直接被分配到老年代。
  4. 对于大对象,我们采取了一种策略,即它们会直接进入老年代。

首先,我们将通过代码示例来模拟一种常见的情况,即对象进入老年代。在这种情况中,如果Survivor区域内年龄为1、2、3至n的对象总和占据了超过50%的Survivor区域空间,那么年龄为n或更高的对象将会被迁移到老年代。这也就是所谓的动态年龄判定规则。先来看看我们这次示例程序的JVM参数:

-XX:NewSize=10485760 -XX:MaxNewSize=10485760 -XX:InitialHeapSize=20971520 -XX:MaxHeapSize=20971520 -XX:SurvivorRatio=8  -XX:MaxTenuringThreshold=15 -XX:PretenureSizeThreshold=10485760 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log

在这段文本中,我们需要关注几个关键的参数设置。首先,我们通过设置"-XX:NewSize"参数,将新生代的大小设定为10MB。在这个新生代中,Eden区占据了8MB,而每个Survivor区则占据了1MB。Java堆的总大小被设定为20MB,其中老年代占据了10MB。对于大对象,只有当其大小超过10MB时,才会直接进入老年代。

然而,我们还设置了"-XX:MaxTenuringThreshold=15"参数,这意味着只有当对象的年龄达到15岁时,它才会直接进入老年代。

现在,我们已经准备好了所有的设置,让我们先来看看当前的内存分配情况,如下图所示。接下来,我们将开始查看我们的示例代码。

image-20240424103042012

3、动态年龄判定规则代码示例大公开

public class Demo {
    public static void main(String[] args) {
        byte[] array1 = new byte[2 * 1024 * 1024];
        array1 = new byte[2 * 1024 * 1024];
        array1 = new byte[2 * 1024 * 1024];
        array1 = null;
        byte[] array2 = new byte[128 * 1024];
        byte[] array3 = new byte[2 * 1024 * 1024];
    }
}

在研究JVM运行过程中,我们需要逐步调试代码以深入了解其工作原理。虽然上述代码只是我们示例的一部分,但我们需要先执行这部分代码,并通过GC日志来分析代码执行后JVM中对象的分配情况。

4、示例代码运行后产生的gc日志

接着我们把上述示例代码以及我们给出的JVM参数配合起来运行,此时会看到如下的GC日志,接着我们就开始一步一步分析一下这部分代码运行后的gc日志。

0.297: [GC (Allocation Failure) 0.297: [ParNew: 7260K->715K(9216K), 0.0012641 secs] 7260K->715K(19456K), 0.0015046 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
par new generation   total 9216K, used 2845K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
 eden space 8192K,  26% used [0x00000000fec00000, 0x00000000fee14930, 0x00000000ff400000)
 from space 1024K,  69% used [0x00000000ff500000, 0x00000000ff5b2e10, 0x00000000ff600000)
 to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
concurrent mark-sweep generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
Metaspace       used 2782K, capacity 4486K, committed 4864K, reserved 1056768K
 class space    used 300K, capacity 386K, committed 512K, reserved 1048576K

5、GC日志分析

首先我们先看下述几行代码:

byte[] array1 = new byte[2 * 1024 * 1024];
array1 = new byte[2 * 1024 * 1024];
array1 = new byte[2 * 1024 * 1024];
array1 = null;

在这里连续创建了3个2MB的数组,最后还把局部变量array1设置为了null,所以此时的内存如下图所示:

image-20240424105553517

接着执行了这行代码:byte[] array2 = new byte[128 * 1024]。此时会在Eden区创建一个128KB的数组同时由array2变量来引用,如下图。

image-20240424111643930

接下来,我们将执行以下代码:

byte[] array3 = new byte[2 * 1024 * 1024];

在这段代码中,我们试图在Eden区再次分配一个2MB的数组。然而,由于Eden区中已经存在3个2MB的数组和1个128KB的数组,总大小已经超过6MB,而Eden区的容量仅为8MB。因此,在这种情况下,无法再创建一个新的2MB数组。

这将触发一次Young GC(年轻代垃圾回收),我们可以从GC日志中获取相关信息。以下是GC日志的内容:

ParNew: 7260K->715K(9216K), 0.0012641 secs

从这行日志中可以清晰地看到,在进行垃圾回收之前,年轻代占用了7260KB的内存。这部分内存主要包括6MB的3个数组、128KB的1个数组以及一些未知对象所占用的几百KB内存。如下图所示:

image-20240424111749313

接下来,让我们来分析一下这里的数据,7260K->715K(9216K)。在7260K的堆空间中,经过一次Young GC之后,剩余的存活对象大约为715KB。

回顾我们在上篇文章中分析的GC日志,我们曾经提到过年轻代在一开始会存在大约512KB的未知对象。如果我们现在再考虑加上我们自己创建的128KB的数组,那么总的空间使用量将会是多少呢?

是的,如果我们将512KB的未知对象和128KB的数组相加,我们会得到大约640KB的使用量。但是,我们需要考虑到其他因素,比如对象的对齐、对象的头信息等,所以实际的使用量可能会稍微大一些。因此,我们可以大致估计总的空间使用量在700KB左右。

现在,让我们来看一下GC日志的内容:

par new generation   total 9216K, used 2845K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
 eden space 8192K,  26% used [0x00000000fec00000, 0x00000000fee14930, 0x00000000ff400000)
 from space 1024K,  69% used [0x00000000ff500000, 0x00000000ff5b2e10, 0x00000000ff600000)
 to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
concurrent mark-sweep generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)

从上述日志中,我们可以清楚地看到,在一次Young GC之后,From Survivor区域占据了大约69%的内存,约等于700KB。这些是在垃圾回收过程中存活下来的对象,它们全部被移动到了From Survivor区域。

同时,Eden区域占用了大约26%的内存,约等于2MB。这主要是由于执行了byte[] array3 = new byte[2 * 1024 * 1024];这行代码后,分配的数组存储在了Eden区域内。如下图所示:

image-20240424112048253

让我们来探讨一个问题:在当前的Survivor From区域中,那个700KB大小的对象的年龄是多少呢?

答案是:1岁。

在JVM的垃圾回收机制中,每当一个对象成功经历一次垃圾回收,它的年龄就会增加1岁。此时,我们注意到Survivor区域的总大小为1MB,而在Survivor From区中存活的对象已经占用了700KB的空间,这明显超过了整个区域的50%。

6、完善示例代码

public class Demo {
    public static void main(String[] args) {
        byte[] array1 = new byte[2 * 1024 * 1024];
        array1 = new byte[2 * 1024 * 1024];
        array1 = new byte[2 * 1024 * 1024];
        array1 = null;
        byte[] array2 = new byte[128 * 1024];
        byte[] array3 = new byte[2 * 1024 * 1024];
        array3 = new byte[2 * 1024 * 1024];
        array3 = new byte[2 * 1024 * 1024];
        array3 = new byte[128 * 1024];
        array3 = null;
        byte[] array4 = new byte[2 * 1024 * 1024];
    }
}

接着我们把示例代码给完善一下,变成上述的样子,我们要触发出来第二次Young GC,然后看看Survivor区域内的动态年龄判定规则能否生效。
先看下面几行代码:

array3 = new byte[2 * 1024 * 1024];
array3 = new byte[2 * 1024 * 1024];
array3 = new byte[128 * 1024];
array3 = null;

这几行代码运行过后,实际上会接着分配2个2MB的数组,然后再分配一个128KB的数组,最后是让array3变量指向null,如下图所示。

image-20240424130917379

接下来,将会执行以下代码:

byte[] array4 = new byte[2 * 1024 * 1024];

在这个阶段,大家会发现,Eden区如果要再次放入一个2MB的数组,已经没有足够的空间了。因此,此时必然会触发一次Young GC。

大家使用上述的JVM参数运行这段程序会看到如下的GC日志:

0.269: [GC (Allocation Failure) 0.269: [ParNew: 7260K->713K(9216K), 0.0013103 secs] 7260K->713K(19456K), 0.0015501 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
0.271: [GC (Allocation Failure) 0.271: [ParNew: 7017K->0K(9216K), 0.0036521 secs] 7017K->700K(19456K), 0.0037342 secs] [Times: user=0.06 sys=0.00, real=0.00 secs]
Heap
par new generation   total 9216K, used 2212K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
 eden space 8192K,  27% used [0x00000000fec00000, 0x00000000fee290e0, 0x00000000ff400000)
 from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
concurrent mark-sweep generation total 10240K, used 700K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
Metaspace       used 2782K, capacity 4486K, committed 4864K, reserved 1056768K
 class space    used 300K, capacity 386K, committed 512K, reserved 1048576K

接下来我们来分析这些GC日志。

7、分析最终版的GC日志

首先第一次GC的日志如下:

0.269: [GC (Allocation Failure) 0.269: [ParNew: 7260K->713K(9216K), 0.0013103 secs] 7260K->713K(19456K), 0.0015501 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

这个过程刚才我们分析过了。接着第二次GC的日志如下:

0.271: [GC (Allocation Failure) 0.271: [ParNew: 7017K->0K(9216K), 0.0036521 secs] 7017K->700K(19456K), 0.0037342 secs] [Times: user=0.06 sys=0.00, real=0.00 secs] 

在第二次触发Young GC时,即我们执行上述代码的时候,大家会发现日志中出现了一行信息:ParNew: 7017K->0K(9216K)。这行日志表明,在这次垃圾回收之后,年轻代中的对象数量已经变为零,也就是说没有任何存活的对象。但是,这种情况真的可能发生吗?

如果我们简单地认为所有的对象都被回收了,那无疑是对自己的智商的侮辱。我们需要考虑到,array2这个变量一直在引用一个128KB的数组,这个数组显然是一个存活的对象。此外,还有那500多KB的未知对象,它们在这次垃圾回收后去哪里了呢?

首先,让我们回顾一下上面的图。在Eden区里,有三个2MB的数组和一个128KB的数组,这些数组在垃圾回收过程中,很可能会被回收掉。如下图所示。

image-20240424131056027

经过观察,我们发现在Survivor区块中,所有存活的对象不仅数量庞大,占据了超过一半的总空间,而且它们的生存时间都只有1岁。

依据我们设定的动态年龄判断规则,如果某个区域内,所有对象的累计年龄(包括1岁、2岁,以及n岁的对象)超过了该区域总空间的50%,那么生存时间达到n年及以上的对象将被视为老年对象,并被迁移到老年代。

然而,在当前情况下,由于所有对象的年龄都只有1岁,因此它们都将直接被迁移到老年代。如下图。

image-20240424131406003

大家看下面的日志可以确认这一点:

concurrent mark-sweep generation total 10240K, used 700K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)

在CMS管理的老年代中,目前使用的空间正好是700KB。这表明此时Survivor区域中的对象触发了动态年龄判定规则。尽管这些对象没有达到15岁,但它们全部进入了老年代。

其中,包括我们自己的名为array2的变量所引用的128KB大小的数组。

而另一个名为array4的变量所引用的2MB大小的数组,此时将会被分配到Eden区域中。如下图所示。

image-20240424131644980

此时大家看下面的日志:

eden space 8192K,  27% used [0x00000000fec00000, 0x00000000fee290e0, 0x00000000ff400000)

这里就说明Eden区当前就是有一个2MB的数组。
然后再看下面的日志:

from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)

两个Survivor区域都是空的,因为之前存活的700KB的对象都进入老年代了,所以当然现在Survivor里都是空的了。