4.0 第4章:通过垃圾回收释放内存

页码: 85-113
说明: 讲解垃圾回收的标记-清除过程、实现方式和监控。
部分: 1/3


Freeing Memory with Garbage Collection

分配的内存不再需要时必须被释放。在某些语言中,开发者需要负责这件事。而在另一些语言(如 Java)中,这会自动发生。对于 Java,垃圾收集器(garbage collector)负责执行释放。内存释放对于应用程序持续运行是必需的。如果没有能力在内存不再需要时将其释放,我们将只能一次性分配内存,最终用尽所有内存。在本章中,我们将学习更多关于使用垃圾收集器释放堆上内存的知识。

这可能是个棘手的话题!在准备好学习本章之前,你需要清晰理解堆空间(heap space)。同样,我们会尽可能通过可视化概念来加深你的理解。

以下是将要讨论的主题:

  • 技术需求
  • 成为 GC 候选的条件
  • 对象是否可被垃圾回收(GC)回收
  • 垃圾收集器的标记(Marking)
  • 垃圾收集器的清除(Sweeping)
  • 不同的 GC 实现

技术需求

本章的代码可在 GitHub 上找到:
https://github.com/PacktPublishing/B18762_Java-Memory-Management


成为 GC 候选的条件

我们已经知道,堆上的对象在不再需要时会被移除。那么正确的问题是:对象何时不再需要?

这个问题很容易回答,但同时也引出了一个复杂的问题。先来看看答案:当堆上的对象与栈(stack)没有连接时,它们就不再需要了。

当栈中没有变量存储指向该对象的引用时,对象就与栈失去了连接。下面是一个简单示例:

Object o = new Object();
System.out.println(o);
o = null;

在第一行,我们创建了对象,该对象被分配在堆上。变量 o 在栈中保存了对一个 Object 类型对象的引用。因为我们存储了引用,所以可以使用该对象。在这个例子中,我们在第二行打印它,显然输出很无聊,因为 ObjecttoString() 方法只会向控制台返回以下输出:

java.lang.Object@4617c264

在下一行,我们将变量设为 null。这覆盖了对该对象的引用,简单地指向了空处,因为 o 中不再存储任何对象。应用程序中没有其他东西持有对我们创建的 Object 的引用。因此,它变得可以被 GC 回收。

这个例子相当简单。为了演示这个问题实际上有多复杂,让我们看一个稍微复杂一点的例子,并用一些图表来说明。我们需要回答的问题是:在每一行中,哪些对象可以被 GC 回收?

Person p1 = new Person(); // 1
Person p2 = new Person(); // 2
Person p3 = new Person(); // 3
List<Person> personList = Arrays.asList(p1, p2, p3); // 4
p1 = null; // 5
personList = null; // 6

这段代码的堆和栈在执行前四行之后可能看起来像图 4.1。

Figure

图 4.1 – 堆栈概览(用于可回收性示例)
图片描述:栈上有 p1, p2, p3, personList 四个变量。堆上有三个 Person 对象和一个 ArrayList 对象。p1, p2, p3 分别指向三个 Person 对象。personList 指向 ArrayList。ArrayList 内部持有对三个 Person 对象的引用。

在第 5 行,我们将 p1 设为 null。这是否意味着 p1 可以被 GC 回收?快速提醒:一旦堆上的对象不再与栈有连接,它就变得可以被 GC 回收。但让我们看看执行第 5 行后发生了什么:

Figure

图 4.2 – 执行第 5 行后的堆栈概览
图片描述:p1 变量变为 null,不再指向 Person 对象。但 ArrayList 仍然持有对该 Person 对象的引用,因此该对象仍然可以通过栈 → personList → ArrayList → Person 的路径访问。

如图可见,与栈通过 p1 的连接已经消失。但这并不意味着没有与栈的连接了。仍然存在一个间接连接。我们可以从栈到达 Person 对象的列表,然后从那里仍然可以访问 p1 之前引用的那个对象,因为该列表仍然持有对该对象的引用。因此,在第 5 行之后,堆上没有任何对象可以被 GC 回收。

