颠覆!30 岁的 Java,终于可以不再给每个对象都配对象头了

Java 从 1995 年起就把世界分成两个阶层。intdouble 这些原始类型干净利落,直接存值,没有对象头,没有指针。其他一切——你写的每一个 class、每一个 new 出来的实例——都被分配一个「身份」(identity)。

这些能力听上去很强大。但你的 LocalDateRangeMoney 根本用不上。

你只是在存一个日期、一个数值范围、一个金额。你不需要对日期加锁,不需要用 IdentityHashMap 存范围,不需要用 == 判断两个金额是不是同一个对象。你关心的从来都是「这两个日期是不是同一天」「这两个范围是不是同一个区间」,而不是「这两个引用是不是指向同一块内存」。

JVM 不知道你不需要。它诚实地为每个对象分配对象头,为每个数组元素存指针,为每次 GC 扫描所有引用。你在为你不需要的东西付费。

JEP 401 的解方:value class

JEP 401 让你终于可以声明:这个类型,没有身份。

语法简单到你可能怀疑漏看了什么:

1
value record Range(int start, int end) {}

或者用普通类:

1
2
3
4
5
6
value class Money {
private final BigDecimal amount;
private final Currency currency;

// 构造器、方法照常写
}

一个 value 关键字。大部分语法。

语义上的变化不小。值对象有三条核心规则:

第一,== 不再比较内存地址。对值对象,== 比较的是字段值的等价性。两个 Range(1, 10)== 比较,结果是 true。日常开发建议还是用 equals,但 == 的行为终于和直觉对齐了。

第二,不能依赖身份的操作全部失灵。你不能对值对象用 synchronized(没有对象头给你加锁),不能用 IdentityHashMap 存(没有 identity 给你 hash),System.identityHashCode 和弱引用也都不能用。JVM 会在编译期或运行时报错,不会悄悄出 bug。

第三,值对象不可变。所有实例字段隐式 final,构造器里写完就定型。这和你对 record 的预期一致——值对象天然是不可变数据。

这三条规则说白了就是把一句大白话写进了类型系统:「我就是个值,别给我加戏。」

快在哪

省掉对象头只是开始。真正的收益来自 JVM 能做的两类优化。

Heap flattening(堆扁平化):值对象放在数组或另一个对象的字段里时,数据可以直接「铺平」在容器内部,不再是指针指向堆上另一个对象。

没有对象头散落在堆上,没有指针跳转,没有 cache miss 连锁反应。遍历一个值对象数组的体验,更接近遍历 int[] 而不是 Object[]

Scalarization(标量化):C2 JIT 编译器在预热充分后,可以把某些值对象完全拆成字段,直接放进寄存器或栈上参与计算,压根不分配堆内存。分配和不分配,差的不只是常数倍。

性能提示

Inside.java 用早期构建做了一个测试:5000 万个 LocalDate 组成的数组,循环累加年份。

不开预览时,后几次耗时约 72 ms。开了预览、LocalDate 作为值类并触发数组扁平化后,约 25-28 ms。

如何体验

第一步:去 jdk.java.net/valhalla 下载 EA build。Valhalla 项目专用的早期构建,包含 JEP 401 预览能力。

第二步:用 jshell 快速验证感觉:

1
jshell --enable-preview
1
2
3
4
5
value record Range(int start, int end) {}
var r1 = new Range(1, 10);
var r2 = new Range(1, 10);
r1 == r2 // true —— 值对象比较的是值
Objects.hasIdentity(r1) // false

第三步:选一个你项目里的小型不可变类,用 JMH 分别测开启和关闭 --enable-preview 的吞吐、分配率、GC 停顿。别只看墙钟时间。分配率和堆占用变化才是值对象收益的核心指标。