HotSpot 低延迟垃圾收集器详解

HotSpot 的垃圾收集器经历了二十余年的发展,从最初的 Serial 逐渐演化到 CMS,再到后来的 G1。这些经典的垃圾收集器在成千上万台服务器的应用实践中已经变得相当成熟。然而,它们离达到"完美"仍有一段距离。

垃圾收集器的性能可以通过以下三个指标进行评估:

  1. 内存占用 (Footprint):指垃圾收集器在进行垃圾回收过程中额外占用的内存量。这个指标用于衡量垃圾收集器对系统资源的消耗程度。
  2. 吞吐量 (Throughput):宏观上表示用户线程运行时间占总时间的比值。吞吐量与延迟指标有关,但并非简单的线性关系。
  3. 延迟 (Latency):指垃圾回收导致的线程停顿时间。较低的延迟意味着垃圾收集器可以更快地完成回收过程,并且应用程序的响应更迅速。延迟是衡量垃圾收集器对应用程序交互性的关键指标。

这三个指标共同形成了一个“不可能三角”。随着技术的不断进步,这些指标的整体表现会逐渐提升,但要在所有方面达到极致几乎是不可能的。一款出色的垃圾收集器通常只能在其中一两个方面展现出卓越的性能。

随着计算机硬件的不断发展,内存占用、吞吐量和延迟这三个指标中,延迟指标的重要性日益凸显。原因如下:

  • 首先,随着内存价格的下降,企业可以更轻松地为服务器配置更大容量的内存。因此,用户程序对垃圾收集器的内存占用越来越不敏感。
  • 其次,随着 CPU 性能的提升,CPU 可以更快地完成垃圾回收操作,从而减少了对用户程序执行时间的影响,提高了整体吞吐量。
  • 然而,延迟指标却不会与硬件的提升呈严格的正相关,尽管 CPU 性能的提升可以在一定程度上降低延迟,但增加内存容量却会对延迟产生负面影响。例如,完全回收 128GB 堆内存所需的时间肯定比完全回收 1GB 内存所需的时间更长。因此,较大的内存容量反而给实现低延迟带来了困难。

所以,新一代的垃圾收集器越来越重视延迟指标的优化。现在我们观察一下常见的垃圾收集器的停顿情况:

各垃圾收集器的停顿情况

可以发现,除了初始标记和最终标记这些阶段需要短暂的停顿之外,Shenandoah 和 ZGC 这两款收集器的工作过程基本上都是并发完成的。而这些短暂的停顿时间基本上是固定的,并不会随着堆容量和堆中对象数量的增加而线性增长。因此,这两款收集器在任意大小的堆容量下都能够实现可控的停顿时间,业界也因此将它们命名为"低延迟垃圾收集器"。

Shenandoah 收集器

注意:Shenandoah 收集器目前仍在不断迭代,因此随着时间的推移,下文中的内容可能会过时。

Shenandoah 收集器是由 Red Hat 牵头开发的开源低延迟收集器,从 JDK12 开始引入,目前仅在 OpenJDK 中提供 1。该收集器的设计目标是在任何堆大小下将垃圾收集的停顿时间限制在 10 毫秒以内。与 CMS 和 G1 相比,Shenandoah 不但实现了标记过程的并发,还实现了整理过程的并发。

从代码的历史渊源来看,相比于 Oracle 自家的 ZGC,Shenandoah 反而更像是 G1 收集器的下一代继承者。它们在堆内存布局上有相似之处,并且在初始标记、并发标记等阶段的处理思路上高度一致,甚至还共享了一部分代码。这种关系使得一些对 G1 的改进和 Bug 修复也反映在了 Shenandoah 上,同时 Shenandoah 研发的一些新特性也反哺了 G12

Shenandoah 收集器的特性

Shenandoah 相比 G1 收集器,有以下三个重要特性:

  • 支持并发整理算法:Shenandoah 实现了回收过程与用户程序的并发执行。这意味着 Shenandoah 能够在进行垃圾回收的同时,不中断应用程序的正常运行;

  • 没使用显式的分代收集:Shenandoah 采用了一种更统一的方式来管理整个堆内存,从而简化了垃圾收集器的设计和实现,降低了收集器的复杂性;

    分代收集实现难度较大,如果将来 Shenandoah 实现了分代收集,性能会更高。

  • 弃用了记忆集:上文讲过,G1 的双向记忆集至少需要额外占用 Java 堆容量的 10% 至 20%,而且维护时需要耗费大量的计算资源。而 Shenandoah 采用了连接矩阵这一全局数据结构来记录跨 Region 的引用关系,从而减少了对计算资源和内存的消耗。

    连接矩阵

    连接矩阵可以简单的理解为一张二维表格,如果 Region N 有对象指向 Region M,就在表格的 N 行 M 列中打上一个标记,如下图所示:

    连接矩阵

    如果 Region5 中的对象引用了 Region3 中的对象,Region3 中的对象又引用了 Region1 中的对象,那连接矩阵中的 (5, 3)、(3, 1) 就会被打上标记。在回收时遍历该矩阵便可获悉哪些 Region 存在跨区引用。

