跳到主要内容

JEP 475: G1 的后期屏障扩展

QWen Max 中英对照 JEP 475: Late Barrier Expansion for G1

概述

通过将 G1 垃圾收集器的屏障(记录应用程序内存访问信息)的扩展从 C2 JIT 编译管道的早期移到后期,来简化其实现。

目标

  • 减少使用 G1 收集器时 C2 的执行时间。

  • 使 G1 屏障对缺乏深入了解 C2 的 HotSpot 开发人员易于理解。

  • 确保 C2 保持关于内存访问、安全点和屏障的相对顺序的不变性。

  • 在速度和大小方面保持 C2 生成代码的质量。

非目标

  • 不打算将 G1 当前的早期屏障扩展保留为一种遗留模式。切换到晚期屏障扩展应该是完全透明的,除了降低 C2 开销的效果之外,因此不需要这样的模式。

动机

基于云的 Java 部署越来越流行,这使得减少整体 JVM 开销成为了一个更受关注的问题。JIT 编译是一种加速 Java 应用程序的有效技术,但它在处理时间和内存使用方面会产生显著的开销。这种开销对于优化型 JIT 编译器(如 JDK 的 C2 编译器)来说尤其明显。初步实验 表明,像目前这样提前扩展 G1 屏障会增加 C2 的开销,具体取决于应用程序,大约增加 10-20%。考虑到 G1 屏障在 C2 的中间表示(IR)中由超过 100 个操作组成,并且会导致约 50 条 x64 指令,这一点并不令人惊讶。减少这种开销是使 Java 平台更适合云计算的关键。

另一个导致JVM开销的主要因素是垃圾收集器。作为半并发的分代垃圾收集器(GC),G1 与 JIT 编译器接口,在应用程序内存访问中插入屏障代码。对于 C2 而言,维护和更新这一接口需要深入了解 C2 的内部结构,而掌握这些知识的 GC 开发者很少。此外,一些屏障优化需要应用底层转换和技术,这些无法用 C2 的中间表示来表达。这些障碍减缓了 G1 关键方面的演进和优化,甚至直接阻碍了这些过程。将 G1 屏障插桩与 C2 内部结构解耦,可以使 GC 开发者通过算法改进和底层微优化进一步优化并减少 G1 的开销。

C2 使用 sea of nodes 中间表示(IR)将 Java 方法编译为机器代码。这种 IR 是一种程序依赖图,为编译器在调度机器指令方面提供了很大的自由度。虽然这简化并增加了许多优化的范围,但它使得保持关于指令相对顺序的不变量变得困难。在 G1 屏障的情况下,这导致了诸如 82421158295066 这样的复杂错误。我们不能保证不存在其他类似的问题。

早期的实验和对 C2 生成代码的手动检查表明,C2 为实现屏障而生成的指令序列与字节码解释器用于检测内存访问的手写汇编代码相似。这表明 C2 优化屏障代码的空间有限,如果将屏障实现细节对 C2 隐藏,并且仅在编译管道的最后阶段展开,则可以生成质量相似的代码。

描述

早期屏障扩张

目前,在编译一个方法时,C2 会将其屏障操作与其原始操作混合在其节点海 IR 中。C2 在其编译管道的开始处将字节码解析为 IR 操作时,会展开每个内存访问的屏障操作。G1 和 C2 的特定逻辑通过堆访问 API(JEP 304)指导此展开过程。一旦屏障被展开,C2 将通过其管道统一转换和优化所有操作。如下图所示,其中 IR<C,P> 表示特定于收集器 C 和目标操作系统/处理器架构平台 P 的 IR:

在编译流水线早期展开 GC 屏障有两个潜在优势:

  • 相同的 GC 屏障实现,可以表示为 IR 操作,并在所有平台上重用,以及

  • C2 可以在整个方法的作用域内优化和转换屏障操作,从而可能提高代码质量。

然而,由于两个原因,实际上的好处是有限的:

  • 平台特定的 G1 屏障实现无论如何都必须为其他执行模式(如字节码解释)提供,以及

  • 由于控制流密度、内存操作顺序限制等因素,G1 屏障操作不太适合优化。

早期扩展模型有三个具体且显著的缺点,已在上面提到:它会带来大量的 C2 编译开销,对 GC 开发者来说是不透明的,并且很难保证不会出现屏障顺序问题。

