JDK 28的早期访问构建里,Value Classes and Objects(JEP 401)已经能跑起来了。但截至2026年8月26日,翻开OpenJDK官方JEP页面,它的状态栏写的还是Submitted——连Candidate都没到,更别提Targeted或Integrated。这不是网站没更新,而是「预览构建里代码能跑」和「JEP流程走完」本来就是两件事:前者是某个每夜构建里功能已经实现,后者是社区立项、目标化、正式集成三道关卡都过了。开发者圈子里已经开始流传一种简化说法:把所有类都改成value class,程序就能白捡性能。工程师Johan Sjölen用几个具体例子把这套说法拆穿了——值类型放弃身份换来的,是JVM的自由裁量权,不是性能下限的保底承诺。
这个提醒来得不算早。Project Valhalla这个让JVM摆脱“一切皆引用”历史包袱的项目,已经跑了十几年,中间换过好几次名字。JEP 401是这条路上的一个真实节点,但它解决的只是单个value class自身的表示优化,跟更宏大的“泛型容器整体拉平”是两件不同的事,很多人把这两者混在一起谈。
扁平化只挑不可变字段下手
FourLongs这个例子说明了问题的核心。它是一个含四个long字段的value record,32字节,单看这个大小,JVM在当前实现里没法给它做原子的扁平更新。但把它塞进一个record Envelope里做payload字段,JVM的字段布局诊断显示这个payload被真正扁平化存储了——因为record组件是strict-initialization的final字段,初始化之后永远不会再变。JEP 539(严格字段初始化)给了JVM这个信心:不会有人事后改这个字段,所以不用担心撕裂读写,可以用非原子的扁平布局。
换成可变字段,结论立刻反过来。把payload放进一个普通可变类MutableEnvelope,字段布局马上退回引用存储。原因很直接:两个线程各自往同一个可变字段写一个FourLongs,如果JVM真把它拆成四个独立的long分量分别写,另一个线程可能看到一个撕裂的、由两次写入拼出来的“幽灵值”。Java内存模型不允许这种撕裂,所以可变字段必须靠一次原子的引用赋值来保证完整性,代价就是放弃扁平化。
- 风险.可变字段+大尺寸value class几乎注定拿不到扁平化红利,写之前先想清楚这个字段会不会被改。
标量化和物化,一字之差天壤之别
第二个例子更容易让人误判。一个不断修改FourLongs某个分量、返回新对象的方法bumpA,在循环里跑起来,C2编译器居然把整个计算收缩成一次简单的加法,连堆分配都没了。这看起来像是value class的魔法,但真正起作用的是逃逸分析+标量化:编译器发现这个中间对象根本没有可观察的身份要保护,所以直接把它拆成寄存器里的几个数字算完就扔。这一步跟value class本身关系不大,普通record在同样场景下也可能被优化掉分配——很多人把“源码层面看不到分配”当成“发生了堆扁平化”,其实是把两种优化混为一谈了。
没有分配不等于扁平化,标量化和扁平化各走各的判断路径。
真正棘手的是第三种情况:物化。当同一个方法要经过接口的虚调用,且调用点是“megamorphic”——也就是运行时可能命中好几个不同实现类——编译器没法提前锁定调用目标,也就没法沿用标量化的调用约定。Java的泛型靠类型擦除实现,接口继承下来的方法签名会变成Object apply(Object),编译器为此生成的桥接方法只认对象引用。调用方手里明明是拆开放在寄存器里的标量值,却必须先在堆上拼出一个完整对象才能塞进这个接口调用,被调用方内部再把它拆开算完,返回时又要重新拼一次。这一来一回的物化开销,恰恰发生在value class本该省掉分配的地方——语义上更“轻”的对象,跑到这条路径上反而更贵。
修复办法出人意料地简单:在接口里显式重新声明带具体类型的方法签名,让调用点的字节码描述符直接写成带类型的版本,而不是被擦除成Object。这样即便运行时依然要动态分派到不同实现,编译器也提前知道调用约定是什么,值对象可以带着标量形式直接跨过这层调用边界,不必先物化。
这也是为什么JEP 401不等于“泛型自动拉平”。Box<Point>不会因为Point是value class就自动变成扁平化的容器,真正让泛型容器整体享受扁平布局的工作,属于更大范围的Parametric VM(泛型特化)设计,跟JEP 401是并行但独立的两条线。指望换个value class关键字就让整个泛型体系变快,目前是不成立的期待。
判断“到底发生了什么”,靠工具不靠直觉
写value class之前光凭直觉猜JVM在做什么,基本靠不住——包括依赖AI辅助生成代码的场景。想知道自己的类到底被扁平化、标量化还是要走物化路径,有几个具体的检验手段:用PrintFieldLayout直接看字段有没有被拍平存储;用JMH跑基准而不是掐表估算;关掉-XX:-DoEscapeAnalysis对比一下有没有逃逸分析在背后兜底;看gc.alloc.rate.norm判断有没有真的产生堆分配;再用PrintAssembly或PrintInlining确认调用点到底是被devirtualize还是留了动态分派。这些工具比“看起来没有new”靠谱得多。
对写高性能库、序列化框架的开发者来说,这意味着重构成value class之前要先想清楚字段是不是真的不可变、调用路径是不是会经过接口虚调用或泛型擦除边界。对普通应用开发者,尤其是让AI工具批量改写类定义的人,更现实的建议是:改完之后跑一次基准测试再下结论,别把“语义上更干净”直接等同于“性能上更快”。
