JEP 475: Late Barrier Expansion for G1 | G1 的后期屏障扩展
概述
通过将 G1 垃圾收集器屏障的扩展从 C2 JIT 编译管道的早期转移到后期,简化这些屏障的实现。这些屏障记录了关于应用程序内存访问的信息。
目标
- 减少在使用 G1 收集器时 C2 的执行时间。
- 使 G1 屏障对于缺乏深入了解 C2 的 HotSpot 开发者来说易于理解。
- 确保 C2 维护有关内存访问、安全点和屏障之间相对顺序的不变量。
- 在速度和大小两方面保持 C2 生成代码的质量。
非目标
- 不打算保留 G1 现有的早期屏障扩展作为遗留模式。除了降低 C2 开销的效果外,切换到晚期屏障扩展应该是完全透明的,因此不需要这样的模式。
动机
基于云的 Java 部署日益普及,这使得减少 JVM 整体开销的关注度增加。即时(JIT)编译是加速 Java 应用的有效技术,但它在处理时间和内存使用方面产生了显著的开销。对于像 JDK 的 C2 编译器这样的优化即时编译器而言,这种开销尤其明显。初步实验 表明,当前做法中过早地扩展 G1 屏障增加了大约 10-20% 的 C2 开销,具体取决于应用程序。考虑到 G1 屏障在 C2 的中间表示(IR)中由超过 100 个操作表示,并导致约 50 条 x64 指令的结果,这一点并不令人惊讶。减少这种开销是让 Java 平台更好地适应云计算的关键。
另一个主要贡献者是垃圾收集器(GC)。作为一个半并发的分代垃圾收集器,G1 与 JIT 编译器接口合作,以屏障代码对应用程序内存访问进行检测。就 C2 而言,维护和发展这一接口需要深入理解 C2 内部结构,而这正是少数 GC 开发者所缺乏的。此外,一些屏障优化需要应用无法用 C2 的中间表示表达的低级转换和技术。这些障碍减缓或直接阻碍了 G1 关键方面的进化和优化。将 G1 屏障检测与 C2 内部解耦可以使 GC 开发者进一步优化并减少 G1 的开销,通过算法改进和低级微优化来实现。
C2 使用 节点海洋 IR 将 Java 方法编译为机器码。此 IR 是一种程序依赖图,赋予编译器调度机器指令很大的自由度。虽然这简化并扩大了许多优化的范围,但也使得难以维护关于指令相对排序的不变量。在 G1 屏障的情况下,这已经导致了如 JDK-8242115 和 JDK-8295066 所示的复杂错误。我们不能保证没有其他性质相似的问题存在。
早期实验和对 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 开发者不透明,并且它使得难以保证不存在屏障排序问题。
晚期屏障扩展
因此,我们建议尽可能延迟 G1 屏障在 C2 编译管道中的扩展,将其从字节码解析推迟到代码生成阶段,当 IR 操作被翻译成机器码时。这在下图中有所描述,使用了上述相同符号:
详细来说,我们将晚期屏障扩展实施如下:
- 在字节码解析过程中生成的 IR 内存访问带有生成其屏障代码所需的信息,这些信息不会暴露给 C2 的分析和优化机制。
- 指令选择用特定于平台和 GC 的指令替换抽象内存访问操作,但屏障仍然是隐式的。此时插入特定于 GC 的指令,例如确保寄存器分配器为屏障操作预留足够的临时寄存器。
- 最后,在代码生成期间,根据其标记的屏障信息,每个特定于 GC 的内存访问指令都被转换成机器码。这段代码包括围绕屏障代码的特定于平台的内存指令。屏障代码使用字节码解释器的屏障实现生成,并辅以实现从屏障调用到 JVM 的程序集存根例程。
ZGC,JDK 中的另一种全并发收集器,自 JDK 14 以来成功使用了这种设计。实际上,我们将晚期屏障扩展视为 ZGC 达到生产准备状态所需的稳定性前提之一,如 JDK 15(JEP 377) 所述。
对于 G1 中的晚期屏障扩展,我们重新使用了许多为 ZGC 开发的机制,如对堆访问 API 的扩展和执行 JVM 调用的逻辑。我们还重新使用了已存在于所有平台上以支持字节码解释的汇编级屏障实现。这些实现以(伪)汇编代码的形式表达,这是所有 HotSpot 开发者熟悉的抽象层次。
候选优化
初步实验表明,没有进行任何优化的简单实现的晚期屏障扩展已经能够达到接近 C2 优化代码的质量。然而,完全弥合性能差距需要采用一些 C2 当前应用的关键优化。作为这项工作的一部分,或者可能在后续工作中,我们将重新评估这些优化在晚期屏障扩展背景下的适用性,并重新实现那些在应用程序级别有明显性能优势的优化。
我们考虑的优化集中在写操作的屏障上,即形式为 x.f = y
的操作,其中 x
和 y
是对象,f
是一个字段。初步实验表明,这些构成了大约 99% 的所有执行的 G1 屏障。写屏障包括支持并发标记的前置屏障和支持将堆区域分区成代的后置屏障。
移除对新对象写入的屏障 —— 对新分配的对象的写入不需要屏障,只要在分配和写入之间没有安全点。目前,C2 在检测到这种模式时会积极地移除写屏障。我们可以通过在写操作标记的信息中注明这种情况并在代码生成时省略相应的屏障代码来为此类写入实现相同的优化。
基于空值信息简化屏障 —— C2 通常可以保证要存储在内存写入中的对象指针(
y
)要么为空要么非空。这可以从原始字节码直接推断出来,也可以由 C2 的类型分析推断。代码生成可以利用此信息简化甚至移除后置屏障,并在启用该模式时简化 对象指针压缩和解压。目前,C2 通过其通用平台无关的分析和优化机制无缝地实现了此类简化。我们可以通过根据 C2 类型系统提供的信息显式跳过不必要的屏障和对象指针压缩及解压缩指令的发射来为此类晚期屏障扩展实现同样的简化。初步实验表明,大约 60% 的执行写的后置屏障可以通过这种技术被简化或移除。移除冗余解压缩操作 —— 当启用对象指针压缩和解压缩时,内存写入存储的是压缩指针,但屏障操作于未压缩指针上。目前,C2 对写及其屏障的全局分析和优化通常会产生单个压缩操作以使对象指针的两个版本都可用。一个简单的晚期屏障扩展实现会导致每次写入产生一个压缩和一个解压缩操作。我们可以通过插入匹配压缩和写 IR 操作对的压缩并写伪指令来移除冗余的解压缩操作。在此类伪指令范围内,我们使用单个压缩操作使对象指针的压缩和未压缩版本都可用,从而达到与 C2 当前优化相同的效果。
优化屏障代码布局 —— 前置屏障和后置屏障都会测试是否实际需要屏障;如果需要,则屏障调用 JVM 以通知收集器有关写操作的信息。初步实验、早期研究 以及 原始 G1 论文 显示,实际上很少需要屏障,因此很大一部分屏障代码很少被执行。目前,C2 自然地将很少需要的屏障代码放置在主执行路径之外,提高了代码缓存效率。我们可以通过手动将屏障实现拆分为频繁和不频繁的部分,并在程序集存根中扩展后者来为晚期屏障扩展实现相同的效果。
替代方案
GC 屏障可以在 C2 编译管道的几个不同点扩展:
- 在字节码解析时(早期屏障扩展):GC 屏障在最初构建 IR 时扩展。
- 平台无关优化之后:GC 屏障在循环变换、逃逸分析等之后扩展。
- 指令调度之后:GC 屏障在选择和安排特定于平台的指令之后但在寄存器分配之前扩展。(目前,在这个层面上还没有支持扩展。)
- 寄存器分配之后:GC 屏障在寄存器分配和最终 C2 变换之间扩展。
- 在代码生成时(晚期屏障扩展):GC 屏障在 IR 指令被翻译成机器码时扩展。
每个这些点在 C2 开销、所需的 C2 知识、遭受指令调度问题的风险和所需的特定于平台的努力方面提供了不同的权衡。
- 一般来说,屏障扩展得越晚,C2 开销就越低。最大的节省是在屏障扩展从字节码解析移动到平台无关优化之后,然后移动到寄存器分配之后。
- 除了代码生成外,任何扩展点上的开发者都需要大量的 C2 知识。
- 在字节码解析和平台无关优化之后扩展屏障不需要特定于平台的支持,但它确实存在触发指令调度问题的风险。
- 正如这里所提议的那样,在代码生成时扩展屏障是最具最低开销的替代方案,也是唯一不需要特定于 C2 开发人员知识的方案。与所有其他依赖于平台的扩展点一样,它享有避免指令调度问题的优势和需要为每个平台付出实现努力的劣势。
下表总结了每个扩展点的优点和缺点:
扩展点 | C2 开销 | 需要 C2 知识 | 调度控制 | 平台无关 |
---|---|---|---|---|
字节码解析时(早期) | 高 | 是 | 否 | 是 |
平台无关优化之后 | 中 | 是 | 否 | 是 |
指令调度之后 | 中 | 是 | 是 | 否 |
寄存器分配之后 | 低 | 是 | 是 | 否 |
代码生成时(晚期) | 低 | 否 | 是 | 否 |
设计空间的另一个维度是暴露给 C2 的屏障实现的粒度。对于 ZGC,我们尝试使用单个 IR 操作表示屏障,除了对应的内存访问操作,但得出结论即使是更粗粒度的表示也有可能导致指令调度问题。考虑到两个收集器的调度问题相似,这一结论也可能适用于 G1。
测试
为了降低引入功能故障的风险,我们将结合以下措施:
- 基于广泛的已有的 JDK 测试套件的常规测试,由 Oracle 的内部测试系统在不同配置下执行,
- 新的测试以练习目前覆盖不足的情况,以及
- 编译器和 GC 压力测试,以练习可能会被忽略的罕见代码路径和条件。
为了降低性能回归的风险,我们将使用一组行业标准的 Java 基准测试在不同平台上评估新的实现。
为了测量和比较编译速度和代码大小,我们将使用 HotSpot 选项 -XX:+CITime
提供的功能。为了控制跨 JVM 运行中编译方法的大小和范围的变化,我们将测量每个基准运行的多次迭代,并使用如 -Xbatch
之类的 JVM 选项使每次运行更加确定。
风险和假设
- 就像任何影响 JVM 核心组件交互的更改一样——在这种情况下,是 G1 垃圾收集器和 C2 编译器——存在着不可忽视的引入可能导致失败和性能退步的错误风险。为了缓解这一风险,我们将进行内部代码审查,并在上述基础上进行广泛的测试和基准测试。
- 在 G1 的上下文中,不精确卡标记 是一种优化,避免了对同一对象的不同字段进行一系列写入时多次调用 JVM。基于早期屏障扩展的这种优化的当前实现尚未显示出显著的应用级性能收益。因此,我们假设无需为晚期屏障扩展实现不精确卡标记即可匹配当前生成代码的质量。不过,我们的工作在这里可能为将来实现更有利的不精确卡标记铺平道路。