晚期屏障扩展

因此,我们建议在 C2 的编译流水线中尽可能晚地扩展 G1 屏障,将其从字节码解析一直推迟到代码生成阶段,即当 IR 操作被转换为机器代码时。这在下图中用与上述相同的符号表示:

具体来说,我们按如下方式实现延迟屏障扩展:

  1. 在字节码解析期间生成的 IR 内存访问被打上了生成其屏障代码所需的信息标签。这些信息不会暴露给 C2 的分析和优化机制。

  2. 指令选择将抽象的内存访问操作替换为特定于平台和垃圾收集器的指令,但屏障仍然是隐式的。此时会插入特定于垃圾收集器的指令,例如,确保寄存器分配器为屏障操作保留足够的临时寄存器。

  3. 最后,在代码生成期间,每个特定于垃圾收集器的内存访问指令根据其标记的屏障信息转换为机器代码。该代码由平台特定的内存指令组成,并被屏障代码包围。屏障代码使用字节码解释器的屏障实现生成,并通过实现从屏障到 JVM 的调用的汇编存根例程进行增强。

ZGC 是 JDK 中的一种替代的全并发收集器,它从 JDK 14 开始成功地使用了这种设计。事实上,我们认为延迟屏障扩展是 ZGC 在 JDK 15 (JEP 377) 中被认为可以投入生产使用的稳定性要求的先决条件。

对于 G1 中的延迟屏障扩展,我们重用了为 ZGC 开发的许多机制,例如对堆访问 API 的扩展以及在屏障代码中执行 JVM 调用的逻辑。我们还重用了已经存在的汇编级屏障实现,这些实现在所有平台上都支持字节码解释。这些实现是用(伪)汇编代码表示的,这是所有 HotSpot 开发人员熟悉的抽象级别。

候选优化

初步实验表明,即使是没有经过任何优化的简单的 late barrier expansion 实现,其性能已经接近 C2 优化后的代码。然而,要完全弥合性能差距,仍然需要采用 C2 当前应用的一些关键优化。作为这项工作的一部分,或者可能在后续工作中,我们将在 late barrier expansion 的背景下重新评估这些优化,并重新实现那些在应用程序级别具有明显性能优势的优化。

我们考虑的优化集中在写操作的屏障上,即形式为 x.f = y 的操作,其中 xy 是对象,f 是一个字段。初步实验表明,这些操作占所有执行的 G1 屏障的大约 99%。写屏障由支持并发标记的预屏障和支持将堆区域划分成代的后屏障组成。

  • 移除对新对象写入的障碍 —— 只要分配和写入之间没有 safepoint,对新分配的对象进行写入就不需要障碍。目前,当 C2 检测到这种模式时,会积极地移除写入障碍。我们可以通过在写操作附加的信息中标记这种情况,然后在代码生成时省略相应的障碍代码,从而为后期障碍扩展实现相同的优化。

  • 基于空值信息简化障碍 —— C2 通常可以保证存储在内存写入中的对象指针 (y) 是空值或非空值。这可以直接从原始字节码中得出,也可以由 C2 的类型分析推断出来。代码生成可以利用这些信息来简化甚至移除后置障碍,并且在启用对象指针压缩和解压缩模式时简化该过程。

    目前,C2 通过其通用的平台无关分析和优化机制无缝实现了此类简化。我们可以通过根据 C2 类型系统提供的信息显式跳过不必要的障碍以及对象指针压缩和解压缩指令的生成,为后期障碍扩展实现同样的简化。初步实验表明,大约 60% 的执行写入后置障碍可以通过此技术简化或移除。

  • 移除冗余的解压缩操作 —— 当启用了对象指针压缩和解压缩时,内存写入存储一个压缩后的指针,但障碍作用于未压缩的指针上。当前,C2 对写入及其障碍的全局分析和优化通常产生单个压缩操作以使两种版本的对象指针可用。后期障碍扩展的一个简单实现将为每次写入生成一个压缩和一个解压缩操作。我们可以通过插入与 IR 中的压缩和写入操作匹配的压缩-并-写伪指令来移除冗余的解压缩操作。在这些伪指令的作用范围内,我们使用单一的压缩操作使压缩和未压缩两种版本的对象指针都可用,达到与 C2 当前优化相同的效果。

  • 优化障碍代码布局 —— 前置障碍和后置障碍都会测试是否实际需要障碍;如果需要,障碍会调用 JVM 通知收集器写入操作。初步实验、早期研究最初的 G1 论文 表明,在实践中很少需要障碍,因此大量障碍代码不经常执行。目前,C2 自然地将不常需要的障碍代码放置在主执行路径之外,提高了代码缓存效率。我们可以通过手动将障碍实现拆分为频繁部分和不频繁部分,并在汇编存根中扩展后者,为后期障碍扩展实现同样的效果。

