Skip to content

필사 모드: Java Value Objects(JEP 401)带来的改变 —— 放弃身份标识,得到什么、失去什么

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 7月31日 00:45 UTC,22万行

2026 年 7 月 31 日 00:45 UTC,OpenJDK 主线迎来了一个提交。标题是两行。

8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)

这次提交的统计数字是 1,888 个文件、新增 208,011 行、删除 13,161 行。最初的 Pull Request #31120由 David Simms 在 5 月 11 日提出,OpenJDK 的 Skara 机器人按照惯例把它压缩(squash)成了一个提交合并进来。几个小时后,Hacker News 上的讨论帖涨到了 177 分。

JEP 401 本身创建于 2020 年 8 月 13 日,它的源头 —— Project Valhalla —— 则始于 2014 年。Effort 与 Duration 两项并排标着 XL 的 JEP 并不常见。写这篇文章时,它的状态是 Targeted,Release 28,文档更新日期是 2026 年 7 月 30 日。同时合并进来的 JEP 539,是一个从字节码校验层面强制要求值对象字段必须在初始构造阶段完成初始化的依赖 JEP —— 没有 401,它就没有意义;没有它,401 也无法成立。

这里有必要把兴奋值调低一档。这是一个 预览特性,默认关闭,JDK 28 的 GA 还早得很。即便如此,它依然值得细看,因为它触及的是 Java 对象模型本身。会改变 == 运算符与 synchronized 关键字含义的变更,在 Java 的历史上屈指可数。

「没有身份标识」到底意味着什么

JEP 举的例子很清楚。用 LocalDate.of(1996, 1, 23) 创建的对象,和在它基础上加 30 年再减 30 年得到的对象,保存着相同的年、月、日。equals 返回 true。但 == 是 false。因为这两个对象拥有彼此不同的 身份标识

对可变对象来说,身份标识必不可少 —— 你需要区分两个当前状态相同、但以后可能各自变化的对象。如果文本编辑器里的两行碰巧保存着相同的字符串,修改其中一行时,另一行不该跟着变。但对不可变数据来说,情况正好相反。两个表示 1996-01-23 的 LocalDate 之间没有实质差异。没有理由区分它们,却被强行要求区分。

这种强制区分要付出两个代价:一是每次都要分配新内存,二是每次使用那块内存都要顺着指针跳转。JEP 用一张图展示了 LocalDate 数组的内存布局 —— 为了装下五个 48 位的数据,实际上用了五个指针和五个堆对象(每个都带着至少 64 位的头部)。而且如果这些对象彼此相距很远,每次遍历都会打散缓存行。

在 JDK 28 预览版中,平台 API 里的 30 个类被声明为值类。全部装箱类型(IntegerLongFloatDoubleByteShortCharacterBoolean),再加上 NumberRecordOptional 系列四个、java.time 中的 12 个,以及 java.time.chrono 中的四个历法日期类。于是打开预览之后,会发生这样的事。

// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y                       // true   (不启用预览则为 false —— 超出 Integer 缓存范围)

LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3                     // true

Objects.hasIdentity(d1)      // false  (新增方法)

String 不是 值类。因为它的 API 与实现仍有部分依赖身份标识,所以字符串继续作为身份对象存在。

== 的新定义是这样的:两个值对象满足 ==,当且仅当 (1) 它们是同一个值类的实例,(2) 基本类型字段的位模式相同,(3) 引用类型字段递归应用 == 后彼此不可区分。== 在身份对象上的行为,自 Java 1.0 以来没有变过。

需要留意的是,即便在值对象上,==equals 现在也 可能不同。JEP 的例子是一个用原字符串加两个坐标来表示子串的值类。它们表示相同的字符序列,但内部状态不同,所以 equals 为 true、== 为 false。浮点数也是一样 —— 两个持有不同位模式 NaN 的值对象,equals 判断相等,== 判断不相等。不要跳到「值对象所以可以用 ==」这样的结论。equals 依然是默认选择。

性能从何而来 —— 扁平化与标量化

JEP 把优化分成两条线来说明,这个区分在实践中相当重要。