Shenandoah 收集器的工作过程

Shenandoah 收集器的工作过程可以分为以下九个阶段:

  1. 初始标记 (Initial Marking):与 G1 相似,标记与 GC Roots 直接关联的对象。这个阶段需要短暂的停顿,但停顿时间与堆大小无关,仅与 GC Roots 的数量相关。

  2. 并发标记 (Concurrent Marking):与 G1 一样,遍历对象图,标记所有可达的对象。这个阶段与用户线程并发执行,耗时取决于存活对象的数量和对象图的复杂性。

  3. 最终标记 (Final Marking):与 G1 一样,处理剩余的原始快照 (STAB),并统计具有较高回收价值的 Region,形成回收集合 (Collection Set)。这个阶段会有短暂的停顿。

  4. 并发清理 (Concurrent Cleanup):清理整个区域内没有任何存活对象的 Region (Immediate Garbage Region)

  5. 并发整理 (Concurrent Envacuation):这是 Shenandoah 与传统收集器的核心区别。在这个阶段,Shenandoah 需要将回收集合中的存活对象复制到未使用的 Region 中。

    在冻结用户程序的情况下,对象的复制过程相对简单。但要实现与用户程序并发执行就比较困难了:在移动对象时,用户线程仍然可以读写被移动的对象,并且在对象移动后,对象的引用仍然指向旧地址,因此这个过程存在一致性问题。Shenandoah 引入了转发指针 (Forwarding Pointers)引用访问屏障 (Load Reference Barrier) 来确保一致性。

    并发整理阶段的执行时间取决于回收集的大小。

  6. 初始引用更新 (Initial Update Reference):并发整理阶段完成对象复制后,需要将堆中所有指向旧对象地址的引用更新为复制后的新地址。但初始引用更新阶段只是设立了一个线程同步点,确保所有并发整理线程都已完成任务,所以这个阶段的停顿时间很短暂。

  7. 并发引用更新 (Concurrent Update Reference):真正开始引用更新操作。这个阶段与用户线程并发执行,耗时取决于涉及的引用数量。与并发标记不同,这个阶段无需按对象图搜索引用,只需按内存地址线性搜索引用并将旧值更新为新值即可。

  8. 最终引用更新 (Final Update Reference):解决了堆中的引用更新后,还需要修正 GC Roots 中的引用。这是 Shenandoah 的最后一次停顿,停顿时间仅与 GC Roots 的数量相关。

  9. 并发清理 (Concurrent Cleanup):经过并发整理和引用更新,回收集合中的所有 Region 都变为 Immediate Garbage Region,因此再次进行并发清理即可。

Shenandoah 实现并发整理的核心原理

上文提到,Shenandoah 通过转发指针 (Forwarding Pointer) 和引用访问屏障 (Load Reference Barrier) 确保了标记整理线程与用户线程的并发执行的一致性。

转发指针 (Forwarding Pointer)

实现转发操作最常用的方法是为被移动对象的原内存设置内存保护陷阱 (Memory Protection Trap)。当用户程序访问旧对象的内存空间时,会触发自陷中断,将访问转发到复制后的新对象上。如果能够合理利用硬件特性和操作系统机制,那么内存陷阱也是一种绝佳方案,业界公认的顶尖收集器 Azul C4 就采用了这种方案。然而,这种方法实现难度较大,对硬件和操作系统的要求也很高。

Shenandoah 选择了转发指针这种纯软件方案,绕过了对特定硬件和操作系统的依赖。它在原有对象布局结构的最前面添加了一个新的引用字段,在非并发移动的情况下,该引用指向对象本身,如下图所示:

1
2
3
4
5
6
7
8
+----------------------+
|  Forwarding Pointer  |--+
+----------------------+  |
|        Header        |<-+
+----------------------+
|         ...          |
|                      |
+----------------------+

当对象有新的副本时,修改指针的值,使其指向新的地址。只要旧对象的内存未被清理,所有通过旧引用地址进行的访问都会自动转发到新对象上继续工作。如下图所示:

