Java 的对象模型有一个长期被视为理所当然的前提:每个对象都有身份。

即使两个对象的字段完全相同,它们也可以是两个不同对象;可以用 == 区分,可以锁住其中一个,可以取得与身份相关的 hash,还可以在对象图中让多个引用指向同一实例。这套模型非常适合用户、连接、线程、缓存节点等实体,却也被强制套在坐标、复数、日期片段、金额和颜色这些“只由内容决定是什么”的数据上。

Project Valhalla 要解决的核心问题不是“给 Java 再加一种 struct 语法”,而是:

当一个对象不需要身份时,程序能否明确放弃身份,让 JVM 不再为一个永远不会被观察的性质付费?

截至 JDK 28,JEP 401 将 Value Objects 作为预览特性引入,JEP 539 同时预览 JVM 的严格字段初始化。Null-Restricted Types、增强装箱与泛型特化仍是后续工作,不能与 JEP 401 已交付的能力混为一谈。

身份并不是免费的

普通对象在堆中的概念形态通常包含:

TEXT
reference ──> [ object header | fields | padding ]

对象头要支撑 GC、类型信息、同步和身份相关状态;引用本身占空间;对象通常单独分配;读取字段要先沿引用跳转。实际布局受压缩指针、对象对齐、GC 和 JVM 实现影响,但成本类别不变:

  • header 与对齐浪费内存;
  • 每个对象需要分配与回收;
  • 数组保存的是引用,元素散落在堆中;
  • 多一次 pointer chasing,增加 cache miss 风险;
  • GC 要扫描更多对象和引用边。

考虑一百万个二维点:

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

Point[] points = new Point[1_000_000];

今天的 record 仍是有身份的普通对象。数组主体保存一百万个引用,每个 Point 还要单独占据一个堆对象。真正计算距离时,CPU 想要的是连续的 x, y,拿到的却是连续引用与离散对象。

手工改成两个 int[] 可以获得更好的布局,却破坏了领域模型:坐标不再是一个类型,而是两组必须永远同步的数组。

Valhalla 想保留“像类一样建模”,同时给 JVM “像一组值一样存储”的自由。

Value Object 的第一原则是语义,不是布局

JEP 401 使用 value 修饰符声明值类:

Java
value class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    double distanceTo(Point other) {
        int dx = x - other.x;
        int dy = y - other.y;
        return Math.hypot(dx, dy);
    }
}

这段声明首先承诺:Point 实例没有可观察身份。由此才派生出 JVM 的优化自由。

身份对象 值对象
两个字段相同的实例仍可被身份区分 相同状态代表同一个值
可以作为 monitor 不能依赖对象监视器
实例字段可变 实例状态浅不可变
引用位置通常保存对象地址 JVM 可保存引用,也可拆成字段
复制引用保留共享身份 复制值不改变程序含义

因此不要把 value 读成 inline、stack 或 packed。它没有承诺对象一定在栈上、一定没有引用,或者字段一定紧密排列;它承诺的是程序不能观察身份,于是 JVM 可以在不改变语义的前提下选择表示。

没有身份为什么能解锁优化

假设方法接收一个普通 Point:

Java
int sum(Point point) {
    return point.x() + point.y();
}

只要对象身份可能被观察,JVM 就必须考虑:

  • 调用方是否还持有同一引用;
  • 方法中是否执行 ==、锁或 identity hash;
  • 对象是否可能被别处修改;
  • 调试、反射或 native 边界是否需要真实对象。

逃逸分析可以在局部证明安全时消除分配,但它依赖每次编译的全局上下文,跨模块、反射、复杂控制流后容易失效。

值类把关键事实写进类型定义:所有使用者都不得依赖身份。JVM 因而可以更稳定地进行:

  • scalarization:把对象拆成若干标量,在寄存器或栈槽中传递;
  • flattening:把字段内容嵌入容器或数组,而不是保存引用;
  • allocation elimination:不建立独立堆对象;
  • reconstruction:需要调试、反射或边界对象时再物化一个等价实例。

概念上:

TEXT
Point add(Point a, Point b)

可以被 JVM 当作近似:

(ax, ay, bx, by) -> (ax + bx, ay + by)