在第 6 行之后,这种情况发生了变化。在第 6 行,我们将持有列表的变量设为 null。这意味着在这一行之后,p1 不再与栈有连接,如图 4.3 所示。

Figure

图 4.3 – 代码结束时的堆栈概览
图片描述:personList 变为 null,ArrayList 与栈的连接断开。因此 ArrayList 和第一个 Person 对象(原本由 p1 引用)变为不可达。p2 和 p3 变量仍然持有对各自 Person 对象的引用。

列表与栈之间没有连接了,现在 List 对象和 Person 对象的第一个实例都可以被 GC 回收。同时,p2p3 变量仍然持有对堆上对象的引用,因此这些对象不可被 GC 回收。

一旦你理解了从堆到栈的直接和间接连接,判断哪些对象已准备好被 GC 回收并不难。然而,找出哪些对象与栈有连接需要一些时间,这会拖慢应用程序的其余部分。有几种方法可以做到这一点,但在准确性或性能方面各有缺点。

这个复杂问题是语言无关的:我们如何确定一个对象是否仍然与栈有连接?我们将要讨论的解决方案当然是 Java 专用的。寻找不再需要的对象是由垃圾收集器在标记阶段(marking phase)完成的。标记阶段包含一种特殊算法,用于确定哪些对象可以被 GC 回收。


垃圾收集器的标记(Marking)

标记(Marking)会标记所有存活的对象,任何未被标记的对象都将被视为准备被垃圾回收。对象保留一个特殊位(bit)来确定它们是否被标记。创建时,该位为 0。在标记阶段,如果一个对象仍在使用中且不应被移除,则该位被设为 1。

堆在不断变化,栈也在不断变化。堆上那些与栈没有连接的对象可以被 GC 回收。它们是不可达的(unreachable),应用程序不可能再使用这些对象。不准备被移除的对象被标记;未标记的对象将被移除。

具体如何实现取决于你使用的 Java 实现以及特定的垃圾收集器。但在高层次上,这个过程从栈开始。栈上的所有对象引用都被追踪,对象被标记。

如果看我们之前的例子,这就是它们被标记的方式。我们使用以下代码示例,其中我们未将 personList 的引用设为 null

Person p1 = new Person(); // 1
Person p2 = new Person(); // 2
Person p3 = new Person(); // 3
List<Person> personList = Arrays.asList(p1, p2, p3); // 4
p1 = null; // 5

在 GC 开始之前,所有对象都未标记。这意味着特殊位是 0,即创建时得到的值。

Figure

图 4.4 – 垃圾回收开始前,没有对象被标记
图片描述:三个 Person 对象和 ArrayList 对象旁边都标有 0。

因此,一开始所有对象都未标记,正如我们在对象后面看到的全 0。下一步是通过将 0 改为 1 来标记与栈有连接的对象。

Figure

图 4.5 – 标记第一步:与栈的直接连接
图片描述:p2, p3, personList 对应的 Person 对象和 ArrayList 对象被标记为 1。p1 对应的 Person 对象仍然为 0,因为 p1 已为 null。

但仅仅标记与栈有直接连接的对象还不够。当前,由 Person p1 引用的对象应该是可 GC 回收的,即使它是可达的(通过列表)。这也是为什么每个对象的引用也会被遍历并标记,直到没有更多嵌套对象。

图 4.6 展示了我们的例子在标记阶段后的情况。

Figure

图 4.6 – 标记之后
图片描述:所有三个 Person 对象和 ArrayList 对象都被标记为 1。

我们堆上的所有对象都被标记了,如每个对象后面的 1 所示。因此,在我们的示例中,没有任何对象可以被 GC 回收,因为它们都是可达的。

有多种算法在标记阶段扮演重要角色。我们将首先了解一种方法:停止世界(stop-the-world)方法。


