【JVM】六、掌握对象在内存中的分配与变迁

接下来,我们将深入探讨对象何时进入新生代,以及在什么情况下会进入老年代

1、前文回顾

在昨天的文章中,我们已经介绍了一些关于对象分配的基础知识。现在,大家应该对这些概念有了一定的了解。在编程中,我们创建的对象通常分为两类:

  1. 短期存活的对象:这类对象在Java堆内存中分配后,会迅速使用完毕并被垃圾回收器回收。
  2. 长期存活的对象:这类对象需要在Java堆内存中持续存在,以便程序后续不断地使用它们。

短期存活的对象通常位于Java堆内存的新生代区域,而长期存活的对象则位于老年代区域。相信这个结论大家已经理解了。

接下来,我们将深入探讨对象何时进入新生代,以及在什么情况下会进入老年代。

2、优先在新生代分配内存

首先我们先来看上篇文章中的一段代码,稍微带着大家来理解一个概念:大部分的正常对象,都是优先在新生代分配内存的。

public class User {
    private static UserInfoFetcher fetcher = new UserInfoFetcher();
    public static void main(String[] args) {
        loadUserInfosFromDB();
        while(true) {
            fetchUserInfosFromRemote();
            Thread.sleep(1000);
        }
        
    }
    private static void loadUserInfosFromDB() {
        UserManager UserManager = new UserManager();
        UserManager.load();
    }
    private static void fetchUserInfosFromRemote() {
        fetcher.fetch();
    }
}

大家还记得之前讨论的那段代码吗?从代码中我们了解到,类静态变量“fetcher”引用的那个“UserInfoFetcher”对象,会在内存中长期存在。然而,即使是这种对象,在最初通过“new UserInfoFetcher()”代码实例化一个对象时,它也是分配在新生代中的。

同样地,在“loadUserInfosFromDB()”方法中创建的“UserManager”实例对象,也是分配在新生代中的。我们以一张图,来展示一下:

3、垃圾回收触发时机?

现在,让我们假设一个场景。众所周知,当“loadUserInfosFromDB()”方法执行完毕后,该方法的栈帧将出栈,这将导致没有任何局部变量引用到那个“UserManager”实例对象。此时可能会如下图所示:

image-20240418162622242

在讨论Java的垃圾回收机制时,我们可能会产生这样的疑问:当我们不再使用某个对象,比如“UserManager”实例,系统会不会立即进行垃圾回收?然而,答案并非如此简单。

首先,我们需要明确的是,Java的垃圾回收并不是在任何时候、任何情况下都会立即触发的。垃圾回收的触发需要满足一定的条件。

一种常见的情况是,当我们的代码创建了大量的对象,这些对象在一段时间内被频繁引用,例如作为各种方法中的局部变量。但是,随着时间的推移,这些对象可能不再被引用,也就是说,没有任何引用指向它们。这时候,它们就成为了垃圾回收器的目标。如下图所示:

image-20240418162331441

在这个阶段,如果我们事先为新生代分配的内存空间几乎被全部对象占据,那么当我们的代码需要继续运行并在新生代中分配一个新的对象时,我们将面临一个问题:新生代的内存空间不足。

这时,就会触发一次新生代垃圾回收,也称为“Minor GC”或“Young GC”。这个过程会试图清除新生代中那些没有被任何对象引用的垃圾对象。

比如上图中,那个“UserManager”实例对象,其实就是没有人引用的垃圾对象,此时就会当机立断,把“UserManager”实例对象给回收掉,腾出更多的内存空间,然后放一个新的对象到新生代里去。包括上图中那大量的实例对象,其实也都没人引用,在这个新生代垃圾回收的过程中,就会把这些垃圾对象也都回收掉。

其实,仔细回想一下,我们在编程过程中创建的大多数对象,它们在被使用之后就可以立即回收,因为它们的生存周期非常短暂。