引用扁平化 作用于堆上的字段与数组元素。当某个对象的字段指向一个值对象时,不再存指针,而是把那个值对象的字段直接编码进引用本身。以 Integer 数组为例,每个元素会变成一个 64 位的字,其中 1 位是 null 标志、32 位是 int。这比指针数组明显更小,而且最重要的是 没有额外的内存加载

引用标量化 作用于方法参数与局部变量。JIT 会把一个 LocalDate 引用拆成 null 标志、int、byte、byte 四个局部值来处理。JEP 的伪代码很好地展示了这一点:plusYears 方法编译完成后,根本不会再碰 LocalDate 指针。

这两种优化的决定性差异在于 大小限制。被扁平化的引用必须始终以原子方式读写,否则就可能被撕裂(tearing)—— 一个线程写入的前半部分和另一个线程写入的后半部分混在一起,就可能观察到一个从未存在过的日期。在常见硬件上,能保证原子访问的大小是 64 位,所以 可变字段中能容纳的扁平化引用,实际上被限制在 64 位以内

这就是为什么 LocalDateTime 无法在可变字段中被扁平化:它内部的 LocalDateLocalTime 字段、各自的 null 标志,再加上自身的 null 标志,加起来超过了 64 位。这种情况下 JVM 会悄悄退回指针布局。反过来, 值类的字段没有这个限制 —— 因为值对象的字段变化本就不可能被观察到,根本不存在被撕裂的余地。同一个 LocalDateTime 字段,如果所属的类是身份类就会变成指针,如果是值类就可能被扁平化。

标量化没有这个限制,因为栈和寄存器中的值不会暴露在数据竞争之中。

扁平化与标量化 何时发生,JEP 也做了明确说明。这是一种优化,不是语言特性,你无法直接控制它,但有办法提高它发生的概率。

Integer[] ints = { 1996, 2006, 1996, null, null };  // 可以扁平化
Object[]  objs = { 1996, 2006, 1996, null, null };  // 不可以

record Box<T>(T field) { }      // field 被擦除为 Object —— 无法扁平化
var b = new Box<Integer>(1996); // 存储的是堆指针

变量必须 被声明为具体的值类类型。声明为父类型或泛型类型参数的位置,由于类型擦除实际上就是 Object,不属于优化对象。这正是 Valhalla 剩下的那些拼图(JEP 218,基本类型的泛型)还需要存在的原因。

还有一点。编译一个类时,出现在字段、方法签名里的值类名会被记录进一个叫 LoadableDescriptors 的新 class 文件属性,JVM 会据此足够早地加载对应的值类,从而准备好扁平化的字段和标量化的参数。也就是说, 一旦既有的类迁移成值类,引用它的代码就需要重新编译才能拿到最佳性能。不重新编译也能跑,只是堆分配会一直留着。

records,以及与 primitives 的关系

record 本来就是 final 的,字段也全是 final,所以是值类的有力候选。语法直接就能重叠。

value record Point(int x, int y) { }

Point p = new Point(17, 3);
Objects.hasIdentity(p);      // false
new Point(17, 3) == p;       // true

不过,并不是所有值类都能变成 record。record 必须是构造参数与字段严格一一对应的 透明 类,而很多类会选择不同的内部表示来节省内存。JEP 举的例子是一个把欧元和分打包存进一个 long 里的货币类 —— 它不能是值 record,但可以是值类。

与基本类型的关系,JEP 在 Non-Goals 里说得很清楚。 改变基本类型的处理方式不是目标。 值对象在很多方面表现得像基本类型,但它们是两个不同的概念。Java 语言依然只处理两种数据 —— 基本类型和对象引用。引入类似 C 或 C# 的 struct 同样是明确的非目标。C# 的值类型的实例拥有身份标识、字段也可以修改,因此必须为赋值、调用时的拷贝语义制定细致的规则,用户侧的模型也因此更复杂。Java 选择把这类底层决策交给 JVM 实现者。