替代方案

GC 屏障可以在 C2 编译流水线的几个不同点进行扩展:

  • 在字节码解析(早期屏障展开)时:在最初构建 IR 时展开 GC 屏障。

  • 在平台无关优化之后:在循环转换、逃逸分析等之后展开 GC 屏障。

  • 在指令调度之后:在选择和调度特定于平台的指令之后,但在分配寄存器之前展开 GC 屏障。(目前,在这一级别上不支持展开。)

  • 在寄存器分配之后:在寄存器分配和最终的 C2 转换之间展开 GC 屏障。

  • 在代码生成时(晚期屏障展开):在将 IR 指令翻译成机器码时展开 GC 屏障。

这些点各自提供了不同的权衡,包括 C2 开销、所需的 C2 知识、遭受指令调度问题的风险以及所需的特定平台工作。

  • 通常,屏障扩展得越晚,C2 开销就越低。最大的节省是在将屏障扩展从字节码解析移动到平台无关优化之后,然后再移动到寄存器分配之后。

  • 在除代码生成之外的任何扩展点工作的开发人员都需要大量的 C2 知识。

  • 在字节码解析和平台无关优化后扩展屏障不需要特定于平台的支持,但这确实存在触发指令调度问题的风险。

  • 如此处提出的,在代码生成时扩展屏障是开销最低的选择,也是唯一一个不需要 C2 特定开发知识的选择。与其他所有依赖于平台的扩展点一样,它具有避免指令调度问题的优点,也具有需要为每个平台付出实现努力的缺点。

下表总结了每个扩展点的优缺点:

扩展点优点缺点

扩展点

C2 开销

需要 C2 知识

控制调度

平台无关

在字节码解析时(早期)

在平台无关优化之后

在指令调度之后

在寄存器分配之后

在代码生成时(晚期)

设计空间的另一个维度是暴露给 C2 的屏障实现的粒度。对于 ZGC,我们尝试使用单个 IR 操作来表示屏障,以及相应的内存访问操作,但得出的结论是,即使这种较粗略的表示方式也有可能导致指令调度问题。鉴于两种垃圾收集器在调度问题上的相似性,这个结论也可能适用于 G1。

测试

为了降低引入功能故障的风险,我们将结合

  • 基于广泛且已经可用的JDK测试套件在不同配置下由Oracle内部测试系统执行的常规测试,

  • 新增测试以运行目前尚未充分覆盖的情况,以及

  • 编译器和GC压力测试以运行可能被忽略的罕见代码路径和条件。

为了降低性能退化的风险,我们将使用一套行业标准的 Java 基准测试在不同平台上评估新实现。

要测量和比较编译速度和代码大小,我们将使用 HotSpot 选项 -XX:+CITime 提供的功能。为了控制不同 JVM 运行之间的已编译方法的大小和范围的变化,我们将测量每个基准测试运行的多次迭代,并使用 -Xbatch 等 JVM 选项使每次运行更具确定性。

风险和假设

  • 与任何影响核心JVM组件交互的变更一样——在这种情况下,是G1垃圾收集器和C2编译器——引入可能导致故障和性能回退的bug的风险是不可忽视的。为了降低这种风险,我们将进行内部代码审查,并进行广泛的测试和基准测试,超出上述常规增强和修复bug活动所进行的通常活动。

  • 在G1的上下文中,不精确卡片标记是一种优化,它避免了对同一对象不同字段的连续写入操作进行多次JVM调用。基于早期屏障扩展的此优化当前实现并没有显示出显著的应用级性能优势。因此我们认为不需要为后期屏障扩展实现不精确卡片标记来匹配当前生成代码的质量。然而,我们在这里的工作可能会在未来实现更有效的不精确卡片标记。