停止世界(Stop-the-world)

思考一下这件事是如何完成的。当你检查栈上的所有变量并标记它们的所有对象及嵌套对象时,新对象可能在这期间被创建。你可能会错过栈的那部分。这会导致本应被标记的对象未被标记(记住,对象在创建时最初是未标记的),结果它们会被移除。这将是灾难性的。

这个问题的解决方案会影响性能,因为垃圾收集器需要暂停主应用程序的执行,以确保在标记阶段没有新对象被创建。这种策略被称为停止世界(stop-the-world)——尽管听起来很戏剧化,但这是 Java 的术语。计算机科学领域还有其他策略,其中之一是引用计数(reference counting),我们接下来将讨论它。


引用计数与隔离岛(Islands of Isolation)

另一种实现方法是计算对象上的引用数量。所有对象都会包含一个计数器,记录它们被引用的次数,作为某种属性。这样,执行 GC 就只是移除所有引用计数为 0 的对象。

你可能会认为这比暂停应用程序好得多,那么为什么我们不使用它呢?答案在于隔离岛(Islands of Isolation)。这不是某种现代社交现象;隔离岛是指那些只相互引用、但与栈没有连接的对象。

让我们探索以下代码示例的堆和栈。这个例子中有一个 Nest 类:

class Nest {
    private Nest nest;
    public Nest getNest() {
        return nest;
    }
    public void setNest(Nest nest) {
        this.nest = nest;
    }
}

我们创建两个 Nest 实例,并将它们设置为彼此的 nest 属性:

public class IslandOfIsolation {
    public static void main(String[] args) {
        Nest n1 = new Nest(); // 1
        Nest n2 = new Nest(); // 2
        n1.setNest(n2); // 3
        n2.setNest(n1); // 4
        n1 = null; // 5
        n2 = null; // 6
    }
}

让我们看看在第 4 行之后计数引用会是什么样子。

Figure

图 4.7 – 创建两个 Nest 对象并将它们分配给彼此的字段后的概览
图片描述:栈上有 n1 和 n2。堆上有两个 Nest 对象。n1 指向 Nest1,n2 指向 Nest2。Nest1 的 nest 字段指向 Nest2,Nest2 的 nest 字段指向 Nest1。每个对象的引用计数为 2(一个来自栈,一个来自另一个对象)。

在第 4 行之后,两个计数都是 2。对象同时被另一个对象和栈引用。在第 5 行和第 6 行之后,这种情况发生了变化,因为对栈的引用被移除了。

Figure

图 4.8 – 将栈引用设为 null 后的概览
图片描述:n1 和 n2 变为 null。堆上的两个 Nest 对象仍然相互引用,各自计数为 1。但栈上没有引用指向它们。

如代码所示,在带有注释 6 的行执行完毕后,两个对象从栈上都不可达。然而,如果我们使用引用计数,它们仍然都会有 1,因为它们相互引用。

由于这些对象计数不为 0,但也没有与栈的连接,它们就成了:隔离岛。它们应该被垃圾回收,但简单的计数垃圾收集器无法检测到它们,因为它们的引用计数不为 0。更高级的垃圾收集器(即标记所有与栈有连接的元素并需要暂停应用程序的收集器)会回收它们,因为它们与栈没有连接。

因此,Java 使用更准确的标记阶段来暂停应用程序。没有标记垃圾收集器,隔离岛会导致内存泄漏(memory leak):本可以被释放的内存却永远无法再被应用程序使用。


垃圾收集器的清除(Sweeping)

一旦需要保留的对象被标记,就到了开始下一阶段以实际释放内存的时候了。这些对象的删除在 GC 术语中被称为清除(sweeping)。为了让事情更有趣,我们有三种清除方式:

  • 普通清除(Normal sweeping)
  • 清除并压缩(Sweeping with compacting)
  • 清除并复制(Sweeping with copying)

我们将详细讨论所有这些方式,并配以插图帮助你理解发生了什么。