1
2
3
4
5
6
7
8
9
         Old                          New
+--------------------+       +--------------------+
| Forwarding Pointer |------>| Forwarding Pointer |--+
+--------------------+       +--------------------+  |
|       Header       |       |       Header       |<-+
+--------------------+       +--------------------+
|        ...         |       |        ...         |
|                    |       |                    |
+--------------------+       +--------------------+

这个过程对用户程序来说是透明的,只是多了一层跳转的开销。

引用访问屏障 (Load Reference Barrier)

转发指针方案的关键在于确保旧对象和新副本内容的正确性和一致性。考虑以下情况:

  1. 收集器线程复制了新的对象副本。
  2. 用户线程更新了对象的字段,此时由于转发指针尚未偏转,因此修改的是旧对象。
  3. 收集器线程更新转发指针的引用值为新副本的地址。

如果没有任何保护措施,旧对象和新副本之间的内容就会不一致。因此,必须采取适当的同步机制。

早期的 Shenandoah 利用内存屏障 (Memory Barrier) 确保指针偏转过程的一致性。当标记整理线程修改对象引用或对象状态时,它会插入内存屏障,以确保这些修改对用户线程可见,并且保证用户线程的操作在这些修改之后执行,从而保持并发执行的一致性。然而,Java 作为一门面向对象的编程语言,会频繁地进行对象操作(包括对象比较、计算对象的哈希值以及对对象进行加锁等),覆盖面太广的屏障会严重影响性能。因此,在 JDK13 中,Shenandoah 改进了内存屏障模型,采用了基于引用访问屏障 (Load Reference Barrier) 的实现方式。

引用访问屏障是一种只拦截对象中引用类型数据读写的内存屏障,不会干扰原生数据类型等非引用字段的读写。但即便如此,常规的引用访问仍然存在屏障开销。实际上,当第一次被内存屏障拦截时,完全可以就地将当前引用更新到新的内存地址上,这样后续的访问就无需再进行拦截了。下文将要介绍的 ZGC 就是采用了这种方法。

ZGC 收集器

注意:ZGC 收集器目前仍在不断迭代,因此随着时间的推移,下文中的内容可能会过时。

ZGC 是由 Oracle 开发的一款低延迟垃圾收集器,从 JDK11 开始引入,它也采用了 Region 内存布局设计,JDK21 之前的版本没有实现分代收集。为了实现低延迟的目标,ZGC 利用了内存屏障、染色指针和内存多重映射等多种技术。

ZGC 收集器的内存布局

ZGC 与 Shenandoah 和 G1 一样,也采用了 Region3 堆内存布局,不过 ZGC 的 Region 可以在运行时动态的创建、销毁,甚至调整容量大小。ZGC 的 Region 容量可以分为以下三类:

