Value Classes 仍需编译器的关怀
摘要
本文讨论了 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
```
相似文章
JEP 401:值对象(预览)已合并至 OpenJDK master
JEP 401:值对象(预览)已合并到 OpenJDK master,标志着 Project Valhalla 在 Java 中的值类型取得了重要里程碑。
Project Valhalla 解读:十年耕耘终入 JDK 28
Project Valhalla 的 JEP 401(值类与对象)已合并到 OpenJDK 主仓库,目标为 JDK 28,标志着十年开发历程的重大里程碑。
为何低延迟Java仍需严谨的编码纪律?
讨论为何在现代JVM优化下,低延迟Java仍需严谨的编码实践。
我讨厌编译器
一篇表达对编译器不满的个人观点文章,可能讨论其复杂性或缺点。
值编号
本文解释了值编号,一种编译器优化技术,用于识别相同的计算以避免冗余,基于静态单赋值(SSA)形式,并使用哈希合并进行高效比较。