已分配的内存在不再需要时就必须被释放。在某些语言里,这需要开发者亲自处理;而在另一些语言(如 Java)中,这会自动完成。对 Java 而言,这项工作由垃圾回收器(GC)负责。内存释放对于应用能够持续运行是必要的:如果无法在不再需要时释放内存,我们就只能分配一次内存,最终会耗尽可用内存。在本章中,我们将进一步学习如何借助垃圾回收器释放堆上的内存。
这可能是个不太轻松的主题!在开始本章之前,你需要对堆空间有清晰的认识。我们仍会尽可能通过可视化来加深理解。
本章将讨论以下主题:
- 对象何时具备垃圾回收(GC)资格
- 垃圾回收器的“标记”过程
- 垃圾回收器的“清除”过程
- 不同 GC 实现
技术要求
本章代码示例见 GitHub:github.com/PacktPublis…。
具备 GC 资格
我们已经知道,堆上的对象在“不再需要”时会被移除。于是正确的问题是:对象在什么时候不再需要?
这个问题的答案并不难,但会引出一个复杂的问题。先看答案:当对象与栈不再存在连接时,堆上的对象就不再需要了。
当栈中不再在某个变量里保存该对象的引用时,该对象与栈之间就不存在连接。来看一个简单示例:
Object o = new Object();
System.out.println(o);
o = null;
第一行创建了对象,它被分配在堆上。变量 o 在栈上保存了一个指向 Object 类型对象的引用。我们之所以能使用该对象,是因为持有这个引用。此处我们在第二行打印它,这显然会得到一个有点“无趣”的输出,因为 Object 的 toString() 方法通常只会在控制台输出如下内容:
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。
图 4.1 – GC 资格示例的栈与堆概览
到第 5 行,我们把 p1 设为 null。这是否意味着 p1 引用的对象具备了 GC 资格?快速回顾:**当堆上对象不再与栈存在连接时,它才具备 GC 资格。**那我们看看执行第 5 行之后发生了什么(见图 4.2)。
图 4.2 – 执行第 5 行后的堆与栈概览
如图所示,p1 与栈的直接连接没了。但这并不表示与栈完全没有连接——仍然存在间接连接。我们可以从栈走到 personList 这个 List<Person> 对象,再由它访问到原本由 p1 引用的那个 Person 实例,因为列表仍然持有对该对象的引用。因此,第 5 行之后堆上的任何对象都不具备 GC 资格。
这种情况在第 6 行之后发生变化。第 6 行我们把持有列表的变量设为 null。这意味着 p1 对应的那个对象此时与栈再无连接,如图 4.3 所示。
图 4.3 – 代码末尾时的堆与栈概览
此时列表对象与栈之间也已没有连接,因此 List 对象和第一个 Person 实例都具备 GC 资格。与此同时,变量 p2 与 p3 仍各自持有堆上对象的引用,因此这些对象不具备 GC 资格。
一旦理解了从堆回到栈的直接与间接连接,判断哪些对象可被回收并不难。然而,找出哪些对象仍与栈有关联是需要时间的,这会拖慢应用的其它部分。确定连接的算法有多种,但各有其在准确性或性能方面的权衡。
这个复杂问题与语言无关:如何判定一个对象是否仍与栈相连?我们接下来讨论的方案是 Java 特有的。查找“不再需要”的对象是垃圾回收器在标记(marking)阶段的职责。标记阶段包含一种特殊算法,用来确定哪些对象具备 GC 资格。
垃圾回收器的标记(Marking)
标记阶段会把存活对象标记出来,任何未被标记的对象都被视为可回收。对象内部维护了一个特殊的位来表示其是否已被标记。对象创建时该位为 0;在标记阶段,如果对象仍在使用、不应被移除,就将该位设为 1。
堆与栈都在不断变化。堆上与栈不再存在连接的对象就具备 GC 资格——它们不可达,应用不可能再使用它们。应当保留的对象会被标记;未被标记的对象将被移除。
至于具体如何实现,取决于所用的 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),也就是它们在创建时的状态。
图 4.4 – 垃圾回收开始前,没有任何对象被标记
因此一开始它们都未被标记(对象后面的 0 表示未标记)。接下来要把与栈有连接的对象标记出来,把 0 改为 1。
图 4.5 – 标记第一步:直接与栈相连的对象
但只标记与栈直接相连的对象还不够。此时由 Person p1 所指向的对象会被视为可回收,尽管它实际上可达。因此还必须沿着对象的引用继续遍历并标记,一直到没有更多的嵌套对象为止。图 4.6 展示了标记阶段结束后的状态。
图 4.6 – 标记结束
可以看到堆上的所有对象都被标记了(对象后面的 1)。因此在这个例子里,没有对象具备 GC 资格,所有对象仍是可达的。
标记阶段有多种重要算法。我们先看一种:Stop-the-world(全停顿) 。
Stop-the-world(全停顿)
想象一下标记过程:当你检查栈上的变量、标记它们引用的对象及其嵌套对象时,新的对象可能同时在被创建。这样你可能错过栈的某些部分,导致一些原本应该被标记的对象仍保持“未标记”(对象创建时默认未标记),从而在清理阶段被错误移除——这会非常糟糕。
解决办法会影响性能:垃圾回收器需要暂停应用的执行,以确保标记阶段期间不会有新对象被创建。这种策略就叫 stop-the-world。计算机科学中还有其他策略,其中之一是引用计数,我们接下来就来看它。
引用计数与“隔离岛”(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 实例,并让它们互相指向对方:
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 行之后暂停,看看引用计数会是怎样的。
图 4.7 – 创建两个 Nest 对象并互相赋值后的概览
第 4 行之后,两个对象的计数都为 2:它们各自被另一个对象和栈上的变量引用。第 5、6 行执行后,栈上的引用被移除,情况发生变化:
图 4.8 – 将栈上的引用置空后的概览
从代码可以看出,执行到带注释 6 的那一行后,这两个对象都已经无法从栈上到达。然而如果我们采用引用计数法,它们的计数仍为 1,因为它们互相引用。
也就是说,这类对象虽然与栈无连接,但由于计数不是 0,引用计数法无法识别它们应被回收;它们形成了隔离岛。它们本应被垃圾回收,但简单的引用计数 GC 检不出来。而标记式垃圾回收(需要暂停应用并从栈根开始标记)则能识别出它们不可达,从而回收它们。
因此,Java 采用了更准确的标记阶段并暂停应用执行。如果没有标记式 GC,“隔离岛”就会导致内存泄漏:那些本可以释放的内存永远不会重新供应用使用。
接下来,我们来讨论在标记之后,系统是如何真正释放这部分内存的。
清扫(Sweeping)阶段
一旦需要保留的对象完成标记,下一阶段就要真正释放内存了。在 GC 术语中,这个删除对象的过程称为清扫(sweeping) 。为使话题更有意思,清扫大体有三种方式:
- 普通清扫(Normal sweeping)
- 带压缩的清扫(Sweeping with compacting)
- 带复制的清扫(Sweeping with copying)
下面我们配合示意图分别说明。
普通清扫(Normal sweeping)
普通清扫就是移除未标记的对象。图 4.9 展示了内存中的五个对象,其中打了“x”的两个对象将被移除。
图 4.9 – 含已标记对象的内存示意
这些内存块大小不一,有的更小,有的更大。清扫掉不可达对象后,内存变为如下状态:
图 4.10 – 清扫后的内存示意
清扫释放了内存,块与块之间出现了空隙,这些空隙可以再次被分配。然而,只有能“塞进”空隙的块才能放进去。此时内存已被碎片化,这可能会给较大的内存块分配带来问题。
碎片化(Fragmentation)
内存碎片化发生在先分配了一些块、再从中间移除若干块之后。新内存只能插入到这些夹缝里。见图 4.11。
图 4.11 – 在碎片化内存中分配新对象
当新块大小恰好适配空隙时,一切正常。但如果新块无法适配这些空隙(或无法放在末尾),就会有问题。来看试图存放一个新大块的情形:
图 4.12 – 试图存放一个“大块”,其大小小于系统“总可用”内存
从总可用内存看,这个块似乎放得下;但由于内存被碎片化,没有足够的连续空间来容纳它:
图 4.13 – 新内存块放不进去的示意图
此时就会抛出 OutOfMemoryError。尽管系统并未真正“内存用尽”,也的确有足够的总量,但由于可用空间不连续,导致无法分配。这是普通清扫的缺点:过程高效简单,却容易导致内存碎片化。如果系统内存很充裕、且应用只需要快速释放一些内存,普通清扫可能是可取的;但在内存吃紧时,更倾向于使用另外两种方式:带压缩的清扫或带复制的清扫。先看压缩。
带压缩的清扫(Sweeping with compacting)
带压缩的清扫是一个两步过程。第一步与普通清扫一样:删除不可达对象。不同的是,第二步会执行压缩(compacting) :移动剩余内存块,把它们“挤”在一起,消除空洞。见图 4.14(假设要删除的对象与图 4.9 相同)。
图 4.14 – 带压缩的清扫
可以看到,最终没有碎片,因此不会出现前述因不连续而导致的 OutOfMemoryError。听起来很棒,但“魔法”总有代价——这里的代价是性能。压缩需要移动大量内存块,而且通常要顺序地移动,开销不小。
为避免压缩的高开销,还有一种替代方式:带复制的清扫。不过别急着忘了压缩,因为“复制”也有自己的成本。
带复制的清扫(Sweeping with copying)
带复制的清扫的思路很巧妙:我们需要两块内存区。与其删除不需要的对象,不如把两边都清空——但在清空第一块之前,先把需要保留的对象复制到第二块。见图 4.15。
图 4.15 – 带复制清扫:正式清扫前的复制准备
开始时,有一块含有对象的内存区(其中一些不再需要),旁边还有一块尚未分配的内存区。
接下来,把需要保留的对象复制到第二块内存区:
图 4.16 – 带复制清扫:复制完成后的状态
到目前为止,我们只做了复制,还没有清扫。下一步就是清空第一块内存区,因为要保留的对象已经都在第二块里了。结果如下:
图 4.17 – 带复制清扫后的内存示意
清扫第一块后,所有仍可达的对象都在第二块内存区中。与“带压缩”相比,这通常性能更好,但顾名思义,它需要额外的空闲内存来容纳复制过程。
采用哪种清扫方式,取决于所选的垃圾回收器实现。市面上 GC 实现相当多,下一节我们会探讨最常见的几类。
探索 GC 的各种实现
标准 JVM 提供了五种 GC 实现。其他 Java 发行版(如 IBM、Azul)也有各自的垃圾收集器。但只要理解了标准 JVM 自带的这五种,实现其它 GC 的工作方式也就容易多了:
- Serial GC(串行 GC)
- Parallel GC(并行 GC)
- CMS(Concurrent Mark Sweep,并发标记清除)GC
- G1 GC
- ZGC(Z 垃圾收集器)
稍后我们会详细说明这些实现的工作原理(但不会覆盖它们所有的命令行选项)。在此之前,需要先理解一个概念:分代式 GC。
分代式 GC(Generational GC)
如果一个大型 Java 应用在进行一次完整 GC 期间必须暂停整个程序,等待把所有存活对象都标记完,那将是性能灾难。幸运的是,GC 借助堆上的**不同“代”**做了更聪明的设计。并非所有的垃圾收集器都使用这一策略,但有一些是的。
与其一次性扫描整个堆,分代式 GC会聚焦于某一部分内存,例如新生代。这对“大多数对象朝生暮死”的应用尤其有效,可以省去大量标记工作。
分代式 GC 往往会使用记忆集(remembered set) :这是从老年代对象指向新生代对象的引用集合。这样在回收新生代时,无需扫描整个老年代,因为老年代指向新生代的引用已经在记忆集中记录好了。
如果一个应用的大多数对象都在老年代,那么只盯着新生代做 GC 收益就不高——因为释放的内存占比有限。
通常,分代式 GC 会对不同内存区采用不同策略。例如:新生代用stop-the-world 的复制式算法(把可达对象复制到老年代,再清空新生代);而老年代可能使用压缩,并配合一种减少停顿的方案(比如稍后会提到的 CMS)。
理解了清扫方式、STW(stop-the-world)与分代式 GC 之后,我们就可以看那五种实现了(坚持一下,就到本章尾声了!)。
Serial GC(串行 GC)
Serial GC 在单线程上运行,并采用 stop-the-world 策略。这意味着 GC 期间,应用的主任务会暂停。它是最简单的 GC 选项。
- 新生代:标记 + 复制(mark & copy)
- 老年代:标记 + 清扫并压缩(mark–sweep–compact)
串行 GC 适合小型程序;对于 Spring、Quarkus 这类较大的应用,还有更好的选择。
Parallel GC(并行 GC)
Parallel GC 是 Java 8 的默认垃圾收集器。它在策略上与 Serial GC 类似:
- 新生代:标记 + 复制
- 老年代:标记–清扫–压缩
不同点在于:这些步骤是并行执行的——使用多线程进行标记/复制/压缩。虽然仍然是 stop-the-world,但暂停时间更短,因此整体性能优于串行 GC。
在多核机器上效果很好;而在少见的单核机器上,串行 GC 可能更划算(多线程管理开销大且无法真正并行)。
CMS GC(Concurrent Mark Sweep)
CMS(并发标记清除)用多线程改进了标记–清扫算法,显著缩短停顿时间——这正是它与 Parallel GC 的主要区别。
并不是所有系统都能承受让 GC 与主应用并发争用资源;但若系统能承受,CMS 相较并行 GC 是不错的升级。
CMS 也是分代式收集器,对新生代与老年代分别处理:
-
新生代:标记 + 复制,且是 stop-the-world(回收新生代时会暂停应用线程)。
-
老年代:大部分并发的标记与清扫。所谓“大部分并发”,是指在一个 GC 周期中仍会有两次短暂的 STW:
- 周期最开始会短暂停顿(初始标记),
- 周期中段会有一次(最终标记),通常稍长一些。
这些暂停通常很短,因为 CMS 试图在与应用并发的情况下回收足够多的老年代空间,避免其被填满。但有时做不到:当老年代趋于饱和、或申请新对象失败时,CMS 会暂停全部应用线程,把重点转为 GC。这种没能“主要并发完成”的情况称为 concurrent mode failure。
如果之后仍回收不足,就会抛出 OutOfMemoryError。典型触发条件是:98% 的时间耗在 GC 上却只回收了不到 2% 的堆。
CMS 的这些特性与前述收集器并无本质不同;它的“短暂停顿”已经不错,但后来又出现了更进一步的 G1。
G1 GC(Garbage-First)
G1(Java 7u4 引入)是对 CMS 的升级,它并行、并发,还力求短暂停顿,并使用一种称为**增量压缩(incrementally compacting)**的技术。
G1 把堆划分为很多更小的区域(region) ,远小于传统分代的粒度。它在这些小区域上做标记和清理,并记录每个区域中可达/不可达对象的数量。垃圾最多的区域会被优先回收,因此称为“垃圾优先”。
在回收时,G1 会把对象从一个区域复制到另一个区域,从而完全空出源区域。这样既完成了回收,又达到了压缩的效果,可谓一石二鸟。这正是其相对先前收集器的重要升级。
G1 仍需要 stop-the-world 来完成压缩,但由于区域更小,暂停时间更短。
G1 还引入了字符串去重(String Deduplication) :GC 并发扫描 String 对象,若发现两个 String 内容相同、却各自引用不同的 char[],就让它们共享同一个 char[],从而让另一个 char[] 可被回收,减少内存占用。该特性需要通过 -XX:+UseStringDeduplication 启用。
与 CMS 类似,G1 尽可能并发地进行回收;当并发回收不足以跟上应用分配速度时,仍需要暂停应用线程。
G1 是高性能、内存较大系统的首选 GC。不过它也不是最新的,我们再看 ZGC。
ZGC(Z Garbage Collector)
Java 15 提供了生产可用的 ZGC。它几乎全并发,每次暂停不超过 10 ms。
ZGC 首先标记存活对象,但不维护额外“映射表”,而是使用引用着色(reference coloring) :把对象存活等元数据状态编码在引用位上。由于需要额外的比特位,ZGC 仅支持 64 位系统,不支持 32 位。
为避免碎片化,ZGC采用重定位(relocation) ,并与应用并行执行,以控制停顿在 10 ms 以内。但这会带来一个潜在问题:若你正通过旧引用访问对象,而对象在此刻被重定位了,旧地址可能已被覆盖或清空,调试将一团糟。
为此,ZGC 引入了加载屏障(load barriers) :每当从堆中加载引用时,都会检查其元数据位,并在必要时做额外处理后再取到真实对象。这套机制称为重映射(remapping) 。
以上五种 GC 是当前最主要的选择。能用哪种、效果如何,取决于 Java 版本、系统配置与应用类型。要让 GC 发挥良好表现,必须做好监控。下一节我们就来看看如何监控。
监控 GC
要选择合适的垃圾收集器,你需要充分了解自己的应用。有几项与 GC 尤其相关的指标:
- 分配速率(Allocation rate) :应用在内存中分配对象的速度。
- 堆驻留量(Heap population) :当前驻留在堆上的对象数量及其大小。
- 引用变更率(Mutation rate) :内存中引用被更新的频率。
- 对象平均存活时间(Average object live time) :对象平均能存活多久。不同应用的对象寿命分布可能完全不同——有的“朝生暮死”,有的则更长寿。
评估 GC 性能需要关注另一组指标:标记时间、压缩时间与GC 周期时间。
- 标记时间(mark time) :GC 找出堆上所有存活对象所花的时间。
- 压缩时间(compaction time) :GC 释放空间并搬迁(重定位)对象所需的时间。
- GC 周期时间(GC cycle time) :一次完整 GC(full GC)所需的总时间。
当可用堆空间偏少时,你通常会看到 GC 的 CPU 占用上升。合理配置内存容量能显著提升应用性能;内存越充裕,GC 的工作就越轻松。
对于复制-压缩型收集器(copy-and-compact collector) ,必须有足够的可用空间来完成复制与重定位。在内存吃紧时,这一过程代价更高:它可能只能先复制一小块,腾出一点空间,下次再多复制一点,如此反复。总体而言,内存紧张时 GC 的 CPU 开销会最高。极端地讲,如果我们拥有无限内存,几乎就不需要做垃圾回收了。
在第 6 章,我们将学习如何通过 JVM 调优来改进内存管理,并且看看如何调优垃圾收集器。
总结
本章更深入地讲解了堆上 GC 的工作机制:当堆中对象不再与栈(直接或间接)相连时,就符合回收条件。
GC 在标记阶段确定可达对象:与栈相连的对象会被标记;符合回收条件的对象则保持未标记。
标记之后进入清扫阶段实际释放内存。我们讨论了三种清扫方式:普通清扫、清扫并压缩、以及清扫并复制。
随后介绍了不同的 GC 实现,其中一部分是分代式收集器——它们聚焦堆的某一代,从而在标记阶段无需扫描整个堆。在此基础上,我们讲解了五种常见的 GC 实现。
下一章,我们将把镜头聚焦到 Metaspace。