Hotspot 低延迟垃圾收集器

1 低延迟垃圾收集器简介

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

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

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

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

三项指标之间的关系与 CAP 理论模型类似。

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

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

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

各垃圾收集器的停顿情况

我们可以发现,Shenandoah 和 ZGC 这两款收集器几乎整个工作过程都是并发的。除了初始标记和最终标记这些阶段需要短暂的停顿之外,它们的工作过程基本上可以在并发执行的情况下完成。而这些短暂的停顿时间基本上是固定的,并不会随着堆容量和堆中对象数量的增加而线性增长。因此,这两款收集器在任意大小的堆容量下都能够实现可控的停顿时间

由于 Shenandoah 和 ZGC 收集器具备较低的停顿时间特性,官方将它们命名为"低延迟垃圾收集器"。

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

2 Shenandoah 收集器

Shenandoah 收集器是由 Red Hat 牵头开发的开源低延迟收集器,目前仅在 OpenJDK 中提供(由于竞争原因,它未被 Oracle 纳入官方支持)。该收集器的设计目标是在任何堆大小下将垃圾收集的停顿时间限制在 10 毫秒以内。与 CMS 和 G1 相比,Shenandoah 不但实现了标记过程的并发,还实现了整理过程的并发。

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

2.1 Shenandoah 收集器的特性

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

  • 支持并发整理算法:相较于 G1 收集器,Shenandoah 实现了回收过程与用户程序的并发执行。这意味着 Shenandoah 能够在进行垃圾回收的同时,不中断应用程序的正常运行,提供更好的响应性和用户体验;

  • 不使用显式的分代收集:与 G1 收集器不同,Shenandoah 在默认情况下不采用显式的分代收集概念。这意味着 Shenandoah 不会将堆内存划分为专门的新生代和老年代 Region。相反,它采用了一种更统一的方式来管理整个堆内存,从而简化了垃圾收集器的设计和实现,并且减少了收集器的复杂性;

    如果采用分代收集,理论上性能会更高,但实现难度较大,如果将来 Shenandoah 实现了分代收集,性能可能还会提高。

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

    连接矩阵

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

    连接矩阵

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

