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