普通清除(Normal sweeping)

普通清除是移除未标记的对象。图 4.9 展示了内存中的五个对象。其中两个(带有 x 标记的)将被移除。

Figure

图 4.9 – 内存中已标记对象的示意图
图片描述:五个对象按顺序排列在内存中。三个带有数字 1(已标记),两个带有数字 0(未标记)。带有 0 的两个对象被画上 x,表示将被移除。

(后续内容将在下一部分继续)


核心概念

  • 可达性:堆上的对象只有通过栈的引用链(直接或间接)可达时才存活。
  • 标记-清除:Java GC 的主要方式:先标记存活对象(从根集出发),再清除未标记对象。
  • 停止世界:标记阶段需要暂停所有应用线程,以避免并发修改导致的不一致性。
  • 隔离岛:引用计数无法处理相互引用但无根连接的对象,而标记-清除可以解决。

4.0 第4章:通过垃圾回收释放内存

常规清除

常规清除即移除未被标记的对象。图 4.9 展示了内存中的五个对象。其中两个带有“x”标记的对象将被移除。

图 4.9 – 内存中标记对象的示意图

内存块的大小并不相等;有的较小,有的较大。清除不可达对象后,内存状态如下:

图 4.10 – 清除后内存的示意图

清除操作释放了内存,内存块之间的空隙可以重新分配。然而,只有大小刚好能填入这些空隙的块才能被存放。此时内存已碎片化,这可能导致大内存块无法存储的问题。

内存碎片化

内存碎片化发生在先存储内存、再从中部移除块之后。内存块之间可以分配新内存。如图 4.11 所示。

图 4.11 – 在碎片化内存中分配新对象

新的内存块被存入空隙中。在图中所示的特定情况下,内存块刚好能填入空隙,因此工作正常。但如果内存块无法填入空隙(或在这张概览图的末尾处),就会出现问题。我们来看看想要存入一个新块的情况。

图 4.12 – 尝试存储一个小于总可用内存的大内存块

如果查看总可用内存,上图中所示的内存块是可以放下的。然而,由于碎片化内存中没有足够的连续空间,我们无法存储这个新块。

图 4.13 – 新内存块无法放入的示意图

无法放入请求的内存块会导致错误:OutOfMemoryError。尽管实际上并未耗尽内存,技术上也有足够的可用内存来存储新块,但由于可用内存碎片化,无法放入。这是常规清除的问题。它虽然效率高且简单,但会导致内存碎片化。如果内存充足且应用只需快速释放内存,那么这种方法可能比较合适。如果内存较为紧张,则应优先考虑其他清除选项:压缩清除复制清除。我们先来看压缩清除。

压缩清除

压缩清除是一个两步过程。与常规清除一样,首先删除内存块。但这次我们不接受碎片化内存作为最终结果,而是执行一个额外的步骤——压缩。压缩移动内存块,确保它们之间没有空隙。过程如图 4.14 所示。我们假设要移除的内存块与图 4.9 中相同。

图 4.14 – 压缩清除

如图所示,这次我们不会得到碎片化内存。因此也不会出现 OutOfMemoryError。这听起来很棒,但正如常言道,魔法是有代价的。这里,代价是性能。压缩内存是一个性能开销较高的过程,因为所有内存块都需要移动(并且通常需要顺序进行)。

这种高开销的压缩过程有一个替代方案,即复制清除。但先别完全忘记压缩清除,因为复制清除本身也有其代价。

复制清除

复制清除是一个巧妙的过程。它需要两个内存区域。它并非删除不再需要的内存块,而是删除所有内存块!但在此之前,先把仍需的内存块复制到第二个内存区域(参见图 4.15)。

图 4.15 – 清除前的复制清除

首先,我们有包含不再需要对象的内存区域,以及一个尚未分配的第二个内存区域。下一步,我们将所有需要的对象复制到第二个内存区域。

图 4.16 – 复制后的复制清除

