Java 简明教程

Java - Garbage Collection

对于一个 Java 对象来说,其生命周期由 JVM 进行管理。一旦程序员创建了一个对象,我们就再也不用担心其生命周期的剩余部分。JVM 会自动找出不再使用的那些对象并从堆中回收其内存。

What is Java Garbage Collection?

Garbage collection 是 JVM 执行的一项主要操作,针对我们的需求进行调整能大幅提升我们应用程序的性能。现代 JVM 提供了各种垃圾回收算法。我们必须了解应用程序的需求,才能决定使用哪种算法。

您无法像在非-GC 语言(如 C 和 C++)中那样在 Java 中以编程方式取消分配对象。因此,您无法在 Java 中具有悬空引用。但是,您的引用可能是 null(引用 JVM 永远不会存储对象的内存区域)。每当使用 null 引用时,JVM 都会抛出 NullPointerException。

请注意,尽管 GC 的缘故,在 Java 程序中很少发现内存泄漏,但它们确实会发生。我们将在本章末尾创建一个内存泄漏。

Types of Garbage Collectors

现代 JVM 中使用了以下 GC

  1. Serial collector

  2. Throughput collector

  3. CMS collector

  4. G1 collector

上述每种算法都会执行同样的任务——找出不再使用的 objects 并回收它们在堆中占用的内存。对此问题,一种朴素的方法是计算每个对象拥有的引用计数,并在引用计数变为 0 时释放该对象(这也称为引用计数)。为什么这是一种朴素的方法?考虑一个 circular linked list 。它的每个节点都将有一个引用,但整个对象不会在任何地方被引用,理想情况下它应当被释放。

Memory Coalescing

JVM 不仅释放内存,还将小块内存合并成更大的内存。这样做是为了防止内存碎片整理。

简单地说,典型的 GC 算法执行以下活动 -

  1. Finding unused objects

  2. 释放它们在堆中占据的内存

  3. Coalescing the fragments

GC 在运行时必须停止应用程序线程。这是因为它在运行时会移动对象,因此这些对象不能被使用。此类停止称为“暂停整个世界”,我们在调整 GC 时要做的就是减少此类暂停的频率和持续时间。

下面显示了内存合并的简单演示:

阴影部分是需要释放的对象。即使在全部空间被回收之后,我们最多只能分配大小为 = 75Kb 的对象。这样即使我们有 200Kb 的可用空间,如下所示:

Generations in Garbage Collection

大多数 JVM 将堆分为三代-the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation)

我们来看一个简单的示例。Java 中的 String 类是不可变的。这意味着每当需要更改 String 对象的内容时,你都需要完全创建一个新对象。假设你在一个 loop 中对字符串进行了 1000 次更改,如下面的代码所示:

String str = "G11 GC";

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

在每个循环中,我们创建了一个新字符串对象,在上一个迭代中创建的字符串变得无用(即,它不被任何引用引用)。该对象的 lifetime 仅一个迭代 - 它们将很快被 GC 收集。此类短暂生命期的对象存储在堆的年轻代区域。从年轻代收集对象的处理称为次要垃圾回收,它总是会导致“暂停整个世界”。

Minor Garbage Collection

随着年轻代的填满,GC 进行了次要垃圾回收。舍弃死对象,并将活对象移动到老年代。在此过程中,应用程序线程停止。

在这里,我们可以看到这样的生成设计所提供的优势。年轻代只是堆的一小部分,并且很快填满。但处理它的时间比处理整个堆的时间短得多。因此,这种情况下的“暂停整个世界”较短,但更频繁。即使更频繁,我们也应该始终以更短的暂停为目标,而不是较长的暂停。

Full Garbage Collection

年轻代分为两个空间 - edensurvivor space。在 eden 收集中存活的对象被移到幸存者空间,在幸存者空间中存活的对象被移到老年代。年轻代在收集时会进行紧缩。

随着对象被移到老年代,它最终会填满,并且必须收集和压缩。不同的算法采用不同的方法。其中一些会停止应用程序线程(因为与年轻代比较,老年代非常大,因此会导致较长的“暂停整个世界”),而另一些则在应用程序线程不断运行时同时执行此操作。此过程称为完全 GC。两个这样的收集器是 CMS 和 G1。

Tuning Garbage Collectors

我们可以按需调整垃圾回收器。以下是可以根据情况进行配置的区域:

  1. Heap Size Allocation

  2. Generation Sizes Allocation

  3. Permagen and Metaspace Configurations

在理解其影响时,让我们详细地了解每一个部分。我们还将根据可用内存、CPU 配置和其他相关因素讨论建议。

Heap Size Allocation

堆大小是我们 Java 应用程序性能的一个重要因素。如果它太小,它将经常被填满,结果,GC 将不得不频繁地回收它。另一方面,如果我们只是增加堆的大小,尽管它需要被更少地回收,但暂停时间将增加。

