【JVM】十一、垃圾回收哪家强?揭秘JVM中的核心回收算法与性能之争!

本文将聚焦于垃圾回收过程中,特别是针对新生代内存,采用的具体算法。

1、前文回顾

在上一篇文章中,我们对垃圾回收的触发时机以及可回收对象进行了重新梳理。同时,我们详细讨论了新生代内存填充、GC Roots对象、软引用、弱引用以及finalize()等概念。本文将聚焦于垃圾回收过程中,特别是针对新生代内存,采用的具体算法。

2、复制算法的背景引入

针对新生代的垃圾回收,我们采用了一种称为复制算法的策略。

具体而言,我们将新生代的内存划分为两个部分,如下图所示:

接下来,我们假设有以下代码。在"loadUserInfosFromDB()"方法中,创建了对象。此时,这些对象会被分配到新生代的一块内存空间中。同时,它们由"main线程"的栈内存中的"loadUserInfosFromDB()"方法的栈帧内的局部变量进行引用。如下图所示。

public class User {
    public static void main(String[] args) {
        loadUserInfosFromDB();
    }
    private static void loadUserInfosFromDB() {
        UserManager UserManager = new UserManager();
    }
}

image-20240418163428879

接下来,让我们一起想象一个场景。假设在此时此刻,代码正在不断地运行,大量的对象被分配到了新生代内存中的某个特定区域,并且只会被分配到这个区域。然而,在分配完成后,这些对象很快就失去了局部变量或者类静态变量的引用,从而变成了垃圾对象。此时如下图所示。

image-20240418163615902

在这个时刻,新生代内存区块的分配给对象的内存储器已经接近饱和,当再次尝试分配对象时,发现可用的内存空间已经不足以满足需求。

这时,系统会触发一次Minor GC(垃圾收集)过程,以回收新生代内存区块中不再使用的对象所占用的内存空间。

那么,垃圾收集是如何进行的呢?

3、效果不佳的垃圾回收思路

假设我们目前正在采用的垃圾回收策略是对上图中被使用的内存区域中的垃圾对象进行标记。具体来说,就是根据我们之前讨论的方法,找出哪些对象是可以被垃圾回收的,然后直接对这块内存区域中的对象进行垃圾回收,从而释放内存空间。

让我们思考一下,这种策略是否合理?

如果我们采取这种垃圾回收策略,可能会在回收完成后导致内存区域的布局变得如下图所示。

image-20240418163806235

在观察上面的图表时,我们可能会发现一些有趣的现象。在被使用的内存区域中,大量的垃圾对象已经被回收,这是一件好事,因为它可以释放出更多的内存空间供其他程序使用。然而,我们也看到,一些被引用的存活对象仍然保留在内存中。

这些存活对象的分布情况并不理想,它们在内存区域中零散地分布,这就像是在一个整齐的房间里,物品被随意地放在各处,使得房间显得杂乱无章。这种状况在计算机内存管理中被称为“内存碎片”。

那么,什么是内存碎片呢?我们可以从下面的图表中找到答案。我用红线标记出了一些区域,这些就是所谓的内存碎片。

image-20240418163908549

内存碎片是在计算机内存管理过程中,由于频繁的分配和回收操作,产生的一些无法被有效利用的小片段内存。这些内存碎片的大小不一,有的可能很大,有的可能很小。那么,内存碎片过多会带来什么问题呢?首先,它会导致内存浪费。这是因为,虽然所有的内存碎片加起来可能有很大的一块内存,但是因为这些内存都是碎片式分散的,所以无法形成一块完整的、足够大的内存空间来分配给新的对象。

举个例子,假设你现在需要为一个新的对象分配内存,你会尝试在已经被使用的内存区域里进行分配。然而,如果这个区域的内存碎片过多,你可能会发现,虽然这些碎片的总和可能足够大,但是由于它们都是分散的,你找不到一块完整且足够大的内存空间来分配给新的对象。这就是内存碎片导致的内存浪费问题。

image-20240418164017520

因此,这种采用直接回收一块内存空间中的垃圾对象,而保留存活对象的方法,绝对不可取。其根本原因在于,这种方法会导致大量的内存碎片,进而引发严重的内存浪费。具体来说,很多内存碎片无法被有效利用,这无疑加剧了内存资源的浪费。

4、一个合理的垃圾回收思路

那么,我们能否以一种合理的思路来进行垃圾回收呢?
当然可以!在这个问题中,一直未被利用的另外一块空白内存区域终于派上用场了。
首先,我们并不是直接按照上述思路对已经使用的那块内存进行操作,把垃圾对象全部回收掉,然后保留全部存活对象。
而是先对正在使用的内存空间进行标记,找出其中哪些对象是不能进行垃圾回收的,也就是需要存活的对象。
然后,我们将这些存活的对象转移到另外一块空白的内存中,如下图所示。

image-20240418164308342

不知道大家是否注意到了这其中的巧妙之处?