到目前为止,我们只完成了复制,尚未清除任何内容。这正是下一步要做的事情:清空第一个内存区域,因为所有仍需的对象都已保存在第二个内存区域。结果如图 4.17 所示。

图 4.17 – 复制清除后内存的示意图

清除第一个内存区域后,所有仍然可达的对象都位于第二个内存区域。这在性能上比压缩清除更好,但可想而知,这需要更多的可用内存。

究竟使用哪种清除方式取决于所选垃圾回收器的实现。目前存在相当多的实现。我们将在下一节探讨最常见的几种。

探索 GC 实现

标准 JVM 提供了五种 GC 实现。其他 Java 实现(如 IBM 和 Azul 的垃圾回收器)可能拥有不同的 GC 实现。理解了标准 JVM 自带的以下五种实现后,理解其他实现的工作方式就相对容易了:

  • Serial GC
  • Parallel GC
  • CMS(并发标记清除)GC
  • G1 GC
  • ZGC(Z 垃圾回收器)

我们稍后将详细研究这些实现的具体工作方式(但不会讨论每种实现的全部命令行选项)。不过,在讨论这些特定垃圾回收器的工作原理之前,需要先介绍另一个概念:分代 GC

分代 GC

如果一个大型 Java 应用程序运行时,为了等待垃圾回收器标记每一个存活对象而暂停整个程序,那将是一场性能噩梦。幸运的是,通过利用堆上的不同代(generation),人们想出了更巧妙的方法。并非所有即将介绍的垃圾回收器都使用这一策略,但有些确实使用了。

分代垃圾回收器不会一次性运行完整的垃圾回收,而是专注于内存的某个部分,例如年轻代。这种方法适用于大多数对象在年轻时死亡的应用。它可以节省大量标记工作。

分代垃圾回收器通常与记忆集(remembered set)一起工作。记忆集包含了从老年代指向年轻代的所有引用。这样,老年代就不需要被扫描,因为指向年轻代的引用已经记录在记忆集中。

如果应用程序的大部分对象驻留在终生代(tenured generation),那么专注于年轻代的 GC 方法效果不佳。因为在这种情况下,堆中老年代占比特别大,仅回收年轻代不会释放出高比例的内存。

分代垃圾回收器通常需要在不同内存区域使用不同策略。例如,年轻代可以使用 stop-the-world 垃圾回收器,将整个可达对象集合复制到老年代,然后删除年轻代。与此同时,老代可以使用压缩方式,或许结合 stop-the-world 的替代方案,例如 CMS 垃圾回收器(我们将在介绍不同实现时看到)。

现在我们已经讨论了清除的几种选择,以及 stop-the-world 和分代垃圾回收器,我们就能更好地理解之前列出的五种实现了。(坚持住,你马上就要读完这个艰难的章节了!)

Serial GC

Serial GC 在单线程上运行,采用 stop-the-world 策略。这意味着当垃圾回收器运行时,应用程序不执行其主要任务。它是最简单的垃圾回收选项。

对于年轻代,它使用标记策略识别哪些对象符合 GC 条件,并采用复制清除方法实际释放内存。对于老年代,它使用标记-清除-压缩方法。

Serial 垃圾回收器非常适合小型程序,但对于像 Spring 或 Quarkus 应用程序这样的大型程序,还有更好的选择。

Parallel GC

Parallel 垃圾回收器是 Java 8 的默认垃圾回收器。与 Serial 垃圾回收器一样,它对年轻代使用标记-复制方法,对老年代使用标记-清除-压缩方法。但(这可能会让你惊讶)它是并行执行的。在这里,并行意味着使用多个线程来清理堆空间。因此,标记、复制和压缩阶段不是由一个线程负责,而是由多个线程共同完成。尽管它仍然是 stop-the-world,但它的性能优于 Serial 垃圾回收器,因为世界需要停止的时间更短。

Parallel 垃圾回收器在多核机器上表现良好。在(较少的)单核机器上,由于管理多个线程的开销以及单核上无法真正并行处理,Serial 垃圾回收器可能更合适。