类层次结构也值得梳理一下。值类可以实现接口,可以是 sealed,声明成 abstract 就会变成一个可扩展的「值兼容」父类。值类只能扩展 Object 或抽象值类,不能扩展身份类。不存在像 java.lang.Value 这样的公共父类。

Hacker News 讨论里常被提到的「四个桶」(普通对象 / 无身份标识对象 / 原子值 / 允许撕裂的值)这个框架,来自 Valhalla 的设计笔记,但 这次合并进来的只到前两个。显式允许撕裂、从而摆脱原子性约束的 opt-out 语法并未包含在这个 PR 里,JEP 也把它推迟为「未来的改进」。

什么会被破坏

一旦一个类变成值类,依赖身份标识的操作要么行为改变,要么直接失败。按 JEP 原文梳理如下。

操作在值对象上的结果
==递归比较字段值而非身份标识。两个 Integer 1996 现在也 ==
synchronized若静态类型是值类则编译报错,经由 Object 类型则在运行时抛出 IdentityException
wait / notify由于无法获取锁,始终抛出 IllegalMonitorStateException
System.identityHashCode依据字段值而非身份标识计算哈希(名字是唯一保留下来的历史遗留)
finalizeGC 绝不会调用。重写它会让 javac 发出身份标识警告
java.lang.ref 系列、WeakHashMap构造 Reference 时抛出 IdentityException
序列化值记录(record)自动可用。其余值类若没有 writeReplace/readResolve 则抛出 InvalidClassException
通过深度反射修改 final 字段不支持。即便加上 --enable-final-field-mutation 也不行
clone返回一个与原对象无法区分的值对象。x.clone() != x 这种预期不再成立

这张表里,实践中最痛的地方有三处。

第一, 弱引用家族 —— java.lang.ref 下的 Reference 子类们,以及 WeakHashMap。如果你用值对象当键做了一个弱缓存,它现在会直接抛异常。不过,从 JDK 25 开始,javac 就已经在为 value-based 类发出 identity 警告,JDK 28 开始对值类也发出同样的警告。如果你一直在关注这些警告,那份清单其实早就有了。

第二, 序列化。值类会被编译成严格初始化(strictly-initialized)的字段,而反序列化是绕开构造函数直接填充字段的方式,没法安全地完成初始化。所以实现了 Serializable 的非 record 值类,必须自己实现 writeReplacereadResolve,改用替代对象。不这么做,运行时就会抛 InvalidClassException,javac 也会给出 serial 警告。

第三, 通过深度反射修改 final 字段的库。部分序列化框架、ORM、mock 库、DI 容器会用这种方式。对值对象来说,这条路会被无差别地封死 —— 只能走构造函数。final 字段修改路径本身不断收窄这条脉络,在 JDK 26 的 final 字段修改警告 —— JEP 500 一文里已经开了头,JEP 401 展示的是它的终点。

另外,JEP 的 Risks and Assumptions 里悄悄写下的两点,值得库设计者记在心里。因为 == 会递归比较值对象构成的树, 嵌套很深时可能耗时很久,甚至触发 StackOverflowError。而且因为 ==identityHashCode 可能间接暴露私有字段的值, 装着敏感数据的类,最好不要做成值类

库作者现在该做的事

JDK 28 还早,但现在做的一些准备,以后能白捡好处。

1. 先挑出候选。 判断标准有两个 —— 实例是否不可变(所有实例字段是否都是 final,表示的值是否不随时间变化),以及彼此是否可互换(表示同一个值的两个实例,是否没必要区分)。没有字段的抽象类也是好候选 —— 没理由把身份标识的要求强加给子类。Number 就正是这样变成了抽象值类。

2. 先重写 equalshashCode 在迁移之前让这两者不依赖身份标识,迁移成值类之后行为就不会变。不重写就直接迁移,继承来的实现的含义会被悄悄改变。

3. 清理 public 构造函数。 客户端可能在用 new 创建「与其他任何对象都不同的实例」,拿去当锁用,或者当 == 比较的标记用。JEP 推荐的路径,和 Java 9 当年对 Integer 构造函数做的一样 —— 把构造函数标记为 deprecated,引导大家改用工厂方法。