这里不是 Java ABI 真变成多返回值,而是 VM 内部可以自由拆解和重组。

浅不可变,不等于对象图完全不可变

值类的实例字段不能在构造完成后变化,但字段可以引用可变对象:

Java
value class Tags {
    List<String> values;

    Tags(List<String> values) {
        this.values = values;
    }
}

values 这个引用不能被换掉,不代表列表内容不能变化。这样的类型虽然满足语言层面的浅不可变,却很可能不满足业务对“值”的直觉,也削弱 JVM 对深层状态的推理。

更稳妥的设计是复制并封装:

Java
value class Tags {
    List<String> values;

    Tags(List<String> values) {
        this.values = List.copyOf(values);
    }
}

Valhalla 不会替应用设计真正的不可变对象图;它只规定值对象自身的实例状态不能被重新赋值。

Value Class 与 record 不是同一条轴

record 解决的是数据载体的声明样板:组件、构造器、访问器、equals、hashCode 和 toString。它仍然是 identity class。

value 解决的是对象是否拥有身份。一个普通类可以是 value class,record 也可以表达为 value record;两者是可组合的维度:

形式 关注点
class 自定义类 API 与实现
record 透明数据聚合与生成成员
value class 放弃实例身份
value record 透明数据聚合,同时放弃身份

迁移时不能机械地给所有 record 加 value。如果代码依赖 ==、IdentityHashMap、对象锁、弱引用身份、对象代理或自引用结构,它就不是合适的值对象。

适合与不适合的类型

典型值对象:

  • Point、Vector、Complex;
  • Money(amount, currency);
  • Rgb(r, g, b);
  • 小型日期时间片段;
  • UUID、哈希、版本号等固定内容标识;
  • 领域中的测量单位和受约束数字。

典型身份对象:

  • 用户、订单、账户等会持续演化的实体;
  • 锁、线程、连接、文件句柄;
  • 图节点、双向链表节点等依赖引用拓扑的对象;
  • ORM entity 与 lazy proxy;
  • 需要独立生命周期和共享可变状态的服务对象。

“字段少”不是充分条件,“不可变”也不是充分条件。真正的问题是:两个实例内容相同后,程序是否仍有理由区分它们。如果有,身份就是业务语义,而不是性能负担。

JDK 28 预览交付了什么

需要把 Valhalla 的路线拆成三个层次:

层次 状态 解决的问题
Value Objects,JEP 401 JDK 28 预览 在语言和 JVM 中表达无身份对象
Strict Field Initialization,JEP 539 JDK 28 预览 在字节码验证层保证关键字段及时初始化
Null restriction、紧凑存储、泛型特化 后续设计工作 可靠触发扁平字段/数组并消除泛型装箱

JEP 401 很重要,但它不是 Valhalla 的终点。仅声明值类并不等于每个使用位置都保证扁平化:普通引用位置仍要表示 null,并受原子性、布局兼容、类加载和 GC 约束。

因此准确表述应是:value 给 JVM 许可;具体存储是否扁平化取决于使用位置与 VM 策略。未来 null-restricted storage 才会提供更强的布局契约。

为什么 null 会妨碍扁平化

如果一个字段或数组元素既要保存一个展开后的 Point(x, y),又要保存 null,布局必须额外表示“缺失”:

TEXT
nullable element = null-bit + x + y

这个 null bit 可能需要位图、额外字节、保留值或退回引用表示。数组更新还涉及原子性与并发可见性。JVM 不能仅因类型是 value class 就忽略这些语义。

所以 Valhalla 的长期设计将“是否有身份”与“是否允许 null”分开:

  • value class 决定对象语义;
  • null restriction 决定某个存储位置能否排除 null;
  • JVM 再据此选择平坦或间接布局。

这比把“值类型”设计成一个同时承担所有含义的新类型家族更利于兼容已有 Java。

与 Kotlin value class 的根本区别

Kotlin/JVM 的 @JvmInline value class 是编译器层面的包装消除,当前只能有一个底层属性:

Kotlin
@JvmInline
value class UserId(val raw: Long)

它在一些调用位置可以用 long 表示,在泛型、接口、可空类型等位置会装箱为 wrapper。方法需要名称改编,Java 互操作也能看到编译器生成的表示细节。