CMS GC

并发标记清除垃圾回收器CMS GC,Concurrent Mark Sweep Garbage Collector)拥有改进后的标记-清除算法。它通过多线程执行此操作,并大幅缩短了暂停时间。这是CMS GC与并行垃圾回收器的主要区别。

不过,并非所有系统都能承受在主应用与垃圾回收器之间共享资源的代价。但如果系统能够承受,那么相比并行垃圾回收器,它在性能方面将是一项巨大的升级。

CMS GC也是一种分代垃圾回收器。它对年轻代和老年代分别执行独立的周期。对于年轻代,它使用标记-复制(mark and copy)与停止世界(stop-the-world)。因此,在年轻代GC期间,主应用线程会被暂停。

老年代的垃圾回收主要通过并发标记清除(mostly concurrent mark and sweep)完成。“mainly concurrent”意味着它大部分是并发执行的,但在一个GC周期中仍会两次触发stop-the-world。它会在开始时第一次暂停所有主应用线程,然后在标记过程中短暂停顿一次,接着在GC周期中间(通常略长一些)进行最终标记时再次暂停。

这些暂停通常非常短暂,因为CMS GC试图在并发运行的同时回收足够的老年代空间,从而防止老年代变满。但有时这是不可能的。如果CMS GC在老年代接近满时无法释放足够的空间,或者应用无法分配对象,那么CMS GC会暂停所有应用线程,并将主要精力转向GC。这种垃圾回收器未能以主要并发方式完成GC的情况称为并发模式失败(concurrent mode failure)。

如果此时回收器仍无法释放足够的内存,则会抛出OutOfMemoryError。这种情况发生在应用程序98%的时间都用于GC,而堆内存回收率不足2%时。

这与我们讨论过的其他垃圾回收器相比并没有太大差别。CMS GC的极短暂停听起来已经很不错了,但后续还有更先进的升级。接下来让我们看看G1 GC。

G1 GC

G1(垃圾优先,garbage-first)垃圾回收器随Java 7(小版本4)推出,是CMS GC的升级版。它以巧妙的方式结合了不同的算法。G1回收器是并行的、并发的,并且旨在缩短应用暂停时间。它采用一种称为增量压缩(incrementally compacting)的技术。

G1垃圾回收器将堆划分为更小的区域:比分代垃圾回收器的区域小得多。它使用这些更小的内存段进行标记和清除。它会跟踪每个内存区域中可达对象和不可达对象的数量。包含最多不可达对象的区域会被优先回收,因为这样可以释放最多的内存。这就是它被称为“垃圾优先”垃圾回收器的原因——垃圾最多的区域被优先回收。

在执行这些操作的同时,它还会将对象从一个区域复制到另一个区域。这样就能完全清空第一个区域。通过这种方式,G1 GC一举两得:同时完成GC和压缩。这也是它相较于前面提到的垃圾回收器如此升级的原因。

G1 GC是一种优秀的垃圾回收器。你可能会好奇,这种垃圾回收器能否在没有stop-the-world的情况下工作?不能,压缩仍然需要以这种方式进行。但由于区域更小,暂停时间也大大缩短。

G1 GC的另一个新特性是字符串去重(string deduplication)。顾名思义:垃圾回收器运行一个进程来检查String对象。当发现内容相同但指向堆上不同char数组的String对象时,它们会被更新为指向同一个char数组。这使得另一个char数组符合GC条件,从而优化了内存使用。更令人兴奋的是,这一切都是完全并发的!需要使用以下命令启用该选项:-XX:+UseStringDeduplication

与CMS GC类似,G1 GC也试图并发地完成大部分GC工作。因此,大多数时候应用线程无需暂停。然而,如果G1 GC无法释放足够的内存,且应用分配的速度超过了并发释放的速度,那么应用线程就需要暂停。

