跳到主要内容

JEP 439: 分代 ZGC

QWen Max 中英对照 JEP 439: Generational ZGC

总结

通过扩展 Z 垃圾收集器(ZGC)以维护年轻对象和老年代对象的独立[分代](https://en.wikipedia.org/wiki/Tracing_garbage_collection#Generational_GC_\(ephemeral_GC\)),从而提升应用程序性能。这将使 ZGC 能够更频繁地收集通常“早逝”的年轻对象。

目标

使用 Generational ZGC 运行的应用程序应该会享受到

  • 降低分配暂停的风险,
  • 降低所需的堆内存开销,以及
  • 降低垃圾回收的 CPU 开销。

这些好处应该是在与非分代 ZGC 相比没有显著的吞吐量减少的情况下实现的。非分代 ZGC 的基本特性应该得到保留:

  • 暂停时间不应超过 1 毫秒,
  • 应支持从几百 MB 到许多 TB 的堆大小,并且
  • 应该只需要最少的手动配置。

作为最后一点的示例,应该不需要手动配置。

  • 代的大小,
  • 垃圾收集器使用的线程数,或者
  • 对象应在年轻代中存在多长时间。

最后,对于大多数使用场景而言,分代 ZGC 应该是非分代 ZGC 的更好解决方案。为了降低长期维护成本,我们应该最终能够用前者取代后者。

非目标

在年轻代中执行引用处理并不是目标。

动机

ZGC(JEP 333)专为低延迟和高可扩展性而设计。自 JDK 15(JEP 377)起,它已经可以用于生产环境。

ZGC 在应用程序线程运行的同时完成其大部分工作,仅短暂地暂停这些线程。ZGC 的暂停时间始终以微秒为单位进行测量;相比之下,默认垃圾收集器 G1 的暂停时间范围从毫秒到秒不等。ZGC 的低暂停时间与堆大小无关:工作负载可以使用从几百 MB 到多个 TB 的堆大小,同时仍然保持较低的暂停时间。

对于许多工作负载,简单地使用 ZGC 就足以解决所有与垃圾回收相关的延迟问题。只要有足够的资源(即,内存和 CPU)可用,确保 ZGC 能够比并发运行的应用程序线程消耗内存更快地回收内存,这种方法就能很好地工作。然而,ZGC 当前会将所有对象一起存储,而不管其存活时间长短,因此它每次运行时都必须回收所有对象。

弱分代假设 指出,年轻的对象往往早逝,而年老的对象则倾向于长期存在。因此,收集年轻的对象需要较少的资源并释放更多的内存,而收集年老的对象则需要更多的资源且释放更少的内存。通过更频繁地收集年轻对象,我们可以提高使用 ZGC 的应用程序的性能。

描述

启用分代 ZGC

为了确保平稳过渡,我们将首先在非分代 ZGC 的基础上提供分代 ZGC。命令行选项 -XX:+UseZGC 将选择非分代 ZGC;要选择分代 ZGC,请添加 -XX:+ZGenerational 选项:

$ java -XX:+UseZGC -XX:+ZGenerational ...

在未来的发布版本中,我们打算将 Generational ZGC 设置为默认值,届时使用 -XX:-ZGenerational 将选择非世代 ZGC。在更晚的发布版本中,我们计划移除非世代 ZGC,届时 ZGenerational 选项将被废弃。

设计

Generational ZGC 将堆分为两个逻辑上的 年轻 代用于存放最近分配的对象,而 年老 代用于存放长寿对象。每一代都会独立进行收集,因此 ZGC 可以专注于收集收益较高的年轻对象。

与非分代 ZGC 一样,所有垃圾回收都是在应用程序运行时并发完成的,应用程序暂停时间通常短于 1 毫秒。由于 ZGC 在应用程序同时读取和修改对象图时必须小心,以确保给应用程序提供一个一致的对象图视图。ZGC 通过彩色指针加载屏障存储屏障来实现这一点。

  • 一个染色指针是指向堆中对象的指针,它除了包含对象的内存地址外,还带有编码对象已知状态的元数据。该元数据描述了对象是否已知为存活、地址是否正确等信息。ZGC 始终使用 64 位对象指针,因此可以容纳元数据位和对象地址,支持最大可达数 TB 的堆。当一个对象中的字段引用另一个对象时,ZGC 使用染色指针来实现该引用。

  • 加载屏障是 ZGC 在应用程序中注入的一段代码,这段代码会在应用程序读取引用另一个对象的对象字段时执行。加载屏障会解释存储在字段中的染色指针的元数据,并可能在应用程序使用被引用对象之前采取某些操作。

非分代 ZGC 同时使用了染色指针和加载屏障。分代 ZGC 还使用了存储屏障,以高效地跟踪从一个分代中的对象到另一个分代中的对象的引用。

  • 存储屏障(store barrier) 是 ZGC 在应用程序将引用存储到对象字段时,注入到应用程序的代码片段。分代 ZGC 为染色指针(colored pointers)添加了新的元数据位,以便存储屏障能够判断正在写入的字段是否已经被记录为可能包含跨代指针。染色指针使分代 ZGC 的存储屏障比传统的分代存储屏障更加高效。

添加存储屏障(store barriers)使得分代 ZGC 能够将标记可达对象的工作从加载屏障(load barriers)转移到存储屏障。也就是说,存储屏障可以利用彩色指针中的元数据位,高效地判断在存储之前字段先前引用的对象是否需要被标记。

将标记移出加载屏障(load barriers)使得优化它们变得更加容易,这一点很重要,因为加载屏障通常比存储屏障(store barriers)执行得更加频繁。现在,当加载屏障解释一个带有颜色的指针时,它只需要在对象被重定位的情况下更新对象地址,并更新元数据以表明该地址已知是正确的。随后的加载屏障会解析这些元数据,并且不再检查对象是否已被重定位。

Generational ZGC 在染色指针中使用了不同的标记和重定位元数据位集合,从而使得各代可以独立收集。

其余部分描述了区分 Generational ZGC 与非分代 ZGC 以及其他垃圾收集器的重要设计概念:

  • 无多映射内存
  • 优化的屏障
  • 双缓冲记忆集
  • 无需额外堆内存的重定位
  • 密集的堆区域
  • 大对象
  • 完全垃圾回收

无多映射内存

非分代 ZGC 使用 多映射内存 来减少加载屏障的开销。而分代 ZGC 则在加载和存储屏障中使用显式的代码。

对于用户而言,这项变更的主要优势在于能够更加轻松地测量堆所使用的内存量。在多映射内存的情况下,相同的堆内存会被映射到三个独立的虚拟地址范围中,因此像 ps 这样的工具报告的堆使用量大约是实际使用内存的三倍。

对于 GC 本身,这一变化意味着带颜色指针中的元数据位不再需要位于指针中与堆的可访问内存地址范围相对应的部分。这使得可以添加更多的元数据位,也开启了将最大堆大小增加到超过非分代 ZGC 的 16 TB 限制的可能性。

在 Generational ZGC 中,存储在对象字段中的对象引用被实现为带颜色的指针。然而,存储在 JVM 栈中的对象引用被实现为无颜色的指针,不包含元数据位,位于硬件栈或 CPU 寄存器中。加载和存储屏障会在带颜色的指针和无颜色的指针之间来回转换。

由于彩色指针永远不会出现在硬件堆栈或 CPU 寄存器中,因此只要能够高效地在彩色指针和无色指针之间进行转换,就可以使用更加复杂的彩色指针布局。Generational ZGC 使用的彩色指针布局将元数据放在指针的低位部分,而将对象地址放在高位部分。这样可以最大限度地减少加载屏障中的机器指令数量。通过对内存地址和元数据位进行精心编码,一条移位指令(在 x64 上)既可以检查指针是否需要处理,又可以去掉元数据位。

优化的屏障

随着存储屏障的引入以及加载屏障的新职责,更多的 GC 代码将与编译后的应用程序代码混合在一起。为了最大化吞吐量,这些屏障需要进行高度优化。Generational ZGC 的许多关键设计决策都涉及到彩色指针方案和屏障。

用于优化屏障的一些技术包括:

  • 快速路径与慢速路径
  • 最小化加载屏障职责
  • 记忆集屏障
  • SATB 标记屏障
  • 融合存储屏障检查
  • 存储屏障缓冲区
  • 屏障修补

快速路径与慢速路径

ZGC 将屏障分为两个部分。快速路径 检查在应用程序使用引用对象之前是否需要完成一些额外的 GC 工作。慢速路径 则执行这些额外的工作。所有对象访问都会运行快速路径检查。顾名思义,快速路径必须非常快,因此这部分代码会直接插入到即时编译的应用程序代码中。慢速路径仅在一小部分时间内会被触发。当慢速路径被触发时,所访问对象指针的颜色会被更改,这样后续对该指针的访问在一段时间内不会再次触发慢速路径。基于此,慢速路径的高优化程度变得不那么重要。为了便于维护,它们在 JVM 中被实现为 C++ 函数。

在非分代 ZGC 中,加载屏障就是这样拆分的。在分代 ZGC 中,同样的方案也适用于存储屏障及其相关的 GC 工作。

最小化加载屏障职责

在非分代 ZGC 中,加载屏障负责

  • 更新指向已被 GC 移动的对象的陈旧指针
  • 将已加载的对象标记为存活 —— 应用程序正在加载该对象,因此它被视为存活。

在 Generational ZGC 中,我们必须跟踪两代对象,并在带颜色指针和无颜色指针之间进行转换。为了最小化复杂性,并允许对加载屏障的快速路径进行优化,标记的责任被转移到了存储屏障上。

在 Generational ZGC 中,加载屏障(load barriers)负责

  • 从彩色指针中移除元数据位,并且
  • 更新指向垃圾回收器已重定位对象的过时指针。

存储屏障负责

  • 添加元数据位以创建彩色指针,
  • 维护记忆集,它跟踪从老年代到年轻代的对象指针,以及
  • 将对象标记为存活。

记忆集屏障

当分代 ZGC 收集年轻代时,它只访问年轻代中的对象。但是,老年代中的对象可以包含指向年轻代对象的字段,即从老年代到年轻代的指针。在年轻代收集期间,必须访问这些字段,原因有两点。

  • GC 标记根对象 —— 这样的字段可能包含唯一引用,保持部分年轻代对象图的可达性。GC 必须将这些字段视为对象图的根,以确保所有存活的对象都能被找到并标记为存活。

  • 老年代中的过期指针 —— 收集年轻代会移动对象,但指向这些对象的指针不会被立即更新。相反,当应用程序遇到这些指针时,会通过加载屏障(load barriers)进行惰性更新。在某个时刻,GC 必须更新应用程序未遇到的所有从老年代到年轻代的过期指针。

老年代到年轻代指针的集合被称为记忆集。记忆集包含了老年代中所有可能包含指向年轻代对象指针的内存地址。存储屏障会向记忆集中添加条目。每当一个引用被存储到对象字段时,它被认为可能包含一个老年代到年轻代的指针。存储屏障的慢路径会过滤掉存储到年轻代字段中的情况,因为只有老年代中的地址才是我们感兴趣的。慢路径不会根据写入字段的值进行过滤,因为该值可能引用的是年轻代或老年代。当垃圾回收(GC)使用记忆集时,它会检查对象字段的当前值。

所有这些确保了存储屏障在维护记忆集方面的一次性操作属性。这意味着,在两个连续的年轻代标记阶段之间,每个存储对象字段只会进入一次存储屏障慢路径。当一个字段首次被写入时,会发生以下步骤:

  • 快速路径检查存储字段中将被覆盖的值,
  • 颜色表明自上一个年轻代标记阶段以来该字段尚未被写入,因此
  • 采用慢速路径,
  • 存储字段的地址被添加到记忆集,并且
  • 新的指针值被着色并存储在字段中。

新的指针值会被着色,这样后续的快速路径检查就会发现该对象字段已经通过了慢速路径。

SATB 标记屏障

与非分代 ZGC 不同,分代 ZGC 使用一种开始时的快照(SATB)标记算法。在标记阶段开始时,GC 会对 GC 根对象进行快照;在标记阶段结束时,可以保证从那些根对象开始标记时可到达的所有对象都会被找到并标记为存活。

为了实现这一点,当对象图中对象之间的引用断开时,GC 需要得到通知。因此,存储屏障会将即将被覆盖的字段值报告给 GC;然后 GC 会标记被引用的对象,并访问和标记从该对象可到达的对象。

存储屏障只需要在标记周期内第一次存储字段时报告将被覆盖的字段值。后续对同一字段的存储只会替换垃圾回收器(GC)由于 SATB 特性而必然会找到的值。反过来,SATB 特性支持了存储屏障关于标记的一次性操作特性。

融合存储屏障检查

记忆集维护和标记功能的存储屏障之间有许多相似之处。两者都使用带颜色指针的快速路径检查及其各自的单次执行属性。我们并没有为每个条件分别设置快速路径检查,而是将它们融合为一个组合的快速路径检查。如果两个属性中有任何一个失败,则会采用慢速路径并完成所需的 GC 工作。

存储屏障缓冲区

将屏障拆分为快速路径和慢速路径,并使用指针着色,可以减少调用 C++ 慢速路径函数的次数。分代 ZGC 通过在快速路径和慢路径之间放置一个 JIT 编译的中速路径,进一步降低了开销。中速路径会将即将被覆盖的值和对象字段的地址存储在存储屏障缓冲区中,然后返回到已编译的应用程序代码,而无需采用昂贵的慢速路径。只有当存储屏障缓冲区满时,才会采用慢速路径。这摊销了从已编译的应用程序代码过渡到 C++ 慢速路径代码的部分开销。

屏障修补

加载屏障和存储屏障都会根据全局变量或线程局部变量中的值执行检查,当垃圾回收器(GC)进入新阶段时会更改这些值。在屏障中读取这些变量的方式有所不同,并且这样做的开销在不同的 CPU 架构上也有所不同。

在 Generational ZGC 中,我们通过尽可能修补屏障代码来减少这种开销。全局值被编码为屏障的机器指令中的立即数。这样就无需通过解引用全局变量或线程局部变量来获取当前值。当 GC 变换阶段后首次调用方法时(例如,当 GC 开始年轻代标记阶段时),这些立即数会被修补。这进一步减少了屏障的开销。

双缓冲记忆集

许多 GC 使用一种称为卡片表标记的记忆集技术来跟踪跨代指针。当应用程序线程写入对象字段时,它还会在称为卡片表的大字节数组中写入(即弄脏)一个字节。通常,表格中的一个字节对应于堆中跨越 512 字节的地址范围。为了找到所有从老年代到年轻代的对象指针,GC 必须定位并访问卡片表中脏字节对应的地址范围内的所有对象字段。

相比之下,分代 ZGC 通过使用位图精确记录对象字段位置,其中每个位代表一个潜在的对象字段地址。每个老年代区域都有一对记忆集位图。其中一个位图是活动的,并由运行其存储屏障的应用程序线程填充,而另一个位图则被 GC 用作所有记录的老年代对象字段的只读副本,这些字段可能指向年轻代中的对象。每次年轻代收集开始时,这两个位图会原子性地交换。这种方法的一个好处是应用程序线程不需要等待位图被清空。GC 处理并清空其中一个位图,而另一个位图则由应用程序线程并发填充。另一个好处是,由于这允许应用程序线程和 GC 线程在不同的位图上工作,因此无需在两种线程之间设置额外的内存屏障。其他使用卡表标记的分代收集器(例如 G1)在标记卡时需要内存屏障,这可能导致存储屏障性能更差。

无额外堆内存的重定位

其他 HotSpot GC 中的年轻代收集使用一种称为 scavenging(清除)的模型,在该模型中,存活对象会被发现并在单次遍历中被重新定位。在 GC 完全了解哪些对象存活之前,年轻代中的所有对象都必须被重新定位。使用这种模型的 GC 只有在所有对象都被重新定位后才能回收内存。因此,这些 GC 需要猜测存活对象所需的内存量,并确保在 GC 开始时有所需的内存可用。如果猜测错误,则需要执行代价更高的清理操作;例如,对未被重新定位的对象进行原地固定(in-place pinning),这会导致碎片化,或者在所有应用线程停止的情况下进行一次完整的 GC。

Generational ZGC 使用两个阶段:第一个阶段访问并标记所有可达对象,第二个阶段重定位已标记的对象。因为在重定位阶段开始之前,GC 拥有完整的活跃对象信息,因此可以按区域粒度对重定位工作进行分区。一旦某个区域中的所有活跃对象都被重定位出去(即该区域已被清空),该区域就可以被重新用作新目标区域以供重定位或供应用线程分配对象。即使没有更多空闲区域来重定位对象,ZGC 仍可以通过将对象压缩到当前已重定位的区域中继续执行。这使得 Generational ZGC 能够在不使用额外堆内存的情况下,对年轻代进行重定位和压缩。

密集堆区域

当将对象移出年轻代时,不同区域中的存活对象数量及其占用的内存大小会有所不同。例如,最近分配的区域可能包含更多的存活对象。

ZGC 会分析年轻代区域的密度,以确定哪些区域值得疏散,哪些区域要么太满要么疏散成本太高。未被选中进行疏散的区域则就地老化:这些区域中的对象保持在原位置,要么作为幸存区域保留在年轻代中,要么晋升到老年代。存活区域中的对象将获得第二次死亡机会,希望在下一次年轻代垃圾回收开始时,足够多的对象已经死亡,从而使更多这些区域符合疏散条件。

这种就地老化密集区域的方法减少了收集年轻代所需的工作量。

大型对象

ZGC 已经能够很好地处理大对象了。通过将虚拟内存与物理内存解耦并过度预留虚拟内存,ZGC 通常可以避开使用 G1 时有时会导致难以分配大对象的碎片化问题。

在 Generational ZGC 中,我们更进一步,允许在年轻代中分配大对象。鉴于区域可以在不重定位的情况下老化,就没有必要为了防止昂贵的重定位而在老年代中分配大对象。相反,如果它们是短生命周期的,就可以在年轻代中收集它们;如果它们是长生命周期的,则可以低成本地晋升到老年代。

完全垃圾回收

当收集老年代时,会有从年轻代对象指向老年代对象的指针。这些指针被视为老年代对象图的根。年轻代的对象经常发生变化,因此不跟踪从年轻代到老年代的指针。相反,这些指针是通过在老年代标记阶段运行年轻代收集来发现的。当这个年轻代收集找到指向老年代的指针时,会将它们传递给老年代标记过程。

这个额外的年轻代收集仍然会像正常的年轻代收集一样执行,并将存活的对象留在幸存区域中。这样做的一个效果是,年轻代中的存活对象不会受到在收集老年代时进行的引用处理和类卸载的影响。例如,某个应用程序释放了对某个对象图的最后一份引用,调用了 System.gc(),然后期望某些弱引用被清除或入队,或者某些类被卸载,这时就可以观察到这种效果。为了缓解这个问题,当应用程序代码显式请求进行垃圾收集时,会在老年代收集开始之前先进行一次额外的年轻代收集,以将所有存活的对象提升到老年代。

替代方案

更简单的屏障和指针着色方案

当前的加载和存储屏障实现较为复杂,不易理解。一个更简单的版本可能更容易维护,但会导致加载和存储屏障的开销增加。我们评估了大约十种不同的屏障实现;没有任何一种实现的性能能够比得上所选择的基于移位的加载屏障。继续研究和分析这种性能与复杂性之间的权衡仍然值得考虑。

继续使用多映射内存

通过使用利用多映射内存的更简单解决方案,可以跳过无色根方案。如果指针中需要比非分代 ZGC 更多元数据位,那么最大堆大小将受到限制。另一种方法是使用混合解决方案,其中一些位使用多映射内存,其他位则通过加载和存储屏障进行移除和添加。

测试

ZGC 实现使用了不同的 C++ 类型来表示无颜色指针和有色指针,这确保了两种类型之间不会发生隐式转换。有色指针的使用被限制在垃圾回收(GC)代码和屏障(barriers)中。只要运行时系统使用 HotSpot 的访问 API 和屏障来访问对象指针,它将只能看到可解引用的无颜色指针。运行时可见的对象指针类型将始终包含无颜色指针。我们在不同的对象指针类型中注入了大量的验证代码,以便在指针损坏或屏障缺失时快速发现问题。

  • 垃圾收集算法的标准测试集将用于证明其正确性。

风险与假设

实现复杂性

Generational ZGC 中使用的屏障和有色指针比非分代 ZGC 中的更复杂。Generational ZGC 还同时运行两个垃圾收集器;这些收集器相当独立,但它们之间也有一些复杂的交互方式,增加了实现的复杂性。

考虑到额外的复杂性,从长远来看,我们打算通过用分代 ZGC 完全替换原来的非分代版本的 ZGC 来尽量降低维护成本。

分代 ZGC 的表现将不同于非分代 ZGC

我们相信,相对于其前身,Generational ZGC 将更适合大多数使用场景。由于资源使用率较低,某些工作负载甚至可能会体验到吞吐量的提升。例如,在运行 Apache Cassandra 基准测试时,Generational ZGC 所需的堆大小是非 Generational ZGC 的四分之一,但吞吐量却达到了后者的四倍,同时仍然将暂停时间保持在一毫秒以下。

一些工作负载本质上是非分代的,可能会看到轻微的性能下降。我们认为,这是一组足够小的工作负载,不值得长期维护两个单独的 ZGC 版本的成本。

另一个开销来源是更强大的 GC 屏障。我们预计大部分开销会被减少对老年代对象的频繁收集所带来的收益所抵消。

另一个额外的开销来源是同时运行两个垃圾收集器。我们需要确保平衡它们的调用率和 CPU 消耗,以便它们不会对应用程序产生不当影响。

对于 GC 开发而言,未来改进和优化将由基准测试和用户反馈驱动,这是非常正常的。我们打算在第一个版本发布之后继续改进 Generational ZGC。