此外,增加堆大小会对底层操作系统造成严重影响。使用分页,操作系统让我们应用程序看到比实际可用内存多得多的内存。操作系统利用磁盘上的交换空间管理它,将程序的非活动部分复制到其中。当需要这些部分时,操作系统会将它们从磁盘复制回内存中。

让我们假设一台机器有 8G 的内存,JVM 看到了 16G 的虚拟内存,JVM 不会知道系统中实际上只有 8G 可用。它只会从操作系统中请求 16G,一旦获得该内存,它将继续使用它。操作系统必须交换大量数据,这对系统来说是一个巨大的性能损失。

接下来是此类虚拟内存完全 GC 期间将发生的暂停。由于 GC 将对整个堆进行回收和压缩操作,因此它将不得不等待很长时间才能对虚拟内存进行磁盘交换。如果是并发收集器,后台线程将不得不等待很长时间,因为数据将从交换空间复制到内存中。

因此,现在的问题是如何决定将堆大小设为最优值。第一条规则是:永远不要向操作系统请求比实际存在的更大的内存。这将完全防止频繁交换的问题。如果一台机器安装并运行了多个 JVM,那么它们合计的总内存请求会小于系统中实际的 RAM

你可以使用两个标记控制 JVM 内存请求的大小:

  1. -XmsN – 控制请求的初始内存。

  2. -XmxN – 控制可以请求的最大内存。

这两个标记的默认值取决于底层操作系统。例如,对于在 MacOS 上运行的 64b JVM,-XmsN = 64M 和 -XmxN = 1G 的最小值或总物理内存的 1/4。

请注意,JVM 可以自动调整两个值之间。例如,如果它注意到 GC 发生得太多,只要它在 -XmxN 之下并且满足所需的性能目标,它就会不断增加内存大小。

如果你确切地知道你的应用程序需要多少内存,那么你可以设置 -XmsN = -XmxN。在这种情况下,JVM 不需要计算堆的“最佳”值,因此,GC 进程变得更有效。

Generation Sizes Allocation

你可以决定你想要将多少堆分配给 YG,以及你想要将多少堆分配给 OG。这两个值以下面的方式影响我们应用程序的性能。

如果 YG 的大小非常大,那么它将被收集的频率会更低。这将导致更少的对象提升到 OG。另一方面,如果你将 OG 的大小增加得太大,那么对其进行收集和压缩会花费太多时间,这会导致长时间的 STW 暂停。因此,用户必须在这两个值之间找到平衡。

下面是可以用来设置这些值的标记:

  1. -XX:NewRatio=N :YG 与 OG 的比率(默认值 = 2)

  2. -XX:NewSize=N: YG’s initial size

  3. -XX:MaxNewSize=N: YG’s max size

  4. -XmnN :使用此标志将 NewSize 和 MaxNewSize 设置为相同的值

YG 的初始大小由 newRatio 的值确定,公式为:

(total heap size) / (newRatio + 1)

由于 newRatio 的初始值为 2,因此上述公式给出 YG 的初始值为总堆大小的 1/3。你始终可以通过使用 NewSize 标记显式指定 YG 的大小来覆盖此值。此标记没有任何默认值,如果不显式设置,YG 的大小将继续使用上述公式计算。

Permagen and Metaspace Configurations

永久代和元空间是堆区域,JVM 在其中存储类的元数据。在 Java 7 中,该空间被称为“永久代”,在 Java 8 中,它被称为“元空间”。编译器和运行时使用此信息。你可以使用以下标记控制永久代的大小:-:PermSize = N 和 -XX:MaxPermSize = N。可以使用以下来控制元空间的大小::Metaspace- Size = N 和 -:MaxMetaspaceSize = N。

在没有设置标记值的情况下管理永久代和元空间有一些差异。默认情况下,两者都有一个默认的初始大小。但是,虽然元空间可以占用尽可能多的堆,但永久代不能占用超过默认初始值。例如,64b JVM 的堆空间最大为 82M 的永久代大小。

请注意,由于元空间可以占用无限量的内存,除非指定不占用无限量内存,否则可能出现内存不足错误。每次调整这些区域的大小时,都会进行一次完全 GC。因此,在启动时,如果有大量类正在加载,元空间可能不断调整大小,每次都导致完全 GC。因此,如果初始元空间大小太小,大型应用程序需要花费大量时间才能启动。增加初始大小是一个好主意,因为它可以减少启动时间。

虽然永久代和元空间保存着类元数据,但是它并不永久,并且该空间会像对象一样被 GC 回收。这通常出现在服务器应用程序中。每当向服务器进行新的部署时,旧元数据都必须进行清理,因为新的类加载器现在需要空间。这块空间会被 GC 释放。