确实,通过将存活对象首先转移到另一块空白的内存区域,我们可以实现将这些对象在内存中进行紧凑的排列。这样做的好处是,可以使得被转移的内存区域几乎没有内存碎片,所有的对象都会按照顺序整齐地排列在这块内存中。那么,被转移后的内存区域,是不是就释放出了一大块连续的可用内存空间呢?

答案是肯定的。这样,我们就可以将新的对象分配到这一大片连续的内存空间中,如下面的图示所展示的那样。

image-20240418164351296

这个时候,再一次性把原来使用的那块内存区域中的垃圾对象全部一扫而空,全部给回收掉,空出来一块内存区域,如下图。

image-20240418164431247

这就是所谓的“复制算法”,它是一种高效的内存管理策略,将新生代内存分为两个内存区域,并仅使用其中的一个。一旦该内存区域接近填满,它便将所有存活的对象一次性地迁移到另一个内存区域,以确保不会产生内存碎片。然后,它一次性地回收原内存区域中的垃圾对象,从而再次释放一个内存区域。这两个内存区域以循环的方式交替使用,以实现高效的内存管理。

5、复制算法有什么缺点?

复制算法的局限性是显而易见的。如果我们按照这种思路,可以发现,当我们为新生代分配了1G的内存空间时,实际上只有512MB的内存空间可以被使用。另外的512MB的内存空间需要一直空闲着。当这512MB的内存空间被填满后,存活的对象就需要被移动到另外一块512MB的内存空间。

这样的设计意味着,始终只有一半的内存空间被使用。这种算法对于内存的使用效率显然较低,因为它没有充分利用所有的内存空间。

6、复制算法的优化:Eden区和Survivor区

在之前的分析中,我们已经探讨了系统运行过程中JVM的内存使用模型。基本上,我们的代码会不断地创建对象,并将它们分配到新生代中。然而,这些对象通常很快就会变得无人引用,从而变成垃圾对象。

随着时间的推移,新生代的空间会被填满,这时就需要进行垃圾回收,将那些垃圾对象清理掉,释放出内存空间以供后续的对象使用。

我们之前也分析了,大部分的对象都是生命周期非常短暂的,可能在创建出来1毫秒后就无人引用,成为垃圾对象。因此,大家可以想象,在一次新生代垃圾回收之后,可能有99%的对象都被清理掉了,只有1%的对象存活下来,这些可能是长期存活的对象,或者是还未使用完的对象。

为了优化这个过程,实际的垃圾回收算法会将新生代内存区域划分为三个部分:一个Eden区和两个Survivor区。其中,Eden区占据了80%的内存空间,每个Survivor区各占据10%的内存空间。例如,如果Eden区有800MB的内存,那么每个Survivor区就有100MB的内存。如下图:

image-20240418165017410

平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于就是有900MB的内存是可以使用的,如下图所示。

image-20240418165109519

在Java的垃圾回收机制中,新创建的对象最初都分配在Eden区。当Eden区的空间接近耗尽时,会触发一次名为Minor GC的垃圾回收过程。在这个过程中,Eden区中的存活对象——即那些仍然被应用程序引用的对象——会被移动到一个未被使用的Survivor区。完成这个步骤后,Eden区会被清空,然后再次用于分配新创建的对象。这时,Eden区和一块Survivor区都包含有对象,其中Survivor区存放的是上一次Minor GC后幸存下来的对象。

如果Eden区再次填满,将再次触发Minor GC。这次,Eden区以及存储上次垃圾回收后幸存对象的Survivor区中的存活对象,都会被移动到另一块Survivor区。

这种内存分配策略的设计考虑了垃圾回收的效率。由于分析表明,每次垃圾回收后可能只有约1%的对象会存活,因此在设计时预留了一块100MB的内存空间,专门用来存放垃圾回收后的存活对象。

例如,如果Eden区和一块Survivor区的900MB内存空间都被占满了,垃圾回收后可能只有10MB的对象是存活的。这些存活对象会被转移到另一块Survivor区,然后Eden区和先前使用的Survivor区中的废弃对象会被一次性清理,以释放空间供后续的对象分配使用。如下图:

image-20240418165142624

新的对象总是在Eden区和其中一个Survivor区进行分配,另一个Survivor区则保持空闲。这种设计的最大优点是,只有10%的内存空间是闲置的,而90%的内存都被有效利用了。无论是对垃圾回收的性能,还是对内存碎片的控制,亦或是对内存使用的效率,这种设计都表现出了极高的优越性。

7、如何巧妙应对新生代的各种突发状况?

在阅读了这篇文章之后,许多读者可能会对这里可能出现的各种“万一”情况产生疑问。例如:

  • 万一垃圾回收后,存活下来的对象超过了10%的内存空间,而在另一块Survivor区域中无法容纳,该如何处理?

  • 万一我们突然分配了一个超级大的对象,以至于新生代找不到连续的内存空间来存放,这时该如何应对?

请大家不要着急,我们将在接下来的文章中分析这些新生代可能出现的各种“万一”情况,以及新生代的对象是如何转移到老年代的,然后探讨老年代是如何触发垃圾回收的,以及垃圾回收的具体算法。