Value Classes 仍需编译器的关怀

Hacker News Top 新闻

摘要

本文讨论了 Value Classes 作为 JDK 28 中的预览功能的集成,并分析了 JVM 中的优化机会和限制,着重强调了谨慎的编译器支持的必要性。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/26 12:12

# 值类仍需编译器理解 来源:https://johan-sjolen.github.io/post/compiler-sympathy/compiler-sympathy/ *本文讨论 JDK 28 的预览功能* JEP 401(https://openjdk.org/jeps/401)作为 Valhalla 项目的重要里程碑,已作为预览功能集成到 JDK 28 中。这非常令人兴奋,因为值类不仅增强了我们向他人传达程序语义的能力,还为 JVM 提供了更多优化机会。然而,我注意到网上有人采取"为所有类都使用值类"的策略。我担心存在这样一种观念:普通类为性能设置了下限,而值类会尽力提升性能但绝不会低于这个下限。不幸的是,事实并非如此。 一个设计良好的程序可能会让 JVM 面临这样的情况:某些方法使用扁平化表示更快,而其他方法使用引用表示更快。当这些方法相互交互时,JVM 被迫在两种表示之间进行转换。我希望值类不仅仅是一种魔法,所以今天我将展示 JVM **当前**的能力及其局限性。希望这能为您分析代码(无论是您还是您的 AI 代理编写的)提供一些背景。 值类的主要优化优势在于我们放弃了标识性。这赋予了 JVM 根据特定情况选择合适表示的自由。无需标识性要求,运行时可以更轻松地扁平化值(避免指针追踪),通过将组件独立地表示在寄存器或栈中来对其进行标量化。对于值对象本身,逃逸分析变得微不足道:无需证明其标识性的逃逸不可观测。 我们将考察三个示例:一个存储为扁平化的大最终值、一个无需分配编译的直接值转换,以及一个需要具体化的泛型虚调用。 ## 不可变性实现扁平化 JEP 539(https://openjdk.org/jeps/539)(JVM 中的严格字段初始化)让 JVM 可以依赖 final 字段在其包含对象可观测前已完成初始化。由于此类字段后续无法更新,JVM 可以使用非原子扁平化布局而不会导致部分赋值。然而,可变字段必须保证无撕裂赋值。如果可变字段包含的值太大,无法进行原子扁平化更新,JVM 则必须改用引用布局。 严格初始化保证开启了多种优化可能性。考虑以下小示例: ``` value record FourLongs(long a, long b, long c, long d) {} record Envelope(FourLongs payload) {} ``` `FourLongs` 包含 32 字节的数据,在当前 JVM 中对于原子扁平化更新来说太大了。但 `Envelope.payload` 是记录组件,因此是严格初始化的 final 字段:一旦初始化便永不更新。因此,JVM 可以自由地使用非原子扁平化布局存储 `payload`。 在当前 Valhalla 主构建中,使用 `PrintFieldLayout` 时字段布局诊断会报告以下信息: ``` Layout of class FourLongs @8 REGULAR 8/8 "a" J @16 REGULAR 8/8 "b" J @24 REGULAR 8/8 "c" J @32 REGULAR 8/8 "d" J @40 NULL_MARKER 1/1 NULLABLE_NON_ATOMIC_FLAT layout: 33/8 Layout of class Envelope @8 FLAT 33/8 "payload" LFourLongs; FourLongs NULLABLE_NON_ATOMIC_FLAT ``` 这里可以看到 `FourLongs` 由四个组件和一个 1 字节的空值标记组成,并支持可空、非原子扁平化布局。运行时在 `Envelope` 记录中利用这一点,允许 `FourLongs` 被扁平化。关键是 `Envelope` 也是不可变的;如果将其替换为可变类,布局必须更改: ``` class MutableEnvelope { public FourLongs payload; public MutableEnvelope(FourLongs payload) { this.payload = payload; } } ``` ``` Layout of class MutableEnvelope @8 REGULAR 4/4 "payload" LFourLongs; ``` 为什么会这样?让我们考虑两个线程之间的数据竞争: ``` void thread1(MutableEnvelope a) { a.payload = new FourLongs(1, 0, 0, 0); } void thread2(MutableEnvelope a) { a.payload = new FourLongs(0, 1, 0, 0); } void main() throws InterruptedException { MutableEnvelope a = new MutableEnvelope(new FourLongs(0, 0, 0, 0)); var t1 = new Thread(() -> thread1(a)); var t2 = new Thread(() -> thread2(a)); t1.start(); t2.start(); t1.join(); t2.join(); IO.println(a.payload); } ``` 写入扁平化字段需要单独写入其各个组件。如果线程1和线程2独立写入这些组件,另一个线程可能会观察到撕裂值,例如 `(1, 1, 0, 0)`,由两次赋值的不同部分组合而成。Java 内存模型禁止此类撕裂:两个线程 join 后,此程序可能仅打印 `(1, 0, 0, 0)` 或 `(0, 1, 0, 0)`。 保证如此大的扁平化值的无撕裂赋值代价高昂,因此当前 JVM 使用引用布局。每个线程构造一个完整的 FourLongs,然后执行原子引用存储。 ## 移除标识消除分配 如果我们有一个在循环中更改组件的小函数,如下所示: ``` static FourLongs bumpA(FourLongs value) { return new FourLongs(value.a() + 1, value.b(), value.c(), value.d()); } static long run(long iterations) { FourLongs value = new FourLongs(0, 2, 3, 4); for (long i = 0; i < iterations; i++) { value = bumpA(value); } return value.a() + value.b() + value.c() + value.d(); } ``` 在源代码层面,我们可以看到每次调用 `bumpA` 都构造一个新的 `FourLongs`。然而,在 `run` 的调用点,我们可以看到返回值实际上只是为了更改 `value` 的 `a` 组件。一个优秀的优化编译器应该能够识别这一点。事实证明,C2 是一个相当不错的编译器! C2 保持表示标量化,甚至识别出当 `iterations >= 0` 时,最终结果必须是 `iterations + (2 + 3 + 4) = iterations + 9`。以下是生成代码的简略摘录: ``` mov x0, #9 ; 将 9 放入 x0(包含返回值) cmp x1, #0 ; 将 x1(包含 iterations)与 0 比较 b.le done ; 如果小于等于 0,跳转到 done add x0, x0, w1, sxtw ; 设置 x0 = x0 + w1,将 w1 符号扩展到 64 位 done: ret ``` 如果保持 `bumpA` 不变但将 `FourLongs` 设为有标识记录,C2 必须证明其标识对计算没有影响。编译器通常能做到这一点,但现在我们依赖编译器在每种情况下都进行证明。当我尝试这样做(通过从 `FourLongs` 声明中移除 `value`)时,C2 无法执行此优化。以下是 C2 编译 `IdentityRecordExperiment::run` 的简略摘录。即使 `bumpA` 已内联,分配仍保留在 `run` 的循环中: ``` # {method} static 'run' '(J)J' in 'IdentityRecordExperiment' ; 初始 FourLongs 分配 ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; 预留 40 字节 cmp x11, x10 b.hs slow_allocation ; 对象头设置省略 str x11, [x28, #TLAB_TOP] ; 提交分配 ; 对象初始化省略 ; 循环体:来自内联 bumpA 的分配 ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; 再预留 40 字节 cmp x11, x10 b.hs slow_allocation ; 对象头设置省略 str x11, [x28, #TLAB_TOP] ; 提交分配 ; 对象初始化省略 ``` 显然,为编译器提供更强的语义保证有时会产生巨大的收益。 ## 类型擦除使分配重现 现在我们要看一些更复杂的例子。此示例源自我们从 valhalla-dev 邮件列表用户收到的一封电子邮件(https://mail.openjdk.org/archives/list/[email protected]/thread/3P5463B2OWURWM4LIMYIQMQOMZCOR6LN/)。他将一个解析库从 Elm 移植到 Java,并注意到在将所有记录转换为值记录后出现性能下降。性能回退显然不是我们想要的结果,但像这样的意外结果很有趣:值类为 JVM 提供了更多语义信息和更大自由度,那么使用它们怎么会使程序变慢呢? 我深入研究以找到原因和源代码级别的修复方法。这项调查揭示了一个有趣的编译器问题,我的 C2 团队同事目前正在积极研究。我将在这里解释我的发现,但请注意我不得不大幅简化。JVM 和 `javac` 都相当复杂,所以我必须省略一些细节。 ``` value record LargeValue(long a, long b, long c, long d) {} value record Carrier(LargeValue v, boolean b) {} interface Fun { R apply(F value); } interface Frobber extends Fun {} final class FrobIt implements Frobber { public Carrier apply(LargeValue value) { return new Carrier(value, true); } } final class GrobIt implements Frobber { /* 实现故意省略 */ } final class DrobIt implements Frobber { /* 实现故意省略 */ } ``` 这是相当简单的代码。我们有多个 `Frobber`,它们接受一个 `LargeValue` 并产生一个 `Carrier`,而 `Carrier` 包含另一个 `LargeValue`。`Frobber` 接口扩展了 `Fun` 接口。 让我们通过 JVM 的视角来看一下,以便理解发生了什么。Java 通过类型擦除实现泛型,将这些类型参数替换为 `Object`。这意味着从 JVM 的角度看,`Frobber` 实际上继承了这个方法: ``` interface Frobber extends Fun { Object apply(Object value); } ``` 类型化签名 `Carrier apply(LargeValue)` 未出现在继承的 JVM 方法描述符中,因此必须通过动态分析恢复。为了适应这种类型差异,`javac` 在类文件中生成桥接方法。每个实现获得一个大致等效于以下内容的方法: ``` // 由 javac 生成 public Object apply(Object value) { return apply((LargeValue) value); } ``` 桥接方法接受擦除后的参数,将其转换为预期类型,并调用我们实际编写的方法。 现在考虑原始重现器中的方法: ``` static Carrier reproduce(LargeValue value, Frobber a, Frobber b) { Carrier c = a.apply(value); return b.apply(c.v()); } ``` 这里有两个独立的接口调用点。如果每个调用点只观察到一个实现,C2 可以对其进行去虚拟化。例如,第一个调用点可能总是接收 `FrobIt`,而第二个总是接收 `GrobIt`。C2 可以独立保护并内联两个目标。在这个实验中,C2 成功移除了所有堆分配。用伪 Java 表示,结果如下。我们通过向类型名附加 `Fields` 来表示标量化值(非堆引用的值): ``` static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { guard(classOf(a) == FrobIt.class); CarrierFields c = inline(FrobIt_apply(value)); guard(classOf(b) == GrobIt.class); return inline(GrobIt_apply(c.v)); } ``` 在这个层面,不再有对生成的桥接方法的调用。去虚拟化和内联目标也内联了其桥接方法。一旦桥接方法融入周围的编译,就不再存在真正的 `Object apply(Object)` 调用边界。 然而,在我们收到的报告中,调用点是多态的(megamorphic)。在我们的例子中,这意味着 `FrobIt`、`GrobIt` 和 `DrobIt` 可互换调用。每个调用点有三个热实现,C2 保留了调用的动态分派: ``` invokeinterface Frobber.apply:(Object)Object ``` 继承的方法描述符定义了一个 ABI,要求调用者传递对象引用,实现也返回对象引用。调用者当前拥有一个标量化的 `LargeValue`,但 `Object apply(Object)` 无法接受四个独立的标量组件。它需要一个真正的引用。因此,调用者必须在调用前将值具体化。 动态选择的桥接方法则必须向相反方向转换: ``` Object FrobIt_apply_bridge(Object argument) { LargeValueFields value = scalarize((LargeValue) argument); CarrierFields result = FrobIt_apply_typed(value); return materializeCarrier(result); } ``` 桥接方法将传入的引用转换为 `LargeValue`,提取其组件,并使用标量化值对象调用约定调用类型化实现。类型化实现返回一个标量化的 `Carrier`,但桥接方法本身承诺返回 `Object`,因此它必须在返回前具体化结果。 因此,多态版本的 `reproduce` 大致如下: ``` static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { LargeValue argument1 = materializeLargeValue(value); Object returned1 = invokeinterface_apply_Object(a, argument1); Carrier carrier1 = (Carrier) returned1; CarrierFields c = scalarize(carrier1); LargeValue argument2 = materializeLargeValue(c.v); Object returned2 = invokeinterface_apply_Object(b, argument2); Carrier carrier2 = (Carrier) returned2; return scalarize(carrier2); } ``` 桥接方法充当 ABI 适配器。一边是擦除后的 `Object apply(Object)` 调用约定,另一边是类型化的 `Carrier apply(LargeValue)` 调用约定,其中值组件可以以标量化形式传递和返回。可以想象,这非常昂贵。每次调用时,调用者必须将标量化值转换为对象引用,这意味着在堆上具体化该值。它不能简单地将临时栈存储指向被调用者:调用是不透明的,因此被调用者可能保留该引用并在调用者返回后访问它。因此,参数必须是 GC 管理的堆对象。 幸运的是,修复非常简单!我们通过在 `Frobber` 中显式重声明类型化方法来避免这个问题: ``` interface Frobber extends Fun { @Override Carrier apply(LargeValue value); } ``` `@Override` 注解记录了我们的意图,但重要的是显式的方法声明。现在,静态接收者类型为 `Frobber` 的调用直接使用类型化描述符: ``` invokeinterface Frobber.apply:(LargeValue)Carrier ``` 调用仍然是多态的。C2 仍然不知道它会分派到 `FrobIt`、`GrobIt` 还是 `DrobIt`。但它不再需要这些知识来选择正确的调用约定。每个可能的目标都接受 `LargeValue` 并返回 `Carrier`,因此值可以以标量化形式跨越动态调用边界: ``` static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { CarrierFields c = invokeinterface_typed_apply(a, value); return invokeinterface_typed_apply(b, c.v); } ``` 在重现器中,擦除后的多态版本每次调用 `reproduce` 分配 192 字节。显式重声明类型化方法将其减少到零。 ## 结论 声明值类首先是一个语义决定。它告诉我们的程序伙伴,其实例完全由其状态定义,不需要标识。更清晰的模型本身就很有价值!JVM 优化这些值表示方式的额外自由度是一个受欢迎的附加功能。C2 可以利用这种自由度做令人惊叹的事情,但它并不总是能恢复隐藏在抽象边界后的信息。分析和检查生成的代码仍然是理解发生什么的最佳方式。为了获得最佳结果,我们可能仍然需要对编译器有一丝理解。 ## 附录:打印 C2 汇编代码 如果您想复核我的工作,可以使用这些代码片段自己检查汇编代码。编译时启用预览功能,确保目标方法被频繁调用变热,然后让 VM 编译并打印它: ``` javac --enable-preview --release 28 Example.java java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+PrintStubCode -XX:+PrintCompilation -XX:CompileCommand=compileonly,*::reproduce Example ```

相似文章

我讨厌编译器

Hacker News Top

一篇表达对编译器不满的个人观点文章,可能讨论其复杂性或缺点。

值编号

Hacker News Top

本文解释了值编号,一种编译器优化技术,用于识别相同的计算以避免冗余,基于静态单赋值(SSA)形式,并使用哈希合并进行高效比较。