Valhalla 值类是 JVM 原生对象模型:

维度 Kotlin value class Valhalla value class
实现层 Kotlin 编译器编码 Java 语言 + class file + JVM
字段数 一个底层属性 可有多个实例字段
装箱模型 位置相关的 wrapper/underlying value JVM 直接理解无身份对象
泛型 常见场景仍装箱 长期目标是与特化协同
Java 互操作 名称改编与装箱规则较复杂 目标是平台统一模型
主要用途 类型安全的轻量 wrapper 通用复合值与紧凑布局基础

Kotlin value class 仍非常适合 ID、单位和受约束单值;Valhalla 要解决的是 JVM 生态共同使用的多字段值模型。未来 Kotlin 编译器可以选择映射到 Valhalla 能力,但这需要语言与 ABI 演进,不会自动发生。

与 C# struct、Rust 的差异

C# struct 从语言开始就有值类型语义,默认嵌入字段、数组和栈位置,但也存在 boxing、复制大 struct 的成本、可变 struct 陷阱以及 generic constraint 等复杂性。Java 不能直接复制这条路线,因为几十年的 class file、反射、泛型擦除和库二进制兼容都以引用模型为基础。

Rust 默认按值移动或复制,布局、所有权与借用检查在编译期形成完整系统;没有 GC,也不需要保持 Java 那种动态类加载与统一对象层级。Valhalla 只借鉴“值可以没有身份”的结果,不会把 Java 变成所有权语言。

语言 核心选择
Java Valhalla 在现有对象模型中显式放弃身份,由 JVM自适应表示
C# class 与 struct 形成长期存在的引用/值二分
Rust 值语义、所有权与生命周期共同决定移动和存储
Kotlin/JVM 编译器对单字段 wrapper 做位置相关消除

性能价值不只来自“少一次分配”

Valhalla 最可观的收益往往来自整个内存层次:

  • 更少对象头和 padding,降低 heap footprint;
  • 更少引用,减轻 GC root/heap scan 压力;
  • 连续数组提高 cache line 利用率;
  • 减少 pointer chasing 和 TLB 压力;
  • 更容易在寄存器中传递复合值;
  • 领域类型不必退化成 parallel primitive arrays;
  • 泛型特化落地后,可消除 wrapper allocation 与 boxing。

但扁平化也并非永远更快。大值频繁复制可能比复制引用贵;更新一个大元素要考虑原子性;稀疏数组需要付出完整元素空间;多态与 nullable 场景可能仍适合间接引用。

正确的性能模型是“让 JVM 拥有更多布局选择”,而不是“所有对象都应该 inline”。

迁移与设计检查表

准备把类型改成 value class 时,应逐项确认:

  • 相同字段状态的实例是否完全可互换;
  • 是否使用过 == 来判断别名关系;
  • 是否进入 IdentityHashMap、identity set 或对象锁;
  • 是否依赖 System.identityHashCode;
  • 是否被 ORM、代理、序列化框架按实体处理;
  • 是否存在自引用字段或依赖对象图节点身份;
  • 字段引用的对象是否真的满足领域不可变性;
  • 大值复制是否可能抵消局部性收益;
  • 目标 JDK 的预览 class file 是否能被工具链识别。

预览期还必须锁定 JDK 版本,编译和运行都启用预览,并重新验证字节码工具、agent、反射框架、序列化、JNI/FFM 与调试器。不要把预览 class file 当作长期稳定的公共 ABI。

结论:Valhalla 改写的是对象与数据布局之间的契约

Java 过去把“领域抽象”和“对象身份”捆在一起。为了拥有方法、封装和类型安全,开发者通常也被迫接受独立分配、对象头、引用间接层和装箱。

Value Object 将两者拆开:一个类型仍然可以有构造器、方法、接口和封装,却明确声明实例只由字段值决定。JVM 因此可以拆解、复制、内联或重新物化它,而不改变程序可观察行为。

Valhalla 的价值不是让 Java 模仿某一种 struct,而是让抽象不再天然意味着内存间接层。JEP 401 建立无身份语义,JEP 539补上可靠初始化;null-restricted storage 与泛型特化将继续把这份语义转化为可预测的数据布局和泛型性能。

延伸阅读