返回

文章详情

值类仍然需要编译器的同情

Hacker News2026年8月26日 08:59

本文讨论了JDK 28中的预览功能,JEP 401作为Valhalla的一个主要里程碑,已经作为预览功能集成到JDK 28中。这是非常令人兴奋的,因为值类增加了我们与他人沟通程序语义的能力以及JVM可用的优化机会。然而,我看到人们在网上基本上采取了一种“让所有类都成为值!” 的方式。我担心有一种信念,即普通类提供了性能的下限,而值类会尽力将你提升到这个下限之上,但永远不会让你低于这个水平。不幸的是,这并不是真的。一个出于好意的程序可能会将JVM置于一个情况中:对于某些方法,扁平表示的速度更快,而对于其他方法,引用表示的速度更快。当这些方法相互作用时,JVM被迫在两种表示之间进行转换。我希望值类不仅仅是魔法,因此今天我将向您展示JVM目前的能力,以及它的局限性。我希望通过这个,您能对您(或您的AI代理)所编写的代码进行推理提供一些背景。值类的主要优化优势在于我们放弃了身份。这使得JVM可以自由选择适合特定情况的表示。没有身份的要求,运行时可以更容易地将值扁平化(避免指针追逐),通过在寄存器或栈上独立表示其组成部分来标量化它们。对于值对象本身,逃逸分析变得微不足道:没有需要证明不可观察的身份。我们将检查三个示例:存储为扁平的大最终值、无需分配的直接值转换,以及需要物化的通用虚拟调用。不可变性使扁平化成为可能,JEP 539,JVM中的严格字段初始化,允许JVM依赖于在其封闭对象变得可观察之前已初始化的最终字段。因为这样的字段不会在后续更新中,更改,所以JVM可以使用非原子扁平布局,而不会冒着撕裂赋值的风险。然而,可变字段必须保持无撕裂赋值。如果可变字段包含的值太大,无法进行原子扁平更新,则JVM必须使用引用布局。严格初始化保证开辟了许多优化可能性。考虑这个小示例:值记录FourLongs(long a,long b,long c,long d){} 记录Envelope(FourLongs载荷){} FourLongs具有32字节的负载,使其在当前JVM中对于原子扁平更新来说过于庞大。但Envelope.payload是一个记录组件,因此是一个严格初始化的最终字段:一旦初始化,便永远不会更新。因此,JVM可以使用非原子扁平布局存储负载。在当前的Valhalla主构建中,当使用PrintFieldLayout时,字段布局诊断报告如下:类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布局:33/8 类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; } } 类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);} 写入扁平字段需要写入其各个组件。如果thread1和thread2独立写入这些组件,则另一个线程可能会观察到撕裂的值,例如(1,1,0,0),由两个不同赋值的部分组成。Java内存模型禁止这种撕裂:在两个线程都已经合并之后,该程序只能打印(1,0,0,0)或(0,1,0,0)。对于这样大的扁平值保证无撕裂赋值将是昂贵的,因此当前的JVM使用引用布局。每个线程构造一个完整的FourLongs,然后执行一个原子引用存储。去除身份去除了分配。如果我们有一个小函数改变

赞助内容

NordVPN Next-gen Antivirus

本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。

请我喝杯咖啡