ZGC 内存布局
  • 小型 Region (Smell Region):容量固定为 2MB,用于放置小于 256KB 的对象;
  • 中型 Region (Medium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象;
  • 大型 Region (Large Region):这类 Region 的容量是可变的,但必须是 2MB 的整数倍,主要用于存放 4MB 以上的大型对象。每个大型 Region 内只存储一个大对象,而且其内存空间不会被重新分配 (Relocate),因为复制一个大型对象的代价非常高昂。

ZGC 核心技术原理

染色指针技术

染色指针 (Colored Pointer) 是一种直接将少量元数据 4 存储在指针上的技术。在 64 位系统中,理论上可以访问的内存最高可达 16EB (2^64),但基于实际需求、寻址性能和经济成本的考量,现实中没有硬件平台会用满这 64 位寻址空间。以 AMD64 和 x86_64 架构为例,它们只支持 48 位(256TB)虚拟地址空间,因此目前主流的 64 位硬件平台实际能够支持的最大内存只有 256TB。

此外,出于实现方式的考虑,操作系统通常还会在此基础上做进一步的限制。例如,在 Linux x64 平台上,四级页表最多支持 47 位的虚拟地址空间(128TB),其余高位必须为 0。

但即便如此,对于绝大多数程序来说,47 位的地址空间仍然是过剩的。因此,ZGC 盯上了这部分空间,将元数据信息存到了高比特位中。在 JDK11 时期,ZGC 仅使用了这 47 位地址空间中的低 46 位,并将高 4 位用于存储元数据,这使得 ZGC 当时只支持最大 4TB (42bit) 的堆内存(当时项目刚起步,管理的内存太大了难以满足性能指标)。随着不断优化,在 JDK13 时期,ZGC 支持的最大堆内存扩展到了 16TB (43bit),接下来我们将以早期版本为例进行讲解。

ZGC 将指针的高地址位作为元数据区,其中的 Marked0、Marked1 和 Remapped 共同表示了物理内存空间的三个状态视图,且同一时刻只能有一个视图生效

ZGC 染色指针

通过这些元数据,JVM 可以直接从引用指针中了解到其引用对象的 三色标记等状态,详细过程如下:

  • ZGC 初始化后,整个内存空间的地址视图都被设置为 Remapped;
  • 在并发标记过程中:
    • 如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0;
    • 如果对象地址视图已经是 Marked0 了,说明该对象被其他标记线程访问过了,跳过即可;
    • 并发标记过程中用户线程新创建的对象会直接进入 Marked0 视图;
  • 并发标记结束后,如果对象地址视图是 Marked0,则说明对象活跃,如果是 Remapped,则是可被清理的。

多重内存映射技术

JVM 作为操作系统中的一个普通用户进程,并不能自由定义内存指针中特定位的含义 5,因此,常规的实现方法是在寻址前移除高位的元数据信息。伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 46 bit 的染色指针
ptr_with_metadata = ....;
 
// 低 42 位掩码
AddressBitsMask = 0x3FFFFFFFFFF;
// 取出低 42 位
address = ptr_with_metadata & AddressBitsMask
 
// 访问目标地址
use(*address)

为了便于理解,我们将染色指针简化为由 16 位地址和 1 位元数据组成。假设有两个指针 0x132100x23210,通过上述伪代码移除头部的元数据位,即可得到实际的对象地址都是 0x3210

1
2
3
4
5
6
7
Pointer value:     0x13210 : 0001 0011 0010 0001 0000
Metadata bits:     0x1     : 0001
Address bits:      0x3210  :      0011 0010 0001 0000

Pointer value:     0x23210 : 0010 0011 0010 0001 0000
Metadata bits:     0x2     : 0010
Address bits:      0x3210  :      0011 0010 0001 0000

然而,这种移除元数据位的操作会增加 CPU 指令数量,从而拖慢应用程序的运行速度。因此,ZGC 选择了通过多重内存映射技术 (Multi-Mapped memory) 来实现染色指针。

ZGC 将 64 位虚拟地址空间划分为多个子空间。当应用程序创建对象时,ZGC 首先在堆中申请一个虚拟地址,但该虚拟地址不会立即映射到实际的物理地址。接下来,ZGC 将 Marked0、Marked1 和 Remapped 这三个虚拟地址映射到同一个物理地址(这也是“多重”一词的由来),大小等于对象虚拟地址的大小,如下图所示:

64 位虚拟地址空间

最终的结果就是:Java 堆、Marked0、Marked1 和 Remapped 都指向了同一块物理内存,且 Marked0、Marked1 和 Remapped 作为这块内存的最高 bit 被使用,而 Java 堆中的地址则成为了这块物理内存中的偏移量。还是看上边的例子,假设有两个指针 0x132100x23210,我们将这两个指针的元数据位映射到同一块 16KB 的内存中:

1
2
3
4
5
6
7
8
9
+-----------+ 0x10000 -----+
| Mapping 1 |               \
|           |                +---> +------------------------+
|           |               /      |                        |
+-----------+ 0x20000 -----+       | 16 KB allocated memory |
| Mapping 2 |                      |                        |
|           |                      +------------------------+
|           |
+-----------+ 0x30000

那么,程序直接访问这两个地址时,就能实现对同一块物理内存的访问,因为 0x132100x23210 中的 0x3210 部分,都是相对于物理内存首地址的偏移量:

1
2
3
4
5
6
7
8
9
+-----------+ 0x10000
|           |
|        X  | 0x13210 -----+       +-----------------------+
|           |               \      |                       |
+-----------+ 0x20000        +---> | - X @ offset 0x3210 - | 
|           |               /      |                       |
|        X  | 0x23210 -----+       +-----------------------+
|           |
+-----------+ 0x30000

这样就巧妙且高效地实现了对物理地址的访问。

引用指针自愈技术

ZGC 收集器通过内存屏障和全局有序保证机制实现了引用指针自愈技术。其目的是解决在并发重分配期间可能出现的悬挂指针 (dangling pointers) 问题,即指针指向无效内存地址的情况。当用户程序首次访问已经被移动的非法地址时,ZGC 会利用预设的内存屏障将访问转发到正确的内存地址,并修复悬挂的指针,使其指向正确的新地址。

尽管指针自愈操作时的开销较大,但一旦指针被修复,后续的访问将直接指向新对象,无需再触发内存屏障。相比之下,Shenandoah 在每次访问对象时都需要经过内存屏障拦截,会产生固定的开销。

ZGC 收集器的工作过程

理解了染色指针的原理后,我们详细介绍下 ZGC 收集器的四个工作阶段:

  1. 并发标记 (Concurrent Marking):与 Shenandoah 收集器的初始标记、并发标记、最终标记一样,需要遍历对象图,完成可达性分析,这里我们将三个小阶段合到了一起。该阶段内部也要经历与 Shenandoah 一样的短暂停顿,但有一点不同:Shenandoah 收集器标记的是对象,而 ZGC 标记的是引用指针上的标志位。

  2. 并发预备重分配 (Concurrent Prepare for Relocate):该阶段会根据特定的模型统计本次收集过程中要清理的 Region,并将这些 Region 组成重新分配集合 (Relocation Set)

    Relocation Set 与 G1 的回收集合 (Collection Set) 有所不同,ZGC 划分 Region 并非为了进行收益优先的增量回收。相反,它每次回收都会扫描所有的 Region。通过大范围的扫描,ZGC 省去了维护记忆集的开销,从而整体降低了成本。

  3. 并发重分配 (Concurrent Relocate):重分配是 ZGC 的核心阶段,在这个过程中,ZGC 会将 Relocation Set 中存活的对象复制到新的 Region,并为 Relocation Set 中的每个 Region 维护一个转发表 (Forward Table),用于记录从旧对象到新对象的跳转信息。

    在染色指针技术的加持下,ZGC 收集器能够凭借引用准确地判断一个对象是否位于 Relocation Set 中(染色指针的 remapped 位被设置为 1)。当一个用户线程访问位于 Relocation Set 中的对象时,访问操作将会被内存屏障转移到新的对象上,并通过指针自愈操作更新引用。

  4. 并发重映射 (Concurrent Remap):这个阶段涉及修正整个堆中指向 Relocation Set 中旧对象的引用,与 Shenandoah 收集器的引用更新阶段的目标相似。

    然而,ZGC 的重映射并不像 Shenandoah 那样迫切需要完成,因为 ZGC 的指针具有"自愈"的能力。因此,ZGC 巧妙地将这个阶段的工作合并到下一次垃圾收集的并发标记阶段中进行。

    通过将引用关系修正与并发标记阶段合并,ZGC 可以直接复用并发标记阶段遍历得到的对象图,进一步节省了遍历开销。一旦引用关系修正完成,Relocation Set 中的转发表就可以被释放。这种巧妙的设计进一步提高了 ZGC 的性能和效率。

至此,HotSpot 垃圾收集器的主要实现就介绍完了。


  1. 由于竞争原因,它未被 Oracle 纳入官版 JDK,Oracle 主推的是 ZGC。 ↩︎

  2. 例如并发失败后“兜底”的 Full GC,G1 就是合并了 Shenandoah 研发期间的代码才获得了多线程 Full GC 的支持。 ↩︎

  3. Oracle 官方称其为 ZPage,不过本质一样。 ↩︎

  4. JVM 在运行过程中通常需要为对象添加一些描述其运行状态的元数据,例如用于完成可达性分析的三色标记、用于判断对象是否被标记整理操作移动过的标志等。维护这些元数据的常用方案有以下三种:

    1. 在对象头中添加额外字段:这是 Serial 等经典收集器采用的方案。它的缺点是如果想要了解对象的状态信息,就必须实际访问对象。如果对象被移动了,就会产生对象不可达问题。
    2. 将元数据存放到独立的内存区域中: 这是 G1 和 Shenandoah 收集器采用的方案。它们在堆中开辟一块独立的空间来存放 BitMap,用于维护所有对象的标记信息。这种方法避免了由于对象不可达而无法获悉对象状态的尴尬情况,但需要占用额外的空间。
    3. 使用染色指针 (Colored Pointer):这是最直接的方案。它将标记信息直接存放在引用指针上,只需访问引用指针即可直接了解对象的状态,无需访问对象本身,同时也能避免对象不可达的问题。
     ↩︎
  5. 一旦程序代码转换为机器指令流,处理器将把整个指针视为内存地址,而不会关注指针中哪些部分是标志位,哪些部分是实际的寻址地址。虽然 Solaris/SPARC 平台可以通过硬件层面的虚拟地址掩码忽略染色指针中的标志位,但 AMD64 平台上没有类似的技术。 ↩︎

0%