4. 审计同步对象。 检查自己的代码有没有把值候选类的实例当锁用,文档有没有在诱导客户端这么做。Java 16 开始就有针对 value-based 类的同步警告,如果你一直开着构建警告,清单早就摆在那里了。

5. 二进制兼容性不用担心。 只要类是 final 或 abstract、字段全部是 final,加上或去掉 value 关键字就是一次 二进制兼容的变更。源码兼容性和行为兼容性的风险,上面那份清单就是全部。

想现在就亲手试一试,拿一个 JDK 28 EA 构建、打开预览即可。

javac --release 28 --enable-preview Main.java
java  --enable-preview Main

# 单文件源码启动器
java --enable-preview Main.java

# jshell
jshell --enable-preview

有一条预览规则需要特别指出。平台 API 里那 30 个类, 只有打开预览时才是值类。不打开预览编译,编译器用的是既有的身份版 LocalDate;打开预览,用的就是值版本。打开预览后,没有办法再用身份版本。也就是说,这个特性没法「只开一部分」。

「预览」这个词的分量 —— 时间线与尚未完成的部分

把日期讲精确一点。在 JDK 28 项目页面上,JEP 401 的 targeting 评审在 2026 年 7 月 30 日结束,截至那天,JEP 文档的状态是 Targeted / Release 28。合并发生在第二天凌晨。同一个页面上,JEP 539(评审于 7 月 30 日结束)和 JEP 535(把 Shenandoah 分代模式设为默认,评审将于 8 月 3 日结束)也并排列在那里。

与此同时,JDK 27 已经进入 Rampdown Phase Two,GA 定在了 2026 年 9 月 15 日。考虑到 Java 的六个月节奏, JDK 28 的 GA 会落在 2027 年 3 月。而且预览特性按惯例至少还会再多送出一个发行版的预览期,所以不需要 --enable-preview 就能用值类的时间点,还要更晚。到底要经过几轮预览,现在还没定 —— 这一点尚未得到确认。

而且这次合并进来的,并不是 Valhalla 的全部。仅 JEP 明确列为 Future Work 的就有两项。

  • JEP 402,Enhanced Primitive Boxing —— 在语言层面利用装箱因为值对象而变轻这一事实。
  • JEP 218,Generics over Primitive Types(修订版) —— 让泛型类在用值类做参数化时,能够特化字段、数组、局部变量的布局。这正是解开前一节「泛型字段不会被扁平化」这条限制的拼图。

再加上前面提到的撕裂 opt-out、排除 null 的布局、值类的自动序列化,都还没做。所以「Valhalla 已经完工」这种说法并不准确。12 年过去, 第一块拼图落进了主线

结语 —— 改变对象模型的变更从不悄然而至

总结一下。

  • 值类以放弃身份标识为代价,换来 JVM 在内存布局上的自由。== 变成字段值的比较,synchronized 变得不可能。
  • 性能来自扁平化(堆上的字段、数组)和标量化(栈上的局部变量、参数)。前者因为原子性存在一个实实在在的 64 位天花板,后者没有。
  • 要拿到这份优化,变量必须被声明成具体的值类类型 —— Object 或泛型类型参数的位置都不算。而且用到已迁移值类的代码需要重新编译。
  • 会被打破的是 java.lang.ref 家族、WeakHashMap、非 record 值类的序列化、通过深度反射修改 final 字段,以及客户端的同步操作。Java 16 以来累积的 identity 警告,列出的就是这份清单。
  • 现状是 Targeted / JDK 28 预览,JDK 28 的 GA 预计在 2027 年 3 月。默认开启的时间点还要更晚。

眼下对生产环境没有影响。但如果某处代码把 LocalDate 当 key 放进了 WeakHashMap,那段代码大概率已经在报警告了。现在,就是该去读那条警告的时候。

参考资料

현재 단락 (1/94)

2026 年 7 月 31 日 00:45 UTC,OpenJDK 主线迎来了一个提交。标题是两行。

작성 글자: 0원문 글자: 8,612작성 단락: 0/94