2.2 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):清理 Immediate Garbage Region,即整个区域内没有任何存活对象的 Region。

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

    在冻结用户程序的情况下,对象的复制过程相对简单。但要实现与用户程序并发执行就比较困难了:在移动对象时,用户线程仍然可以读写被移动的对象,并且在对象移动后,对象的引用仍然指向旧地址,因此这个过程存在一致性问题。Shenandoah 引入了转发指针 (Forwarding Pointers)内存屏障 (Memory 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,因此再次进行并发清理即可。

2.3 Shenandoah 收集器并发整理的核心实现

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

2.3.1 转发指针 (Forwarding Pointer)

1984 年,Rodney A. Brooks 在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为 Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。

在以前,要实现类似的转发操作,通常会在被移动对象原有的内存上设置保护陷阱 (Memory Protection Trap)。一旦用户程序访问到属于旧对象的内存空间,就会触发一个自陷中断,将控制权转移到预设的异常处理器中。异常处理器中的代码逻辑会将访问转发到复制后的新对象上。尽管这种方法可以实现对象移动与用户线程的并发执行,但如果没有操作系统层面的直接支持 2,就会导致用户态与内核态之间频繁切换,从而产生巨大的开销。

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       |<-+
+--------------------+       +--------------------+
|        ...         |       |        ...         |
|                    |       |                    |
+--------------------+       +--------------------+

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

2.3.2 内存屏障 (Memory Barrier)

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

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

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

在早期的实现中,转发指针方案通过使用内存屏障来确保指针偏转过程的一致性。当标记-整理线程修改对象引用或对象的状态时,它会插入内存屏障,以确保这些修改对用户线程可见,并且保证用户线程的操作在这些修改之后执行,从而保持并发执行的一致性。但作为一门面向对象的编程语言,Java 中的对象访问操作非常频繁,包括对象比较、计算对象的哈希值、对对象进行加锁等。为了覆盖所有的对象访问操作,Shenandoah 需要在大量的对象操作中设置内存屏障,并添加额外的转发处理。Shenandoah 的开发团队意识到,这些内存屏障带来的性能开销肯定会被人诟病。因此,在 JDK 13 中,Shenandoah 进行了内存屏障模型的改进,采用了基于引用访问屏障 (Load Reference Barrier) 的实现方式。

引用访问屏障是一种只拦截对象中引用类型数据的读写操作的内存屏障,而不干扰其它原生数据类型等非引用字段的读写。这样可以避免在处理原生类型、对象比较、对象加锁等场景时设置内存屏障所带来的开销,进而提高性能 3

3 ZGC 收集器

ZGC 是由 Oracle 开发的一款低延迟垃圾收集器,它采用了基于 Region 内存布局的设计,并且(暂时)没有分代的概念。为了实现低延迟的目标,ZGC 利用了多种技术,包括内存屏障、染色指针和内存多重映射等。

3.1 ZGC 收集器的内存布局

ZGC 与 Shenandoah 和 G1 一样,采用了基于 Region 的堆内存布局(在 ZGC 中被称为 ZPage)。然而,与其它两种收集器不同的是,ZGC 的 Region 具有动态性,可以在运行时动态创建、销毁甚至调整容量大小。

在 x64 平台下,ZGC 的 Region 容量可以分为以下三类:

ZGC 内存布局
  • 小型 Region (Smell Region):容量固定为 2MB,用于放置小于 256KB 的对象;

  • 中型 Region (Medium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象;

  • 大型 Region (Large Region):这类 Region 的容量是可变的,但必须是 2MB 的整数倍,主要用于存放 4MB 或更大的大型对象。

    每个大型 Region 只存储一个大型对象,因此实际上它们的大小可能甚至小于中型 Region。大型 Region 的特点是不会被重新分配 (Relocate),因为复制一个大型对象的代价非常高昂。

3.2 ZGC 收集器的特性

3.2.1 染色指针技术

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

  1. 在对象头中添加额外字段:这是 Serial 等经典收集器采用的方案。它的缺点是如果想要了解对象的状态信息,就必须实际访问对象。如果对象被移动了,就会导致对象不可达的问题。
  2. 将元数据存放到独立的内存区域中: 这是 G1 和 Shenandoah 收集器采用的方案。它们在堆中开辟一块独立的空间来存放 BitMap,用于维护所有对象的标记信息。这种方法避免了由于对象不可达而无法获悉对象状态的尴尬情况,但需要占用额外的空间。
  3. 使用染色指针 (Colored Pointer):这是最直接的方案。它将标记信息直接存放在引用指针上,只需访问引用指针即可直接了解对象的状态,无需访问对象本身,巧妙地避免了对象不可达的问题。

染色指针是一种直接将少量额外的信息存储在指针上的技术。在 64 位系统中,理论上可以访问的内存高达 16EB (2^64),但基于实际需求、寻址性能和经济成本的考量,基本上没有硬件平台会选择将 64 位寻址空间用满。以 AMD64 架构为例,它只支持 52 位 (4PB) 地址总线和 48 位 (256TB) 虚拟地址空间,因此目前主流的 64 位硬件平台实际能够支持的最大内存只有 256TB。此外,操作系统出于实现方法的考量,还会在此基础上施加自己的约束。例如,64 位 Linux 系统支持 47 位 (128TB) 的进程虚拟空间和 46 位 (64TB) 的物理地址空间,而 64 位的 Windows 系统甚至只支持 44 位 (16TB) 的物理地址空间。

尽管 Linux 系统的 64 位指针的高 18 位不能用来寻址,但剩余的 46 位指针所能够支持的 64TB 内存在今天仍然是绰绰有余的。因此,ZGC 的染色指针盯上了这剩下的 46 位指针宽度,将其高 4 位提取出来存储了 4 个标志信息。如下图所示:

ZGC 染色指针

通过这四个标志,JVM 可以直接从引用指针中了解到其引用的对象的三色标记、是否进入了重分配集合等状态。

然而,由于压缩了寻址空间,ZGC (在 JDK 11 版本) 只能管理最大 4TB 的内存空间。除此之外,染色指针还存在不支持 32 位系统、不支持指针压缩等限制。尽管存在这些限制,但相比其带来的优势,这些限制是可以接受的。

3.2.2 内存多重映射技术

尽管染色指针的原理相对简单,但在实现过程中需要解决一个关键问题:作为操作系统中的一个普通用户进程,JVM 无法自由定义内存指针中特定位的含义。一旦程序代码转换为机器指令流,处理器将把整个指针视为内存地址,而不会关注指针中哪些部分是标志位,哪些部分是实际的寻址地址。虽然 Solaris/SPARC 平台可以通过硬件层面的虚拟地址掩码忽略染色指针中的标志位,但 x86-64 平台上没有类似的技术。因此,ZGC 通过内存多重映射的方式来间接实现染色指针。

ZGC 将 64 位虚拟地址空间划分为多个子空间,然后将 Marked0、Marked1 和 Remapped 这三个虚拟地址映射到同一个物理地址上,如下图所示:

64 位虚拟地址空间

这三个虚拟内存空间分别代表 ZGC 的三个视图,但在任意时间点只有一个视图是有效的。ZGC 初始化后,整个内存空间的地址视图都被设置为 Remapped,在并发标记过程中,如果 GC 线程访问到对象,如果对象地址视图是 Remapped,就把对象地址视图切换到 Marked0,如果对象地址视图已经是 Marked0 了,说明该对象被其他标记线程访问过了,跳过即可。标记过程中用户线程新创建的对象会直接进入 Marked0 视图。标记过程中用户程序访问对象时,如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0。标记结束后,如果对象地址视图是 Marked0,则说明对象活跃,如果是 Remapped,则是不活跃的。

3.2.3 引用指针自愈技术

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

尽管指针自愈可能导致第一次访问时的额外开销较大,但一旦指针被修复,后续的访问将直接指向新对象,无需再触发内存屏障,从而提高性能。相比之下,Shenandoah 在每次访问对象时都需要经过内存屏障拦截,这会带来固定的开销。

3.3 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 的目的并非为了进行收益优先的增量回收,相反,ZGC 每次回收都会扫描所有的 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 的性能和效率。


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

  2. 如果能够合理利用操作系统内核机制,那么使用内存陷阱也是一种很不错的方法。业界公认最优秀的 Azul C4 收集器就使用了这种方案。 ↩︎

  3. 虽然将普通的内存屏障替换为引用访问屏障可以减少屏障的影响范围并提升性能,但对于常规的引用访问仍然存在屏障开销。实际上,在第一次被内存屏障拦截时,完全可以顺便将当前引用更新到新的内存地址上,这样后续的访问就无需再进行内存屏障拦截。这种方法正是下文将要介绍的 ZGC 所采用的策略。 ↩︎


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容