重新思考 Java 中的领域原始类型与 Project Valhalla

多年来,Java 开发者一直在类型安全与性能之间进行令人沮丧的权衡。一方面,使用领域原始类型——能够编码业务约束的类型(例如,用 PositiveInt 代替原始的 int)——可以让编译器拒绝无效状态,从而防止 bug。另一方面,将原始类型包装在类中会产生带有头部和指针的堆对象,导致显著的内存开销以及在性能关键的“热循环”中出现缓存未命中。

历史上,经验法则是只在系统边界处细化类型,而在核心逻辑中恢复使用原始的基本类型以保持速度。Project Valhalla 通过引入 值类 改变了这一局面,使 JVM 能够将包装对象直接展平到寄存器、封闭对象或数组槽位中。

包装类的性能税

要理解为何需要 Valhalla,必须先看看标准 Java 包装类的内存布局。一个简单的 class PositiveInt { final int v; } 在 HotSpot 上通常占用 16 字节(12 字节的对象头加上 4 字节的整数)。此外,这类对象的数组并不直接存储数值本身,而是存放指向这些对象的 4 字节引用,这些对象散落在堆中。

对于处理数百万事件的流处理器来说,这种架构是灾难性的。包装对象的内存开销是原始 int 的四倍,而且每一次访问都需要一次“指针追踪”,往往导致一次缓存行加载并可能产生缓存未命中。这种开销正是开发者在热路径中传统上回避细化类型的原因。

实现细化类型

在 Scala 等语言中,细化类型可以在编译期通过谓词进行约束。Java 并没有这种原生机制,这意味着约束只能在对象构造时检查。典型的实现方式是基于一个 Refined<T> 基类并进行具体扩展:

private static class PositiveInt extends Refined<Integer> {
    public PositiveInt(int value) {
        super(i -> i > 0, value);
    }
}

虽然这提供了任何 PositiveInt 实例在静态层面上都是有效的保证,但基本类型的装箱仍然是瓶颈。这正是 Project Valhalla 的 value 关键字发挥作用的地方。

引入 Project Valhalla 与值类

值类是没有身份(identity)的对象。它们拥有行为和不变式,但存储方式类似基本类型。使用 value 关键字,开发者告诉 JVM 该类型没有身份,从而允许 JVM 在任何出现该对象的地方内联其字段。

下面展示了使用 Valhalla 预览(JEP 401)实现领域原始类型的示例:

public value class PositiveInt implements RefinedInt<PositiveInt> {
    private final int value;

    public PositiveInt(int value) {
        if (value <= 0) {
            throw new IllegalArgumentException("must be positive: " + value);
        }
        this.value = value;
    }

    @Override public int value() { return value; }
}

通过使用像 RefinedInt 这样的专用接口而不是通用的 Refined<T>,可以避免装箱。得到的类型在提供与普通类相同的安全性的同时,拥有基本类型的性能。

扩展到多字段类型

该模式同样适用于不仅仅是单值包装的情况。一个包含 LatitudeLongitude(均为 double 支持的值类)的 Coordinate 值类,使 JVM 能够在每个槽位中存放 16 字节的连续 double。相比之下,等价的身份类会存放指向分散在堆中的对象的引用,每个对象都有自己的头部。

数字上的影响

在 64 位 HotSpot(开启压缩 oops)上的基准测试展示了 10 元素数组的内存占用差异:

  • int[10]:56 字节(裸基本类型)
  • PositiveInt[10](身份类):216 字节(领域感知但代价高)
  • PositiveInt[10](值类):56 字节(廉价且具领域感知)

值类实现了与裸基本类型完全相同的内存布局和缓存行为,实质上消除了领域建模的“性能税”。

采用时的关键考量

虽然值类是强大的工具,但它们带来了若干语义上的转变,开发者需要加以注意:

1. 相等语义

值类通过值比较,而非指针比较。对值类使用 == 运算符实际上是逐字段的可替代性检查,等价于实现良好的 equals() 方法。将已有的身份类迁移为 value 类会悄然改变所有依赖引用相等性的代码行为。

2. 可空性

值类不能为 null。Project Valhalla 正在引入受限空引用(例如 PositiveInt! 表示非空,PositiveInt? 表示可空),这使编译器能够在热路径中消除空检查。

3. 泛型鸿沟

目前 List<PositiveInt>Optional<PositiveInt> 仍会因类型擦除而产生装箱。泛型特化尚未完全实现;因此在关键路径中,为了获得最佳性能,应该使用类型化数组(PositiveInt[])而非集合。

4. 框架集成

大多数 Java 框架(Jackson、JPA、Bean Validation)期望的是基本类型或 String。值类型需要轻量的适配器。例如,针对 PositiveInt 的 Jackson 反序列化器只需在 p.getIntValue() 的返回值上包装一次 new PositiveInt(...) 即可。

结论

Project Valhalla 正在弥合高级领域建模与底层性能之间的鸿沟。通过让类型能够“像类一样编码,却像 int 一样运行”,Java 正在消除在整个应用程序中使用强领域原始类型的最后一道障碍。虽然仍处于预览阶段,值类已经预示着一个安全性与正确性(由类型系统编码)不再付出性能代价的未来。

Sources