Valhalla项目经过十二年设计迭代,JEP 401(值类和值对象)以预览形式进入JDK 28。值对象移除对象身份,使JVM能进行标量化和堆扁平化,实现类可读性与原始类型内存密度的统一。JDK 28仅为第一阶段,非空类型和特化泛型留待后续版本。
Valhalla项目交付JDK 28预览版
2014年启动的Valhalla项目,其核心目标是用一句话概括:“代码像类,运行像int”。经过十二年反复推敲与舍弃,JEP 401(值类和值对象)终于以预览形式进入JDK 28,但默认禁用。这项变更涉及超过19.7万行代码、1816个文件,连其他提交者都被要求在此期间暂缓大型提交。
Brian Goetz迅速泼了冷水:“这只是Valhalla的第一部分。”社区里流传多年的玩笑是——我们可能比Valhalla项目本身更早抵达北欧神话中的英灵殿。你得先有成就,才能招来黑粉。
这次变更移除了Java自1995年以来最根本的假设:每个对象都有身份(identity)。值对象没有身份,这让JVM能以密集方式将它们存储在内存中,像原始类型(int、long)一样紧凑,同时保留类的可读性——有名字、有构造器、有验证、有方法。
不过JDK 28只包含第一部分。非空类型(null-restricted types)和完整特化泛型(specialized generics)都还没到。接下来我们从头讲起:2014年的问题、中间被抛弃的各种想法、以及JDK 28里你到底能用到什么。
问题根源:Java的内存布局与硬件脱节
Java里除了八个原始类型(int、long、double、boolean等),一切都是引用类型。当你写Point p = new Point(1, 2)时,变量p不是那个点。p是一个指针,一张寄存牌。堆上某个地方有一个对象,你手里攥着它的地址。
每次你想读取一个字段,JVM都必须“去寄存处取货”,通过指针做一次跳转(指针间接寻址)。对于单个对象这没什么,问题出在规模上。
堆上每个对象都有自己的对象头(header),大约十几个字节的元数据:JVM需要知道它是什么类型、有没有人在对它做同步。顺便一提,这正是Lilliput项目最近在解决的事情——帮忙缩小对象头大小。但对象头不是全部。每个对象都得分配,之后还得被垃圾回收。而且对象在堆上是分散的,一个包含百万个Point的数组,实际上是百万张寄存牌,指向散落在整个仓库各处的百万个盒子。
Brian Goetz在“Valhalla现状”文档里称这种内存布局为“毛茸茸的”(fluffy):蓬松、臃肿。我们梦想的是密集布局(dense layout),数据紧挨着数据。
为什么密度如此重要
硬件变得比Java快多了。1995年,一次内存访问和一次CPU操作耗时差不多。现在CPU比主内存快两个数量级,整个差距靠缓存(cache)来填补。处理器以缓存行(cache line)为单位读取内存,通常是64字节。如果数据密集且有序,一次读取就拉进来一大堆有用值。如果我们到处跳指针,每次访问都可能缓存未命中(cache miss),比命中慢上百倍。这就是局部性原理(locality of reference),整场游戏的真正赌注。
“但JVM有逃逸分析(escape analysis)啊。”有人会说。确实:虚拟机可以识别出某些对象从未“逃逸”出一段局部代码,然后干脆不分配它。从程序员角度看,对象好像存在,但实际上它的字段被拆开成普通变量或CPU寄存器。最理想情况下,分配开销和后续GC清理成本几乎降到零。
麻烦在于这个优化不可预测且脆弱。它只在JIT编译器能高置信度地追踪对象完整流向时有效。但只要对象落到另一个类的字段里、存进数组、传进更复杂的方法、或者出现在JIT能分析的范围之外,这套把戏就失效了。源代码完全一样,性能表现却可能天差地别。
这就是为什么有经验的JVM程序员把逃逸分析当作锦上添花,而不是项目地基。如果应用性能取决于某个特定JIT版本能不能用上这个优化,那就很容易掉进难以预测的性能衰退陷阱。一次小重构、一次JDK更新、或者代码结构变化,都可能让对象重新回到堆上,分配和GC开销全面卷土重来。
那就只剩下笨办法:放弃对象,手动编码数据。不用Color类,用三个字节r、g、b来存。这不只是学术例子。游戏引擎、图形库、图像处理系统、数据库、分析引擎、高性能计算代码里,这种方法用了好多年——只要每个字节和每次分配都重要。问题在于,速度的代价是安全和可读性。我们失去了名字、私有状态、验证和方法。JEP 401给了一个简单例子:开发者对着“原始”颜色字节工作时,可能误把BGR当成RGB,红蓝互换,整张图悄悄损坏。类不会允许这种事。一个光秃秃的int?当然会。
正是这种两难——要么方便的类,要么快速的原始类型——Valhalla试图消除。
Valhalla简史:十二年淘汰之路
官方说法,Valhalla项目启动于2014年。James Gosling当时形容它是“六个博士缠成一个结”,毫不夸张。有趣的是,这个想法比项目本身更老:Java的创造者在语言第一个版本时就想要值类型,但1995年放弃了,因为问题太难。
目标定得很高:让编程模型和现代硬件的性能特征重新对齐。换句话说,让程序员能声明自己的类型,这些类型在内存里像原始类型一样平整、密集,但外观和行为像普通类。
说起来容易做起来难。接下来几年,团队做了五个不同原型,每个探测问题的不同侧面。这里才是故事最有趣的部分——要理解Valhalla现在的形态,得先看有多少想法死在了路上。
早期原型走向了我们今天称为“Q World”的方向。它假设新的值类型在本质上是跟对象不同的东西,有独立的类型描述符、独立字节码、独立顶层类型,就像原始类型一样。听起来合逻辑:既然它们要像int一样工作,就让它们像int一样表示。问题在于,这种分离给整个JVM类型系统灌入了额外复杂性——每件事都得做两套。
突破来自一个命名为“L World”的原型(大概在2019年左右)。这个名字来源于值类型开始与对象引用共享同一个“L载体”(L描述符,JVM用于普通引用的那个)。团队本来以为这种统一太难,结果出乎他们意料,它行得通,没有重大妥协,还顺带解决了早期版本的一堆问题。
L World带来了另一个根本性的“啊哈”时刻,塑造了后来的一切:语言模型和JVM模型不必百分之百重叠。L World是虚拟机的正确模型,但你可以把它当作翻译目标,在语言层面给程序员提供更方便的东西。这种层次分离成了后续项目的关键。
也是在那个时候,分两阶段实施的计划定型了:先是值类(当时还叫别的名字,稍后会讲),然后才是特化泛型。泛型我们留到第六节再说,因为那是另一篇长论。
名字变迁史:概念演进的缩影
如果你试过阅读Valhalla相关资料,被一堆相互矛盾的术语搞得晕头转向,那不是你的错。命名在这里改了好几次,而且不是表面功夫——每次改名背后都是模型的改变。我们捋一遍,因为这是理解这个特性如何设计出来的最佳途径。
第一阶段:值类型(value types)。最早的术语。含糊,因为当时还不清楚这些东西到底是什么。
第二阶段:内联类(inline classes)。2019-2020年左右,一个区分确立下来,其本质保留至今:类分成有身份的类(identity classes,也就是我们迄今为止知道的一切)和新的内联类(无身份)。“代码像类,运行像int”这个口号就是那时提出的,基本约束也定下来了:内联类默认final,字段final,不能在上面做同步。
第三阶段:“原始类”(primitive classes)和双投影模型。这里就有意思了,因为这个想法被大幅削减了。在2021年的“Valhalla现状”文档里,Valhalla承诺了三样东西:值对象、原始类、特化泛型。“原始类”的想法是,一个类型有两种投影:值变体(平整、永不空、行为像原始类型)和引用变体(装箱、允许null)。在不同迭代中,这被写成Point.val/Point.ref,后来还试过Point!和Point?语法。
这个模型强大,但心智负担也重。程序员日常得在同一类型的两种形态之间来回切换,还得理解它们什么时候发生转换。团队遵循“为用户简化模型,哪怕牺牲性能上限”的教训,最终拆掉了这个二元对立。
第四阶段(今天):“值类”(value classes)和“值对象”(value objects)。当前的JEP 401,作者Dan Smith(审阅人:Brian Goetz),说得很简单。有一个新东西:用value修饰符声明的值类。它的实例是值对象:没有身份的对象。关键在这里——值类仍然是引用类型。整个关于非空性的复杂事务被拆分到一个单独的、可选的JEP(空限制值类类型,Null-Restricted Value Class Types)里,我们后面会讲到。所以不是一个复杂概念,而是两个简单正交的概念:“有没有身份?”以及,分开来,“允不允许null?”
值得记住,因为如果你看到一篇旧文章(或者Baeldung把“原始类”描述成一种独立机制),你读到的是过时模型。在OpenJDK正典里,那种意义上的“原始类”已经不存在了。
更多东西倒在了路上。原来的“值对象”JEP草案被撤回,换成JEP 401。原来的“通用泛型”草案也回去重做了。JEP 401附带JEP 402(增强原始装箱,也是预览),还有一整套早期访问构建(LW1、LW2、LW3……)以及JVM语言峰会的演讲,包括Frédéric Parain讲堆扁平化(heap flattening)和Daniel Smith讲新的对象初始化模型。
这一节的教训是:十二年不是“写代码”的十二年。是十二年的拒绝想法,直到剩下那个真正能维护的。
JEP 401:到底给了我们什么
来具体看看。我们得到什么。
声明。通过添加value修饰符来创建值类:
java
value class USDCurrency implements Comparable {
private int cents; // 隐式final
public USDCurrency(int dollars, int cents) {
this.cents = dollars * 100 + cents;
}
public USDCurrency plus(USDCurrency that) {
return new USDCurrency(0, this.cents + that.cents);
}
// dollars(), cents(), compareTo(), toString()...
}
它也可以是值记录(value record)。规则:所有实例字段隐式final,方法不能是synchronized,类默认final(也可以形成由值类和抽象值类组成的继承体系),不能继承有身份的类,但可以愉快地实现接口。除了这些约束,它就是普通类。
定义性特征:没有身份。这是关键。普通对象有身份:两个分别创建的new Point(1,2)是两个不同对象,即使内容完全相同。值对象没有身份,就像不存在两个“不同”的整数4。从这个特征流出了所有后果:
==的含义变了。到现在为止==比较的是身份(是不是同一个地址)。对于值对象,==检查的是可替换性(substitutability):两个值是不是同一个类,字段是否相同(原始类型按位比较,对象字段再通过==递归比较)。所以new USDCurrency(3,95) == new USDCurrency(3,95)返回true。这是好消息:结束了==在Integer上那个著名的混乱。但小心:==看的是内部状态,不一定等于对象代表的东西,所以“是不是同一份数据”还是继续用equals。
synchronized抛出异常。没什么可同步的。尝试会抛出IdentityException。当需要强制身份时,有新工具Objects.requireIdentity和Objects.hasIdentity。
现在最重要的概念陷阱:值对象仍然可以是null。这让所有认为“值=像原始类型=永不空”的人都吃了一惊。在JDK 28的模型里,值类是引用类型,所以USDCurrency d = null;完全合法。非空类型(带空限制)是一个单独的、未来的JEP。它们不在JDK 28里。我们还会回到这个话题,因为这不是细节——它是达成完整性能的关键。
两种优化机制:标量化和堆扁平化
JEP 401给了JVM自由,通过两种主要方式优化值对象。
标量化(Scalarization)是一种JIT编译器技术。对值对象的引用被“分解成它的质因数”,还原成本质:一组字段,没有包装。JIT不传指向Color的指针,只传三个字节r、g、b(外加一个标志位表示引用是否非空)。这样的对象实际上免费:没有分配,没有GC工作。有点像逃逸分析,但可预测得多、范围广得多——即使跨过JIT没有内联的方法调用边界也能工作。局限:标量化通常不适用于变量类型是值类超类型的情况(比如Object,或者重要的,擦除后的泛型参数)。这时对象必须在堆上物化(materialized)。
堆扁平化(Heap flattening)是第二种机制。对象的本质被编码成紧凑的位向量,直接写入字段或数组单元,没有指向内存另一处的指针。密度和局部性正是从这里诞生的。
不过这里有个值得知道的陷阱:扁平化数据必须能原子地读写(否则并发访问下有“撕裂”风险)。在典型平台上,“足够小”目前意味着最多64位,包括空标志。所以许多小型值类会漂亮地扁平化,但一个有比如两个int字段或一个double的类可能放不进原子写入,到头来还是作为普通对象在堆上。未来128位编码会到来,前面提到的关于空限制类型的JEP将允许以放弃原子性保证为代价扁平化更大的类。这恰恰是非空性不再是表面功夫、而成为性能杠杆的时刻。
装箱成本与Integer的命运
还记得古老的装箱成本吗?把int包进Integer?在新模型里,包装类本身变成值类(预览开启时,Integer、Long、Double等失去身份)。因为装箱不再有身份,JVM可以对它做标量化和扁平化。效果:Integer开始接近int的效率,装箱开销——引用JEP 401的话——大幅缩减。配套的JEP 402(增强原始装箱)走得更远,平滑了原始类型与其装箱之间的转换,为写List这样的东西打开了道路。但那是另一块仍在成熟中的部分,别假设它会跟401一起完整落地。
这里最能体现效果。不用存百万个指向百万个分散对象的指针,一个Color数组可以直接存储扁平化的、32位编码的连续颜色(再说一次:外加一个空标志)。从内存角度看,这样的数组开始表现得像一个普通的int:一块连续的数据块,处理器逐缓存行顺序扫过去。
一个具体例子:Point数组的前世今生
为了让这一切工作,一些非常深的基础被挪动了:新的value修饰符;严格的构造规则(所有字段必须在任何东西看到新对象之前设置好,实际上是在super()调用之前,这样对final字段的“修改”永远不可见);==重新定义为可替换性测试;在引用比较字节码(acmp)中加入值对象检查;标量化和扁平化机制;IdentityException;以及现有“基于值”(value-based)类的迁移。简而言之,这不是语法糖。这是对Java自1995年以来就一直成立的一个假设的重建:每个对象都有身份。
我们拿最简单的例子来追踪一遍,即使不了解JVM内部也能看明白。
Valhalla之前:
java
final class Point { // 普通类,有身份
final int x;
final int y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point points = new Point[1_000_000];
内存里发生了什么?points数组是百万个指针。每个指针对应堆上某处一个单独的Point对象。每个这样的对象不只是它的两个int(8字节),还有一个对象头(另外十几个字节元数据)。这些对象是分散的:分配器在不同的时刻、不同的位置创建了它们。当你遍历数组累加坐标时,每个点处理器都得:从数组读指针,跳到指定地址(缓存未命中风险),读字段。一百万次。这就是第一节说的“毛茸茸”布局。
Valhalla之后:
java
value class Point { // 值类,无身份
final int x;
final int y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point points = new Point[1_000_000];
代码上的区别只有一个词:value。但内存里的区别是根本性的。JVM现在可以把值本身存储在数组里,密集地一个接一个排列:每个点8字节(外加可能的空标志),连续块。每个元素没有对象头。没有指针。不在堆上跳来跳去。
当你现在遍历数组时,处理器顺序读取数据。每个64字节缓存行立刻带来好几个完整的点。以内存带宽速度累加百万个坐标,而不是卡在缓存未命中上。对数据密集型代码,那可能是数倍而不是百分比的差距。
最重要、对可维护性最有利的是,你没有为它付出抽象代价。Point仍然是类:有名字、有构造器、可以有验证(if (x < 0) throw ...)、可以有方法。你不用像以前那样把点拆成两个原始int xs和int ys数组,祈祷永远不会搞混下标。你得到了原始类型的密度和类的可读性。这就是整个Valhalla项目在一个例子里的全部。
特化泛型:Valhalla的下半场
这是Valhalla的第二半,坦白说更难的那半。我们先从问题源头说起。
Java通过类型擦除(type erasure)实现泛型。实际上:List和List在运行时是同一个普通List,类型参数T被擦除成Object。这经常被嘲笑,但值得知道这是一个有意的、可辩护的决定,不是懒惰。擦除给了Java渐进式迁移兼容性:你可以把一个现有的非泛型类变成泛型,而不破坏任何现有源文件或编译后的类,客户端可以立即迁移、稍后迁移、或者永远不迁移。2004年,当Java已经有了庞大的代码库时,替代方案(“给泛型,但扔掉所有库”)会是一笔糟糕的交易。今天会更糟。
问题在于擦除在性能最关键的节点上与Valhalla冲突。因为T擦除成Object,放进List的值对象必须在堆上物化为普通对象。换句话说:你那个漂亮的、可扁平化的Point在泛型集合里失去了扁平化——容器持有的是引用,不是扁平值。你在Point中获得的所有密度在ArrayList中蒸发了。
修复计划跟Valhalla一样,分两阶段:
第一阶段:通用泛型(Universal Generics)。这是语言层面的变更:让类型变量也能覆盖值类型,也就是说你甚至可以表达ArrayList或List。目前仍然通过擦除。程序员主要会感觉到关于“空污染”(null pollution)的新编译器警告,因为T类型的字段默认是null,即使T是值类型。处理这些警告使API“为特化做好准备”。
第二阶段:特化泛型(Specialized Generics)。这是未来的JVM扩展,将为具体类型参数生成异构的特化类布局(项目术语中:species和type restrictions)。只有到那时ArrayList才能真正由扁平内存支持。这部分很大程度上仍是研究工作。
对库和框架的影响是巨大的,这正是一步步推进的原因。最终,集合、流、整个API都可以在值类型上变得扁平且无分配。但库作者得处理新警告并面向特化设计。说实话,原来的通用泛型草案经过了返工,特化的完整回报是未来版本的事。JDK 28不带它。
哪些来了,哪些没来
放在一起整理一下,因为很容易在“已经到了!”和“还没到!”之间迷失。
已接受:JEP 401(值类和值对象)作为预览特性,目标JDK 28(2027年3月发布),主线集成计划在2026年7月左右。19.7万行,1816个文件,Lois Foltan协调,请其他提交者暂缓大型变更。默认禁用:要玩语法,得打开--enable-preview。
实际到达用户手里:能够声明value class和value record;JDK中现有“基于值”的类(包括原始包装类如Integer)在预览下迁移为值类;符合条件的类的标量化和扁平化;更便宜的装箱。
可能还会演变的,以及不在28里的:空限制类型(非空);完整特化泛型;128位编码;完全成熟的JEP 402。语法本身也是预览状态,正是人们对预览的预期:它可以在各版本间根据反馈变化。所以Goetz那句“只是第一部分”。
对生态可能的影响:对高性能Java(数据、向量计算、机器学习、游戏开发、金融、编解码器)来说,这是在不放弃抽象的前提下获得密集数据的路径,正是有些领域等了好多年的。框架和库将开始迁移它们的基于值类。你还需要注意==和synchronized在代码中(有意或无意)依赖身份的行为变化的长尾效应。规划时还有一点值得记住:JDK 28不是“LTS”版本——下一个LTS可能是2027年9月的JDK 29。所以大多数公司只会在LTS遇到稳定版的Valhalla,但正是28的预览启动了与实际代码的反馈循环。如果你在从事可能从中受益的工作,现在就是开始实验和提交反馈的时候。
为什么这是平台历史上最大的变化之一
为什么我称这是平台历史上最大的变化之一?
因为Valhalla不是在语言上再挂一个特性;它在移动它的最深假设。从1995年起“每个对象都有身份”在Java中一直成立;它是其他一切立足的基础。让程序员选择退出这个假设(选择哪些对象需要身份、哪些不需要)不是重构,是地基的偏移。这正是它解锁未来十年工作的原因:统一原始类型和对象、特化泛型、更密集的集合、更快的数值计算。
同时,这也是标题的诚实版本,“Valhalla进入JDK 28”是半真半假。它是多阶段推出的第一个预览步骤。但正是这个团队的纪律性(为人简化模型,把困难的性能工作做成可选)——花了十二年的原因,也是现在能交付的原因。
对我们程序员来说,一个需要内化的东西比语法更重要:区分身份(identity)与值(value)。其余(==、扁平化、泛型)都是这一个区分的后果。早期访问构建已经在这里了:你可以在竞争对手之前在自己的代码上摸到它。
常见问题
值类就是record吗?不,它们是两个正交的决定。record意味着“我放弃独立的内部状态”(内容=组件)。value意味着“我放弃身份”。
四种组合都可以:普通类、record、值类、值record。
能用==比较值对象吗?可以,但==现在含义不同了:可替换性,即所有字段的递归比较,不是内存地址。
对于“它们是否代表同一份数据”这个问题,通常还是用equals更好,因为==看的是内部状态,不总是等于代表的状态。
值类可以是null吗?在JDK 28模型里,可以。value class仍然是引用类型。非空类型(带空限制)是另一个未来的JEP,它们才是解锁更大值类扁平化的关键。JDK 28里没有。
Integer变成值类,会破坏我的代码吗?大多数情况下不会。二进制仍然链接,唯一的新编译错误是尝试在这种类型上做同步。你可能注意到的变化涉及依赖身份的代码:Integer上的==开始按值比较,synchronized(someInteger)会失效。如果你依赖其中任何一个,那本来就是脆弱的代码。
我能得到快速的、扁平的ArrayList吗?还不行。由于类型擦除,泛型集合中的对象在堆上物化。扁平泛型集合需要通用和特化泛型——那是未来。JDK 28中,扁平化直接作用于值类型的字段和数组,比如Point。
这和C#的struct有什么不同?
C#的struct有身份和可变性,所以赋值或传递时的复制语义必须精确定义,给程序员更重的模型,给运行时更少的自由。
Valhalla的值对象没有身份,它们在内存中的布局方式由JVM自行决定。对人更简单的模型,对机器更多的自由。
逃逸分析不是已经做完了这些吗?如我前面提到的,部分。逃逸分析可以在证明对象不依赖身份时避免分配,但它不可预测,且当对象落到字段、数组或逃逸出优化范围时就帮不上忙。值对象的标量化是可预测的,且范围远得多,包括跨方法调用边界。
我需要重写代码才能受益吗?
对于你自己的类,通常在那些表示“简单领域值”且不依赖身份的类上加上value修饰符就够了;
迁移大多是兼容的。有些收益你甚至能免费获得,因为JDK在迁移自己的类(比如原始包装类)。
我什么时候能看到完整的Valhalla,包括泛型、非空类型和其余部分?
在未来的版本中。团队增量交付:JDK 28是值类的第一个预览。完整故事(特化泛型、空限制类型、128位编码)会分布在多个版本中,最有可能在下一个LTS附近稳定下来。
附注:早期访问构建可以在jdk.java.net/valhalla找到,这可能是形成你自己观点最快的方式。
作者单位背景
Artur Skowronski,JVM Weekly主编,Java性能与语言特性独立研究者。
Hacknews极客辣评
虽然Valhalla经过十二年终于有成果进入JDK 28,但许多网友觉得实际交付的功能太有限了。ArrayList
部分讨论集中在非空类型的设计取舍上。有观点认为,既然值类的主要价值之一就是性能,让它们支持null反而增加了额外的标志位开销,限制了许多实际场景的优化空间。虽然官方计划在未来通过单独的JEP来实现非空限制,但这种分阶段推进的方式让大家觉得核心问题还要等很久。
不少评论把Valhalla和C#的struct做了对比。C#从一开始就支持值类型,并且通过泛型特化实现了List
整体来看,评论区对Java发展方向的评价两极分化。一方认为Oracle接手后Java进步显著,虚拟线程、模式匹配、值类型等特性稳步推进,保持了平台的生命力;另一方则认为Java在关键决策上过于保守,把“简化模型”当成挡箭牌,迟迟不敢解决空安全等核心问题,迟早会被更现代的语言甩开。
极客一语道破
值对象主要特点是没有身份,也就是没有对象引用,或者说没有指针,类似语言中没有指称的语言,比如指鹿为马,这个词语好像是指称,其实是值对象,无论真实世界那个动物是鹿或是马,都无所谓,也可以称之为XXX,关键是这里体现了语言的自生成特性,语言可以自己生成自己,AI大语言模型像chatGPT吐出的词语都是“值对象”,AI不懂语义,但是懂词语的上下文关系,这就够了。
因为人类的肉体生活在物理世界,我们有各种感知,因此我们的思维除了胡说八道的值对象,还必须将这些对象加上指针、指称或引用,指向内存中实在的地址,而实体对象代表物理世界的订单、商品等概念,一边指向物理世界,一边指向内存地址,这事面向对象编程OOP的语言桥梁作用。