G1垃圾回收器是高性能、大内存空间系统的首选GC。但它还不是最新加入的垃圾回收器。接下来我们看看ZGC。

Z GC

Java 15为我们带来了又一个生产就绪的垃圾回收器实现——Z垃圾回收器ZGC)。它完全并发地执行所有垃圾回收工作,并且每次暂停时间不超过10毫秒。

它通过从标记存活对象开始来实现这一点。它不维护映射表,而是使用引用染色(reference coloring)。引用染色意味着引用的存活状态作为引用的一部分位来存储。这需要额外的位,因此ZGC只能运行在64位系统上,而不能在32位系统上运行。

通过使用重定位(relocation)来避免碎片化。该过程与应用并行发生,以避免暂停超过10毫秒,但这是在应用执行期间进行的。

如果没有额外的措施,这可能导致令人不快的意外。想象一下,我们正试图通过引用访问某个对象,但在此过程中,该对象被重定位并拥有了新的引用。旧的内存位置可能已经被覆盖或清除。在这种情况下,调试将是一场噩梦。

当然,Java团队不会将有此类问题的垃圾回收器推向生产环境。他们引入了加载屏障(load barriers)来解决这个问题。每当从堆中加载引用时,加载屏障就会运行。它会检查引用的元数据位,并根据结果决定在获取结果之前是否需要进行一些处理。这种神奇的操作称为重映射(remapping)。

我们刚才讨论的五种垃圾回收器是编写本书时可用的主要选项。你的选择取决于所使用的Java版本、系统配置以及应用类型。为确保垃圾回收器性能良好,需要进行监控。这正是下一节要讨论的内容。

监控GC

为了选择合适的垃圾回收器,你需要了解你的应用。以下几个指标对于GC尤为重要:

  • 分配速率(Allocation rate):应用程序在内存中分配对象的速度。
  • 堆人口(Heap population):堆上存活对象的数量及其大小。
  • 变动速率(Mutation rate):内存中引用被更新的频率。
  • 平均对象存活时间(Average object live time):对象的平均存活时间。某些应用的对象可能“夭折”,而另一些应用的对象则可能存活更长时间。

GC性能的监控需要不同的指标。最重要的有标记时间(mark time)、压缩时间(compaction time)和GC周期时间(GC cycle time)。标记时间是指垃圾回收器找到堆上所有存活对象所花费的时间。压缩时间是指垃圾回收器释放所有空间并重定位对象所花费的时间。GC周期时间是指垃圾回收器执行一次完整GC所花费的时间。

每当堆空间不足时,你会看到GC的CPU使用率上升。选择合适的内存大小将提高应用的性能。可用内存越大,垃圾回收器越容易发挥作用。

复制-压缩回收器需要有足够的可用空间进行复制和重定位。当可用内存有限时,这是一个成本高得多的过程。可能只能复制一小段内存来释放一点点空间,希望下次能复制更多,以此类推。垃圾回收器的CPU使用率在内存低时最高。在另一个极端,假设我们拥有无限内存,那么实际上我们根本不需要垃圾回收。

在第6章中,我们将探讨如何通过JVM调优来管理内存,从而改善JVM内存的运行。届时,我们还将了解如何调优垃圾回收器。

总结

在本章中,我们更深入地了解了堆的GC工作原理。当堆上的对象不再与栈有联系(无论是直接还是间接)时,它们就有资格被GC回收。

在标记阶段,垃圾回收器确定哪些对象有资格被回收。与栈有联系的对象会被标记。有资格被GC回收的对象则不被标记。

标记阶段之后,实际的对象移除发生在清除阶段。我们讨论了三种清除方式:普通清除、压缩清除和复制清除。

接着,我们讨论了不同垃圾回收器的实现。其中一部分是分代垃圾回收器。这些垃圾回收器专注于堆的一个代,因此在标记阶段不需要扫描堆上的所有对象。之后,我们讨论了五种常见的垃圾回收器实现。

在下一章中,我们将深入探讨元空间(Metaspace)。