Project Valhalla, Explained: How a Decade of Work Arrives in JDK 28
TL;DR · AI 摘要
JDK 28 将引入 Project Valhalla 的价值类,使 Java 对象能像原始类型一样高效运行,但目前仍处于预览阶段。
核心要点
- JDK 28 将引入 Value Classes,使 Java 对象能像 int 一样高效运行。
- Project Valhalla 的实现涉及 197,000 行代码,覆盖 1,816 个文件。
- Value Classes 目前是预览功能,且默认禁用。
结构提纲
按章节快速跳转。
- §引言
JDK 28 将引入 Project Valhalla 的价值类,但目前仍处于预览阶段。
Valhalla 的目标是让 Java 对象像原始类型一样高效运行。
Java 中的对象是引用类型,导致内存和性能开销。
Project Valhalla 的实现涉及大量代码,且仍需进一步完善。
Value Classes 是预览功能,且目前只实现了 Valhalla 的一部分。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Project Valhalla in JDK 28
- 目标
- 让对象像原始类型一样高效运行
- 代码可读性与性能兼顾
- 实现挑战
- 大量代码改动(197,000 行)
- 预览功能,目前只实现了一部分
- Java 对象模型问题
- 引用类型导致内存开销
- 对象头信息增加性能负担
金句 / Highlights
值得收藏与分享的关键句。
The change is so large that the remaining committers were asked to hold off on bigger commits during the integration.
The slogan Valhalla has carried from the start is: 'codes like a class, works like an int.'
Every object on the heap has its own header (a dozen-or-so bytes of metadata).
项目 Valhalla 解读:十年的工作最终在 JDK 28 中实现 - JVM Weekly 第 180 期
新的 JVM Weekly 来了... 看起来 Ragnarok 即将到来,因为 Valhalla 终于进入了 JDK。不过,情况有点...微妙。
Artur Skowronski
2026 年 6 月 18 日
6 月 15 日,Oracle 工程师 Lois Foltan 确认了一个行业很多人已经不再相信的事情:JEP 401:值类和对象将被合并到主 OpenJDK 仓库中,并且目标是 JDK 28。
感谢阅读 JVM Weekly!免费订阅以接收新文章并支持我的工作。
这次的改动非常巨大,以至于剩下的提交者被要求在整合期间暂停较大的提交。仅这个拉取请求就增加了 197,000 多行代码,分布在 1,816 个文件中。
不过,在我们庆祝之前:这是一个预览功能,默认是禁用的,而且正如 Brian Goetz 迅速冷静下来所说,这只是 Valhalla 的第一部分。Goetz 做了一个很好的观察,即“他们永远不会发布它”的人现在会顺利地转向“但他们没有发布最重要的部分”(多年来,社区一直流传着一个玩笑,我们更可能自己进入 Valhalla,也就是北欧的来世,而不是这个项目发布)。
你必须自己赢得自己的敌人。
所以,这是一个讲述整个故事的好时机。这个问题是一个深入的分析,假设你之前从未关注过 Valhalla 的工作:从 2014 年的问题,到想法的演变(其中不少最终被扔进了垃圾桶),一直到我们将在 JDK 28 中实际接触到的内容。为自己泡一杯咖啡。我一直在等待这个版本,就是为了这个时刻。
1. 引言 - 这到底是什么
Valhalla 从一开始的口号就是:“像类一样编写代码,像 int 一样运行。” 用一句话概括了这个项目的全部要点:我们希望编写正常、可读的类,带有方法、构造函数验证和合理的字段名称,但希望 JVM 能够像处理基本类型一样高效地处理它们。
要理解为什么这是一个问题,你必须回到 Java 的基础。在这个语言中,除了八个基本类型(int、long、double、boolean 和其余的),一切都是引用类型。当你写 Point p = new Point(1, 2) 时,变量 p 并不是一个点。变量 p 是一个指针,一个衣帽间号码:在堆的某个地方有一个对象,而你拿着一张写着它地址的纸条。每次你想读取一个字段时,JVM 都必须“去衣帽间”,通过指针进行一次跳转(指针间接访问)。
对于单个对象来说,这无关紧要。问题出现在规模上。堆上的每个对象都有自己的头(大约十几个字节的元数据:其中包括 JVM 用来知道对象类型和是否有人在它上面进行同步的信息)。顺便说一下,这正是 Project Lilliput 最近一直在解决的问题,帮助减小对象头的大小。但头的大小并不是全部。每个对象都必须被分配,之后还要被垃圾回收。由于对象在堆上是分散的,实践中,一百万个 Point 的数组实际上是一百万张纸条,指向堆中散落在整个仓库里的百万个盒子。
Brian Goetz 在他的“Valhalla 状态”文档中,将这种内存布局称为“fluffy”:膨胀、臃肿。我们所梦想的是一个密集的布局,其中数据紧密排列在一起。
密度为何重要?因为硬件的发展速度比 Java 快得多。1995 年,一次内存访问的成本大致与一次 CPU 操作相当。如今,CPU 的速度比主内存快两个数量级,而整个差距由缓存来弥补。处理器以称为缓存行(通常为 64 字节)的块来读取内存。如果数据密集且有序排列,一个这样的块可以一次性带来大量有用值。如果我们跳转指针,每次访问都可能造成缓存未命中,而未命中可能比命中慢一百倍。这就是引用局部性,而这正是这场游戏中的真正关键。
“但 JVM 有逃逸分析,”一个敏锐的人会说。确实:虚拟机可以识别出某些对象从未“逃逸”出代码的局部片段,然后完全不分配它。从程序员的角度来看,对象似乎存在,但实际上其字段被分散到普通变量或 CPU 寄存器中。在最佳情况下,分配的成本以及垃圾收集器后续的清理成本几乎降至零。
问题是,这种优化不可预测且脆弱。它只在 JIT 编译器能够以高度信心追踪对象的整个流程时才有效。但只要对象出现在另一个类的字段中、被存储在数组中、被传递到更复杂的函数中,或者出现在 JIT 无法分析的代码边界之外,整个技巧就失效了。源代码保持不变,但性能行为可能会发生巨大变化。
这正是为什么经验丰富的 JVM 程序员将逃逸分析视为一种不错的附加功能,而不是项目的基础。如果一个应用程序的性能取决于某个特定的 JIT 版本是否能够应用这种优化,那么很容易陷入难以预测的性能退化陷阱。一次小的重构、一次 JDK 更新,或代码结构的更改,都可能使对象重新回到堆中,分配和垃圾收集器工作的成本将再次全面回归。
这使得只剩下一种暴力方法:放弃对象,手动对数据进行编码。而不是使用 Color 类,直接保存三个字节 r、g、b。这不仅仅是一个学术上的例子。这种方法多年来已被用于游戏引擎、图形库、图像处理系统、数据库、分析引擎和高性能计算代码中,其中每字节内存和每次分配都至关重要。问题是,这种速度是以安全性和可读性为代价的。我们失去了名称、私有状态、验证和方法。JEP 401 提供了一个简单的例子:一个正在处理“原始”颜色字节的开发人员可能会错误地将它们解释为 BGR 而不是 RGB,将红色与蓝色交换,并悄无声息地破坏整个图像。一个类不会允许这种情况发生。一个裸露的 int?当然会。
这正是 Valhalla 正试图消除的两难局面:方便的类,或快速的原始类型。
官方上,Project Valhalla 于 2014 年启动。当时 James Gosling 将其描述为“六个博士生缠成一个结”,这并非夸张之词。有趣的是,这个想法比项目本身还要早:Java 的创建者早在语言的第一个版本中就想引入值类型,但在 1995 年他们放弃了,因为这个问题太难了。
目标设定得非常雄心勃勃:恢复编程模型与现代硬件性能特征之间的一致性。换句话说,让程序员可以声明自己的类型,这些类型在内存中是扁平且密集的,就像原始类型一样,但看起来和行为却像普通的类。
说起来容易,做起来难。在接下来的几年中,团队构建了五个不同的原型,每个原型都在探索问题的不同方面。而这就是故事中最有趣的部分开始的地方,因为要理解 Valhalla 当前的形态,你必须看到在这一过程中有多少想法被放弃了。
早期的原型朝着我们现在称之为“Q World”的方向发展。它假设新的值类型本质上与对象不同,具有独立的类型描述符、独立的字节码和独立的顶级类型,就像原始类型一样。听起来很合理:如果它们应该像 int 一样工作,那它们就应该像 int 一样表示。问题是,这种分离使整个 JVM 类型系统充满了额外的复杂性:每件事都必须以两种变体来处理。
突破出现在一个名为“L World”的原型中(大约在 2019 年)。这个名字来源于值类型开始与对象引用共享同一个“L 载体”(L 描述符,JVM 用于普通引用的同一个描述符)。团队原本预计这种统一会非常困难,但出乎他们自己的意料,这种统一在没有重大妥协的情况下成功了,并且意外地解决了之前几轮中的一堆问题。
L World 产生了一个更根本的“啊哈”时刻,这影响了之后的一切:语言模型和 JVM 模型不需要完全重叠。L World 是虚拟机的正确模型,但你可以将其视为一个翻译目标,并为程序员提供更方便的语言特性。这种层次的分离最终成为整个项目的钥匙。
也正是在这个时候,将工作分为两个阶段的计划逐渐清晰:首先是值类(当时还被称为其他名称,稍后会详细说明),然后才是专门化的泛型。我们将在第 6 节中再次讨论泛型,因为这是一个独立且更长的论述。
3. 思想的演变 - 命名过山车
如果你曾经尝试阅读关于 Valhalla 的内容,却被一堆相互矛盾的术语撞得头破血流,那不是你的错。这里的命名多次改变,而且不仅仅是表面上的修饰:每一次名称的改变背后都伴随着模型的改变。让我们来追踪一下,因为这最能说明这个功能是如何设计的。
阶段 1:值类型:最早的术语。模糊,因为当时还不清楚这些东西到底应该是什么。
阶段 2:内联类。大约在 2019 至 2020 年间,一种区分逐渐形成,并一直延续至今:类被划分为具有身份的类(即我们之前所知道的一切)和新的内联类(没有身份)。正是在这个时候,“像类一样编码,像 int 一样运行”的口号被提出,基本的限制条件也被设定:内联类默认是 final 的,它们的字段也是 final 的,不能对它们进行同步操作。
阶段 3:“原始类”和双投影模型。这里变得有趣,因为这正是一个被大幅削减的想法。在 2021 年的“Valhalla 状态”文档中,Valhalla 承诺了三件事:值对象、原始类和专用泛型。对于“原始类”的想法是,一个类型将有两个投影:一个值变体(扁平、从不为 null、像原始类型一样运行)和一个引用变体(一个允许为 null 的盒子)。在多次迭代中,这被写为 Point.val/Point.ref,之后他们尝试使用 Point! 和 Point? 的语法。
这个模型非常强大,但也很费脑力。程序员每天都要在同一个类型的两个形式之间来回切换,并理解它们之间的转换何时发生。团队忠实于“简化模型以方便用户,即使这意味着牺牲性能上限”的教训,最终拆除了这种二元性。
阶段 4(今天):“值类”和“值对象”。当前的 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 关于堆扁平化的演讲和 Daniel Smith 关于新对象初始化模型的演讲。
本节的寓意是:这十二年并不是“编写代码”的十二年。而是十二年不断拒绝各种想法,直到留下一个真正可以维护的想法。
4. Valhalla 当前的工作方式 —— JDK 28 中的值类模型
让我们进入具体细节。我们确切地得到了以下内容。
声明。通过添加 value 修饰符,你可以创建一个值类:
value class USDCurrency implements Comparable<USDCurrency> {
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) 是两个不同的对象,即使它们的内容完全相同。而值对象没有身份,就像没有两个“不同”的 int 类型的 4。由此引申出所有后果:
- == 的含义发生了变化。到目前为止,== 比较的是身份(是否是同一个地址)。对于值对象,== 检查的是可替换性:是否是相同类且字段相同(递归比较,基本类型字段逐位比较,对象字段再次通过 == 比较)。这就是为什么 new USDCurrency(3,95) == new USDCurrency(3,95) 返回 true。这是个好消息:它结束了在 Integer 上使用 == 时著名的混淆。但要注意:== 检查的是内部状态,而内部状态并不总是对象所代表的内容,因此对于“是否是相同数据”的比较,仍应使用 equals。
- synchronized 抛出异常。没有可同步的对象。尝试同步会导致 IdentityException。当你需要强制身份时,可以使用新的辅助方法 Objects.requireIdentity 和 Objects.hasIdentity。
现在最重要的概念陷阱是:值对象仍然可以为 null。这会让所有认为“值对象就像基本类型一样,永远不会为 null”的人感到惊讶。在 JDK 28 模型中,值类是一个引用类型,因此 USDCurrency d = null; 是完全合法的。不可为 null 的类型(带有 null 限制)是另一个、未来的 JEP。它们不在 JDK 28 中。我们稍后再回到这一点,因为它不是一个细节:它是实现完全性能的关键。
### 它在内存中的存储方式
JEP 401 给 JVM 提供了自由,使得值对象可以通过两种主要方式优化。
标量化(scalarization)是一种 JIT 编译器技术。对值对象的引用被“分解成基本元素”,简化为它的本质,即字段的集合,没有任何包装。JIT 不再传递指向 Color 的指针,而是直接传递三个字节 r、g、b(加上一个标志位,表示引用是否不为 null)。这种对象在实践中是免费的:无需分配,GC 也不需要处理。这有点像逃逸分析,但更可预测、影响范围更广:它甚至可以在 JIT 没有内联的方法调用边界上工作。限制是:当变量的类型是值类的超类型(例如 Object 或者重要的是擦除后的泛型参数)时,标量化通常无法工作。此时,对象必须在堆上被实例化。
堆扁平化(heap flattening)是第二种机制。对象的本质被编码为一个紧凑的位向量,并直接写入字段或数组单元中,而不需要指向内存中其他位置的指针。这正是密度和局部性产生的地方。
这里有一个值得注意的限制:扁平化数据必须能够原子地读取和写入(否则在并发访问时可能会出现“撕裂”现象)。在典型的平台上,目前“足够小”意味着小到仅需 64 位,包括空标志(null flag)。这就是为什么许多小型值类可以很好地扁平化,但一个包含两个 int 字段或一个 double 字段的类可能无法容纳在原子写入中,最终仍然作为堆上的普通对象存在。未来,128 位编码将出现,前述 JEP 关于限制空类型的提案将允许扁平化更大的类,但代价是放弃原子性保证。这正是非空性不再仅仅是装饰,而成为性能提升杠杆的时刻。
### 以新的视角看待装箱和拆箱
还记得装箱(boxing)的古老成本吗?将 int 包装成 Integer 的成本。在新的模型中,包装类本身会变成值类(当预览功能开启时,Integer、Long、Double 等类将失去其身份)。由于装箱不再具有身份,JVM 可以对其进行标量化和扁平化处理。其效果是:Integer[] 开始接近 int[] 的效率,装箱的开销,如 JEP 401 所述,显著减少。伴随的 JEP 402(增强原始类型装箱)更进一步,平滑了原始类型与其包装类之间的转换,为编写如 List<int> 的代码铺平了道路。但这是另一个仍在成熟中的部分,因此不要假设它会与 JEP 401 完全同步推出。
### 数组
这是效果最明显的地方。不再需要存储指向一百万个分散对象的百万个指针,Color[] 数组可以直接存储连续颜色的 32 位编码(再次强调:加上空标志)。从内存角度来看,这样的数组开始像普通的 int[] 一样表现:一块连续的数据块,处理器可以按顺序逐行缓存访问。
为了让所有这些都得以实现,一些非常基础的底层机制被重新构建:新的值修饰符;严格的构造规则(所有字段必须在任何对象可见之前设置,实际上是在 super() 调用之前,以确保 final 字段的“突变”永远不会被观察到);重新定义 == 为可替代性测试;在引用比较字节码(acmp)中添加值对象检查;标量化和扁平化机制;IdentityException;以及现有“基于值”的类的迁移。简而言之,这不仅仅是语法糖。这是对一个假设的重建:自 1995 年以来,Java 中的每个对象都具有身份。
## 5. 一个实际例子 - 在 Valhalla 之前和之后,逐步分析
让我们从最简单的例子入手,并逐步追踪,即使不了解 JVM 的内部机制,也能清楚理解。
在 Valhalla 之前:
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 字节),还包括一个头(大约十几个字节的元数据)。这些对象是分散的:分配器在不同的时间、不同的位置创建了它们。当你遍历数组并求和坐标时,处理器对每个点都要执行以下操作:从数组中读取指针,跳转到指定的地址(可能造成缓存未命中),读取字段。这个过程要重复一百万次。这正是第 1 节中提到的“松散”布局。
在 Valhalla 之后:
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 字节(加上一个可能的 null 标志),在一个连续的块中。每个元素没有头,没有指针,也没有在堆中四处跳转。
[图片:Point[] 数组的两种变体:“之前”(一个指向分散的带有头的框的箭头数组)和“之后”(一个统一的数字对条带)]
现在遍历数组时,处理器可以按顺序读取数据。每个 64 字节的缓存行都会立即带入几个完整的点。以内存带宽速度求和一百万个坐标,而不是因未命中而受阻。在数据密集型代码中,这可能带来倍数级的差异,而不仅仅是百分比级的差异。
而且,对可维护性来说最重要的是,你不需要为此付出抽象的代价。Point 仍然是一个类:它有名称,有构造函数,可以有验证(例如 if (x < 0) throw ...),可以有方法。你不需要像之前那样,将点拆分成两个原始的 int[] xs 和 int[] ys 数组,并祈祷你永远不会混淆索引。你获得了原始数据的密度和类的可读性。这就是整个 Project Valhalla 在一个例子中的体现。
### 6. 专用泛型 - 类型擦除为何有害(以及正在采取哪些措施)
这是 Valhalla 的第二部分,说实话,也是更难理解的部分。让我们从问题的根源开始。
Java 通过类型擦除实现泛型。实际上:List<String> 和 List<Integer> 在运行时是相同的普通 List,类型参数 T 被擦除为 Object。这经常被嘲笑,但值得知道的是,这是一个有意为之、有正当理由的决定,而不是懒惰。擦除为 Java 提供了渐进迁移的兼容性:你可以将一个现有的非泛型类转换为泛型,而无需破坏任何现有的源文件或编译后的类,客户端可以立即、稍后或永远不迁移。2004 年,当 Java 已经拥有一个庞大的代码库时,另一个选择(“这里有泛型,但扔掉你所有的库”)将是一个糟糕的交易。今天,这会更糟糕。
问题是,擦除与 Valhalla 在我们最关心性能的地方发生了冲突。由于 T 被擦除为 Object,放入 List<Point> 中的值对象必须作为堆上的普通对象进行实例化。换句话说:你漂亮且可以扁平化的 Point 在泛型集合中失去了扁平化:容器保存的是引用,而不是扁平的值。你在 Point[] 中获得的密度在 ArrayList<Point> 中完全蒸发。
修复计划,就像 Valhalla 的其他部分一样,分为两个阶段:
第一阶段:通用泛型。这是一个语言层面的更改:它允许类型变量也覆盖值类型,也就是说,你可以甚至表达像 `ArrayList<Point>` 或 `List<int>` 这样的结构。目前仍然通过擦除实现。程序员主要会感受到新的编译器警告,关于“空值污染”,因为类型为 `T` 的字段默认初始值为 `null`,即使 `T` 是一个值类型。处理这些警告将使 API 变得“可专门化”。
第二阶段:专门化泛型。这些是未来 JVM 的扩展,将为具体的类型参数生成异构的、专门化的类布局(在项目术语中称为“物种”和“类型限制”)。只有到那时,`ArrayList<Point>` 才会真正由扁平内存支持。这部分目前仍主要是研究工作。
这对库和框架的影响是巨大的,这正是为什么它会逐步进行的原因。最终,集合、流和整个 API 都可以基于值类型变成扁平的、无需分配的。但库作者必须处理新的警告,并在设计时考虑到专门化。老实说:最初的通用泛型草案经历了重写,而专门化的全部好处将取决于未来的版本。JDK 28 并不会带来它。
### 7. JDK 28 具体意味着什么
让我们在这里集中说明这一点,因为很容易在“它已经在这里了!”和“它还没到”之间迷失。
已被接受的内容:JEP 401(值类和对象)作为预览功能,目标是 JDK 28(2027 年 3 月发布),计划在 2026 年 7 月左右整合到主线版本中。涉及 197,000 行代码,1,816 个文件,Lois Foltan 方面的协调工作,对其他提交者请求暂时不要进行大规模更改。默认情况下是禁用的:如果你想尝试这种语法,必须启用 `--enable-preview`。
实际到达用户的内容:声明值类和值记录的能力;将 JDK 中现有的“基于值”的类(包括像 Integer 这样的原始包装类)迁移到预览的值类中;对符合条件的类进行标量化和扁平化;更便宜的装箱操作。
仍可能演变的内容,以及 JDK 28 中没有的内容:空值限制类型(不可为空);完整的专门化泛型;128 位编码;一个完全成熟的 JEP 402。还有语法本身,因为这是预览功能,这正是它的预期:它可以根据反馈在每次发布时发生变化。因此 Goetz 关于“只有第一部分”的引言。
对生态系统的影响:对于高性能 Java(数据、向量计算、机器学习、游戏开发、金融、编解码器)来说,这是实现密集数据而不放弃抽象的路径,这正是这些领域中一些人等待多年的东西。框架和库将开始迁移它们的基于值的类。你还需要注意代码中(有意或无意)依赖于身份的 `==` 和 `synchronized` 周围可能出现的大量行为意外。还有一个在规划时值得记住的点:JDK 28 不是一个“LTS”版本:下一个 LTS 可能是 2027 年 9 月的 JDK 29。因此,大多数公司只有在 LTS 中才能遇到稳定版的 Valhalla,但正是 JDK 28 中的预览功能才真正开启了与实际代码的反馈循环。如果你正在做能从这受益的事情,现在就是开始实验和提交反馈的时刻。
## 8. 总结
为什么我称这是平台历史上最大的变化之一?因为 Valhalla 并没有在语言上又添加一个功能;它改变了语言最深层的假设。“每个对象都有身份”这一原则自 1995 年以来一直适用于 Java,它是所有其他内容的基础。让程序员可以选择是否放弃这一假设(决定哪些对象需要身份,哪些不需要)并不是一次重构,而是基础的转变。这正是为什么它能够开启未来十年的工作:统一基本类型和对象、专门化泛型、更密集的集合、更快的数值计算。
同时,这也是标题的诚实版本:“Valhalla 进入 JDK 28”是一个半真半假的说法。这只是多阶段发布的第一步,也是预览阶段。但正是这个团队的纪律(简化模型以适应人类,将性能相关的工作作为可选)才导致了这一变化耗时十二年,也正是因为这一点,它现在才有可能被发布。
对我们这些程序员来说,有一个要点比语法更重要:要内化“身份”与“值”的区别。其余的内容(==、扁平化、泛型)都是这一区别的结果。而且,早期访问构建已经可以使用了:你可以在竞争对手之前,亲自在自己的代码中尝试这一功能。
## 最后,我经常被问到的问题 😊
1. 值类只是记录类吗?不,它们是两个相互独立的决定。记录类意味着“我放弃单独的内部状态”(内容 = 组件)。值类意味着“我放弃身份”。你可以有任意组合:普通类、记录类、值类、值记录类。
2. 我可以使用 == 比较值对象吗?可以,但 == 现在意味着不同的内容:可替换性,即对所有字段(递归地)进行比较,而不是内存地址。对于“它们是否表示相同的数据”这个问题,通常还是使用 equals 更好,因为 == 看的是内部状态,而内部状态并不总是等于表示的状态。
3. 值类可以为 null 吗?在 JDK 28 模型中,是的。值类仍然是引用类型。不可为 null 的类型(带有 null 限制)是另一个、未来的 JEP,它们将解锁更大值类的扁平化。它们不在 JDK 28 中。
4. Integer 变成值类,这会不会破坏我的代码?在大多数情况下,不会。二进制文件仍然可以链接,唯一新的编译错误是尝试对这种类型进行同步。你可能会注意到的变化是那些依赖于身份的代码:对 Integers 使用 == 将开始按值进行比较,而 synchronized (someInteger) 将不再起作用。如果你依赖于这两者中的任何一个,那你的代码本来就是脆弱的。
5. 我能获得一个快速且扁平的 ArrayList<Point> 吗?目前还不能。由于类型擦除,泛型集合中的对象在堆上被实例化。扁平化的泛型集合需要通用和专门化的泛型:这是未来。在 JDK 28 中,扁平化直接适用于值类型的字段和数组,例如 Point[]。
6. 这与 C# 中的 struct 有什么不同?C# 中的 struct 有身份和可变性,因此赋值或传递时的复制语义必须被明确定义,这为程序员带来了更复杂的模型,也限制了运行时的自由度。Valhalla 中的值对象没有身份,它们在内存中的布局由 JVM 自行决定。这为人类带来了更简单的模型,也为机器带来了更多的自由。
7. 难道逃逸分析不是已经在做这些事情了吗?正如我之前提到的,部分是这样。逃逸分析可以在证明对象不依赖于身份时避免分配对象,但它不可预测,而且当对象出现在字段中、数组中或“逃逸”到优化范围之外时,它无法提供帮助。值对象的标量化是可预测的,并且可以扩展到更远的范围,包括跨方法调用边界。
8. 我是否需要重写代码才能受益?对于你自己的类,通常只需为那些表示“简单领域值”且不依赖于身份的类添加值修饰符即可;迁移过程大多是兼容的。你甚至会获得一些免费的收益,因为JDK正在迁移它自己的类(如原始包装类)。
10. 我什么时候才能看到完整的Valhalla,包括泛型、非空类型以及所有其他特性?将在未来的版本中实现。团队会逐步发布:JDK 28是值类的第一个预览版本。完整的故事(专用泛型、限制空类型的类型、128位编码等)将分布在多个版本中,很可能在下一个LTS版本时才趋于稳定。
PS:你可以在 jdk.java.net/valhalla 找到早期访问版本,这可能是最快形成自己观点的方法,比我再写一篇关于该主题的文章更快。