通常情况下,我们可能会在堆的新生代区域分配大量的对象。然而,当这些对象被使用完之后,如果没有其他引用指向它们,那么它们的生命周期就结束了。此时,如果新生代的内存空间几乎已满,我们需要继续分配新的对象时,就会发现新生代的内存空间不足。

在这种情况下,就会触发一次垃圾回收(Garbage Collection)。垃圾回收的过程就是扫描并识别出那些不再被引用,即已经变为“垃圾”的对象,然后进行回收,从而腾出大量的内存空间,以供后续的对象分配。

这样,通过及时地回收不再使用的对象,我们可以有效地管理内存,避免内存泄露,确保程序的稳定运行。如下图所示:

image-20240418162720839

4、躲过垃圾回收的长期存活对象

接下来,我们将深入探讨另一个问题。在上述的图表中,大家可能已经注意到了一个名为“UserInfoFetcher”的实例对象。这个对象是一个长期存在的对象,因为它被“User”类的静态变量“fetcher”所引用。

在JVM的新生代中,随着系统的运行,会不断地创建新的对象,这会导致新生代被填满。当新生代被填满后,会发生垃圾回收,大量的对象会被清理掉。然而,尽管这个过程不断重复,但是“UserInfoFetcher”对象却始终存在于新生代中。

这是因为它始终被“User”类的静态变量所引用,因此垃圾回收器无法将其清理掉。在这种情况下,JVM有一个规则,如果一个对象在新生代中,经过15次垃圾回收后仍然没有被清理掉,那么就可以认为这个对象的年龄为15。

这里所说的年龄,是指对象在每次垃圾回收过程中,如果没有被清理掉,其年龄就会增加1。因此,如果上图中的那个“UserInfoFetcher”对象在新生代中成功避免了10多次垃圾回收,成为了一个“老年人”,那么它就会被认为是一个可能会长期存活在内存中的对象。

在这种情况下,这个对象会被移动到Java堆内存的老年代中。顾名思义,老年代就是用来存放这些年龄较大的对象的区域。我们再来看一张图:

image-20240418162822290

5、老年代会垃圾回收吗?

接着下一个问题就是,老年代里的那些对象会被垃圾回收吗?
答案是肯定的,因为老年代里的对象也有可能随着代码的运行,不再被任何人引用了,就需要被垃圾回收。
大家可以思考一下,如果随着类似上面的情况,越来越多的对象进入老年代,一旦老年代也满了,是不是就要对老年代垃圾回收了?
没错,这是肯定的,但是暂时我们先不用过多的去考虑这里的细节,因为这将是下周的主题,下周我们会进行深入剖析。

在继续讨论之前,我们需要解答一个重要的问题:老年代中的对象会被垃圾回收吗?

答案是肯定的。在老年代中,对象可能会随着代码的运行,不再被任何引用所指向,因此需要通过垃圾回收来处理。

我们可以思考一下,如果类似的情况发生,越来越多的对象进入老年代,一旦老年代空间也被填满,那么是否需要对老年代进行垃圾回收呢?

确实,这是必须的。然而,目前我们不需要过多地关注这些细节,因为我们将在后面深入探讨这个问题。

6、还有后续吗?

有人可能会问,关于新生代和老年代的对象分配,我们是否已经探讨完毕?答案当然是不是的。今天这篇文章,我们只是相较于之前的文章,更深入地分析了对象分配的一些机制。

然而,实际上在对象分配方面,还有许多其他复杂的机制需要我们去理解,例如:

  • 当新生代进行垃圾回收后,如果存活的对象过多,可能会导致大量对象直接进入老年代。
  • 对于那些特别大的对象,它们会直接进入老年代,而不经过新生代。
  • 动态对象年龄判断机制,这是一种根据对象的使用情况来动态判断对象应该在新生代还是老年代的机制。
  • 空间担保机制,这是一种保证有足够的空间来存储对象的机制。

以上这些机制,都是我们在理解和掌握Java垃圾回收机制时,必须要注意和理解的重要内容。