现代 Java 语言演进:Records、Sealed 与模式匹配的版本边界
结论基线:语言规则以 JLS SE 17 为准,JDK 21 新增正式能力以 JLS SE 21 为准;实现分析固定到 OpenJDK 17u
jdk-17.0.12+7与 21ujdk-21.0.5+9两个 tag。本机实测运行 Oracle JDK 17.0.12+8 与 21.0.5+9(x86_64,macOS 26.7.1,见实验章)。record 浅不可变与泛型擦除的既有讲解在类型正文,本文承接演进而非重写;JEP 400 默认字符集断层由编码正文维护,U12 已在其上补 JDK 17/21 双机对照实测。本文每项能力都标注:引入版本、预览轮次、正式化版本与适用 JDK——"能编译"与"正式可用"不是同一件事,预览特性默认关闭且跨版本可能被撤回(String Templates 即被撤回,见状态表)。
为什么需要语言演进:三件旧语法做不好的事
业务建模里反复出现三个痛点,语言演进分别给出答案:
- 数据载体的样板:一个"金额+币种+来源"的值对象要写构造器、getter、equals、hashCode、toString 五套重复代码,且每个手写版本都可能出错(equals 与 hashCode 不一致是集合正文里的经典事故源)。
- 可扩展类层次的失控:一个
Payment接口谁都能实现,维护者无法穷举"到底有哪几种支付",编译器也无从帮你在新增分支时找出漏改的 switch。 - 按形状分支的笨拙:解出一个值要两行(
instanceof判断 + 强转),按形状分发要用访问者模式搭双层结构,switch 一旦漏分支编译器沉默。
records(JEP 395,JDK 16 正式)解决第一件,sealed(JEP 409,JDK 17 正式)解决第二件,模式匹配(JEP 394 instanceof、JEP 441 switch、JEP 440 record 模式)解决第三件。三者组合后才能写"穷举的代数数据类型":sealed 固定形状集合,record 定义每个形状的数据,模式匹配按形状解构,编译器保证漏分支报错。
能力状态表:每项的引入、预览与正式化
下表是本票逐项核查的官方状态(2026-10-25 查询 openjdk.org JEP 页面与各 JDK 特性列表),"适用 JDK"指无需预览开关即可使用的起点:
| 能力 | 预览/孵化轮次 | 正式化 | 适用 JDK(无需开关) | 本机验证 |
|---|---|---|---|---|
| switch 表达式(箭头/yield/穷举) | JEP 325(12 预览)、JEP 354(13 二次预览) | JEP 361,JDK 14 | 14+ | --release 17 实测穷举编译拒绝与通过 |
| instanceof 类型模式 | JEP 305(14 预览) | JEP 394,JDK 16 | 16+ | --release 17 实测 |
| record | JEP 359(14 预览)、JEP 384(15 二次预览) | JEP 395,JDK 16 | 16+ | --release 17 实测 |
| sealed 类 | JEP 360(15 预览)、JEP 397(17 二次预览) | JEP 409,JDK 17 | 17+ | --release 17 实测 |
| switch 模式匹配(类型模式进 switch) | JEP 406(17 预览)、JEP 420(18 二次)、JEP 427(19 三次)、JEP 433(20 四次预览) | JEP 441,JDK 21 | 21+(17 需 --enable-preview) | 17 拒绝/预览可编译、21 通过,双档实测 |
| record 模式(解构) | JEP 405(19 预览)、JEP 432(20 二次预览) | JEP 440,JDK 21 | 21+(19/20 需预览,17 完全没有) | --release 17 拒绝实测 |
| Sequenced 集合(getFirst/reversed) | 无预览,直接正式 | JEP 431,JDK 21 | 21+ | 双档实测 |
| 虚拟线程 | JEP 425(19 预览)、JEP 436(20 二次预览) | JEP 444,JDK 21 | 21+ | 21 实测,见虚拟线程正文 |
| ScopedValue(替代 ThreadLocal 的作用域值) | JEP 429(20 孵化)、JEP 446(21 预览)、JEP 464(22 二次)、JEP 476(23 三次)、JEP 487(24 四次预览) | JEP 506,JDK 25 | 25+(21–24 均需预览) | 未实跑(本机无 JDK 25),按 JEP 推导 |
| String Templates | JEP 430(21 预览)、JEP 459(22 二次预览) | 已撤回(2024 年初从 23 移除,设计回炉) | 任何 JDK 都不可用 | 撤回状态按 Oracle 23 发行说明登记 |
未命名变量 _ | JEP 443(21 预览) | JEP 456,JDK 22 | 22+ | 未实跑(无 JDK 22),按 JEP 推导 |
基本类型模式(case int i) | JEP 455(23 预览)、JEP 488(24 二次) | JEP 507(25 三次预览,仍未正式) | 无(25 仍需预览) | 未实跑,按 JEP 推导 |
| 紧凑源文件/实例 main、模块导入、灵活构造器 | 455/494/495/492 等 21–24 多轮预览 | JEP 512/511/513,JDK 25 | 25+ | 未实跑(无 JDK 25),按 JEP 推导 |
| Stable Values | — | JEP 502(25 预览,未正式) | 无(25 需预览) | 未实跑,按 JEP 推导 |
两点工程含义:其一,团队锁定 JDK 17 时,switch 模式与 record 模式都不可用(后者连预览都没有),"先升级编译器再决定语法"必须以 --release 而不是 IDE 兼容性为准;其二,预览不等于会正式——String Templates 两轮预览后整体撤回,押注预览特性的库会付出迁移代价。JDK 25 是 LTS,其正式项(Scoped Values、模块导入、紧凑源文件、灵活构造器)只在该版本及以后可用,本机未安装 JDK 25,以上 25 相关结论均为规范推导。
record:契约、紧凑构造器与反射边界
record 的语言契约写在 java.lang.Record 的类文档里:
原码连续摘录(仅统一公共缩进,截取方法局部):Record.java,jdk-17.0.12+7,L35–L47。
* <p>A <em>record class</em> is a shallowly immutable, transparent carrier for
* a fixed set of values, called the <em>record components</em>. The Java
* language provides concise syntax for declaring record classes, whereby the
* record components are declared in the record header. The list of record
* components declared in the record header form the <em>record descriptor</em>.
*
* <p>A record class has the following mandated members: a <em>canonical
* constructor</em>, which must provide at least as much access as the record
* class and whose descriptor is the same as the record descriptor;
* a private final field corresponding to each component, whose name and
* type are the same as that of the component; a public accessor method
* corresponding to each component, whose name and return type are the same as
* that of the component. If not explicitly declared in the body of the record,四条硬契约由此展开:组件字段是 private final(浅不可变——组件是引用时嵌套对象仍可变,反例与防御复制见类型正文 record 段);canonical 构造器访问性不窄于 record 本身;equals/hashCode/toString 从全部分量派生;record 隐式 final,不能再被继承。使用层的两个规则:
// 紧凑构造器:校验/归一化写在 canonical 构造器上,非法输入在"构造时"失败,
// 不是留到读取端才发现坏数据。
record Price(int cents) {
Price {
if (cents < 0) throw new IllegalArgumentException("cents must be >= 0: " + cents);
}
}
new Price(-1); // IllegalArgumentException;修复方向是修调用方输入,不是吞掉异常
// record 序列化不走普通对象的字段回写:反序列化必须经过 canonical 构造器,
// 因此上面的校验对反序列化输入同样生效,且 record 不做 SUID 校验(有独立演化规则)。第二点是 record 与普通可序列化类的实质差异:普通类的反序列化绕过构造器直接按字段赋值,record 的反序列化契约要求走 canonical 构造器,紧凑构造器里的校验不会被绕过;同时 record 不校验 serialVersionUID,演化规则独立(writeRecordData 分支、兼容矩阵与安全边界由序列化正文权威讲解,本文不复制)。运行时反射把 record 当一等公民:Class.isRecord() 的实现有一条"快路径"判定:
原码连续摘录(仅统一公共缩进,截取方法局部):Class.java,jdk-17.0.12+7,L3770–L3785。
* components; {@link #getRecordComponents()} returns a non-null but
* possibly empty value for a record.
*
* <p> Note that class {@link Record} is not a record class and thus
* invoking this method on class {@code Record} returns {@code false}.
*
* @return true if and only if this class is a record class, otherwise false
* @jls 8.10 Record Classes
* @since 16
*/
public boolean isRecord() {
// this superclass and final modifier check is not strictly necessary
// they are intrinsified and serve as a fast-path check
return getSuperclass() == java.lang.Record.class &&
(this.getModifiers() & Modifier.FINAL) != 0 &&
isRecord0();注释明说:父类与 final 检查"并非严格必要",只是可内联的快路径,最终以 isRecord0()(native)为准。读源码时不能把快路径误当成定义本身——真正决定身份的是类文件里的 Record 属性。getRecordComponents() 返回组件元数据(非 record 返回 null),框架可以据此找回 canonical 构造器签名做通用解构,这也是 JSON 库能直接绑定 record 的机制基础。
sealed:把"还有哪些子类"变成编译器可检查的事实
sealed 类把继承集合收窄为显式 permit 列表。三条使用规则:permits 列表中的直接子类必须 final、sealed 或 non-sealed 三选一(否则编译错误);non-sealed 对下重新开放继承(sealed 断言"这几类之内",non-sealed 断言"到我这层为止");同文件的子类可省略 permits。反射侧的权威入口是 getPermittedSubclasses():
原码连续摘录(仅统一公共缩进,截取方法局部):Class.java,jdk-17.0.12+7,L4505–L4533。
* @jls 8.1 Class Declarations
* @jls 9.1 Interface Declarations
* @since 17
*/
@CallerSensitive
public Class<?>[] getPermittedSubclasses() {
Class<?>[] subClasses;
if (isArray() || isPrimitive() || (subClasses = getPermittedSubclasses0()) == null) {
return null;
}
if (subClasses.length > 0) {
if (Arrays.stream(subClasses).anyMatch(c -> !isDirectSubType(c))) {
subClasses = Arrays.stream(subClasses)
.filter(this::isDirectSubType)
.toArray(s -> new Class<?>[s]);
}
}
if (subClasses.length > 0) {
// If we return some classes we need a security check:
@SuppressWarnings("removal")
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkPackageAccessForPermittedSubclasses(sm,
ClassLoader.getClassLoader(Reflection.getCallerClass()),
subClasses);
}
}
return subClasses;
}注意分支语义:非 sealed 类返回 null(与空数组不同——判断"是不是 sealed"用 getPermittedSubclasses() != null);数组/基本类型直接 null;返回值还做了"直系子类型过滤",说明底层 getPermittedSubclasses0() 可能带回非直系条目,反射结果不能直接当 permit 原文使用。本机实测:Shape.class.getPermittedSubclasses() 返回 3 个 permit 成员,record 子类自动 final,non-sealed 分支无 final 修饰。
两个必须用失败输入钉住的边界(探针编译负例实测,run.sh evolution):
sealed interface Shape permits Circle, Square, Blob {}
record Circle(double r) implements Shape {}
non-sealed interface Blob extends Shape {}
class Rogue implements Shape {} // 编译拒绝:不在 permits 列表
class OpenSub implements Shape {} // 编译拒绝:sealed 子类必须 final/sealed/non-sealed 三选一
// 反射边界:record/局部类不能作为 permit 成员——
// 方法体里写 record X() implements Shape {} 会被 JLS 拒绝(局部类不能继承 sealed 类),
// 本机 javac 17 实测诊断「本地 类不得扩展密封类」。第三条容易被忽略:sealed 的 permit 成员必须是顶级或静态嵌套的命名类型,方法内的局部类不行。这个限制来自 sealed 需要类文件级 PermittedSubclasses 属性,而局部类没有独立的二进制名,框架做"按 permit 列表生成工厂"时同样受此约束。
模式匹配:instanceof 的流作用域与 switch 的穷举契约
instanceof 类型模式(JEP 394,16 正式)消掉显式强转,价值在流作用域(flow scoping):
// 短路 &&:右侧 s 已确定赋值,直接使用
if (obj instanceof String s && !s.isEmpty()) { use(s.length()); }
// 取反守卫:左侧为真提前返回,之后的代码里 s 在作用域内
if (!(obj instanceof String s)) return 0;
use(s); // 合法:能走到这里说明匹配成功
// || 右侧不在作用域:编译拒绝(探针负例 PatternScopeOr)
if (obj instanceof String s || true) { use(s.length()); } // javac 拒绝null 永远不匹配任何类型模式(null instanceof String s 为 false),所以守卫式判空是类型模式的自然副产品——这一点与旧式 instanceof 一致,但配上模式变量后"判空+判型+绑定"三步合成一步,不会再出现绑定变量与强转变量混用的漂移。
switch 模式匹配与 record 模式是 JDK 21 的正式能力(17 上分别是 JEP 406 预览与完全不存在),与 sealed 组合后产生穷举契约:
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle(var radius) -> Math.PI * radius * radius; // record 模式解构
case Square(var side) -> side * side;
}; // 无 default:sealed 穷举成立;新增 permit 成员而漏改这里是编译错误,不是运行时疏漏
}穷举规则按版本核对:--release 17 下编译同一文件报错(record 模式语法不存在;case Circle c 类型模式也需 --enable-preview);--release 21 下无 default 的 sealed 穷举编译并运行通过。这正是版本边界的可执行验证——同一份源文件、两个 release 档、两种结果,升级 JDK 的代码评审应当把这类文件作为编译门禁。枚举的 switch 表达式穷举(JEP 361,14 正式)在 17 上可用,但注意它只对枚举与(21 起)sealed 生效:对普通继承层次写无 default 的 switch 依旧要求 default 或全部类型标签,"漏分支编译报错"不能移植到任意类层次。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
--release 档位 | 17:无 switch/record 模式、sealed 可用;21:全部本表能力正式。IDE 兼容性不代表 --release 门禁,CI 用 --release 编译才是发布口径 |
--enable-preview | 17 可编译 JEP 406 预览 switch 模式(本机实测),但预览字节码跨小版本不可运行,依赖预览特性的库不能发布为稳定 API |
| 换编译器/反射框架 | record 派生成员由 javac 生成;字节码增强/代理库对 record 与 sealed 的支持按各自版本核对(代理正文已登记 sealed 接口不能做 JDK 动态代理目标) |
| 序列化/反序列化 | record 走 canonical 构造器且不校验 SUID;sealed 层次跨版本增删 permit 成员是兼容性事件(序列化正文兼容矩阵承接) |
| 反射读取 | getPermittedSubclasses() 有直系过滤与 SecurityManager 分支;isRecord() 快路径不是定义 |
| 升级到 JDK 25 | Scoped Values/模块导入/紧凑源文件正式、基本类型模式与 Stable Values 仍预览(本机未实跑,按 JEP 502/506/507/511/512/513 推导);String Templates 已撤回,任何版本不可用 |
实验:输入、步骤、预期与边界
实验入口:labs/modern-java(ModernJavaProbe.java,run.sh evolution,固定 JDK 17)。
cd <仓库根>
export JAVA_HOME17=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
export JAVA_HOME21=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
bash labs/modern-java/run.sh evolution输入与预期(2026-10-25 实测,完整输出与红绿记录见证据):
| 场景 | 输入 | 预期与实测 |
|---|---|---|
| record 契约 | Price(199) 与 Price(-1) | 派生 toString/equals/hashCode 通过;负数在构造时抛 IAE;反射 isRecord/getRecordComponents/permits 通过 |
| sealed 反射 | Shape.class.getPermittedSubclasses() | 返回 3 个 permit 成员;record 子类 final;non-sealed 非 final |
| instanceof 模式 | "ab"/""/42/null | 流作用域三分支与 null 恒不匹配全部按预期 |
| switch 表达式 | 枚举三值 | 箭头+yield 与直接箭头结果一致(穷举) |
| 编译负例 x7 | SealedRogue/SealedOpenSub/SwitchNonExhaustive/RecordPattern17/TypePatternSwitch17/PatternScopeOr/Sequenced17 | --release 17 全部拒绝(javac 非零退出) |
| 预览观察档 | TypePatternSwitch17 + --enable-preview --release 17 | 可编译(JEP 406 预览),不运行 |
| 双档边界 | PatternBoundary/SequencedBoundary | --release 17 拒绝、--release 21 编译运行通过 |
TDD 红绿(真实,不倒造):红3——先按"模式匹配只是语法糖,17 也能编译"的误解写正向断言,--release 17 编译 case Circle(var radius) 实报「需要')'」,修正为双档断言后转绿。边界:本实验只覆盖所列输入;虚拟线程部分由虚拟线程正文的 JDK 21 实验承担;JDK 22–25 差异(未命名变量、基本类型模式、Scoped Values 等)未实跑,按 JEP 推导并已在状态表标注。
设计案例:领域事件建模的版本选择(算例,非实测)
负载假设:订单域每天 500 万条事件(下单/支付/退款/关单四类),消费方按类型解构处理;团队当前锁定 JDK 17,半年内评估升级 21。不变量:新增事件类型时消费方编译期失败优先于运行时静默丢弃;每条事件的字段不可变。
选择 1:sealed 层次 + record 事件 +(升级 21 后)穷举模式 switch。 sealed 接口 permit 四类 record,17 上先用"sealed + instanceof 模式 + default 抛异常"过渡,升级 21 后改为 record 模式穷举并删除 default——新增第五类事件时 17 的 default 兜底会静默通过(需要靠单测兜住),21 的穷举编译直接失败。成本:一次性把 if-else 链改写为模式 switch;升级时点受 --release 门禁保护(双档边界样本进 CI)。
选择 2:普通接口 + 访问者/自研 type 字段分发。 不依赖 JDK 21:新增类型时消费方编译不受影响,靠访问者接口的编译提醒或 switch 的 default 兜底;type 字段方案则完全靠运行时测试。成本省掉 JDK 升级,代价是把"漏分支"从编译期推到测试期/生产期。两条路都成立,差别在漏分支被谁发现、多快发现;数字(500 万/天)只用于说明该场景对分支遗漏敏感,不是实测吞吐。
追问链
第一层:record 为什么只做"浅"不可变?
组件引用 final 已经让"结构固定、身份稳定、可作为 Map 键"成立;深不可变要递归复制嵌套对象,代价与所有权语义(谁还能改嵌套对象)超出 record 的职责。List.copyOf 一层防御复制 + 元素自身不可变是工程上的完整解(见类型正文)。
第二层:sealed 与枚举怎么选?
枚举适合"固定的一组实例"(常量集合),sealed 适合"固定的一组形状"(每类携带不同数据与行为)。枚举也能参与 21 的 switch 穷举;当各类需要不同字段时(如 Circle(radius) vs Square(side)),record + sealed 是枚举做不到的表达。二者可以组合:sealed 接口的 permit 成员本身可以是枚举。
第三层:为什么模式匹配的穷举只认 sealed 与枚举?
编译器必须能静态枚举全部可能形状。sealed 把子类集合写进类文件 PermittedSubclasses 属性,枚举把实例集合写进类文件——两者都给编译器封闭集合;普通继承层次在运行时仍可被任意扩展(包括其他 jar 包、反射生成类),静态穷举会漏。这就是"漏分支报错"不能推广到任意类层次的原因。
第四层:评审一个"要不要现在用"的提案按什么顺序问?
- 团队最低
--release是多少?17 就砍掉 switch/record 模式,只留 record/sealed/instanceof 模式;2. 新语法是否落在公开 API 上(预览特性不进公开签名);3. 漏分支的兜底在哪一层(编译期穷举 vs default vs 测试);4. 序列化/反射/代理边界(record 构造器校验、sealed 不能被动态代理、permit 成员不能是局部类)是否被覆盖;5. 升级路径有没有--release双档编译样本进 CI。这五问不产生"该不该演进"的结论,但能把语法收益、版本门禁与兼容风险分开审查。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JEP 361/394/395/397/405/406/409/427/431/440/441 | 各自 JDK | 每项能力的预览轮次与正式化版本(2026-10-25 逐项查询) |
| JDK 17/19/20/21/24/25 特性列表 | 各版本 | 状态表交叉核对(String Templates 撤回见 Oracle 23 发行说明) |
| JLS SE 17 §8.10/§9.1/§14.30 | Java SE 17 | record/sealed/模式的语言规则 |
| OpenJDK 17u Record.java | jdk-17.0.12+7 | record 契约(L35–L47) |
| OpenJDK 17u Class.java | jdk-17.0.12+7 | isRecord 快路径(L3770)与 getPermittedSubclasses 分支(L4505) |
本地 labs/modern-java 实验 | Oracle 17.0.12 + 21.0.5 | 本文全部行为实测(证据) |
访问日期:2026-10-25。源码行号以对应 tag 为准;JDK 22–25 的能力状态按 JEP 推导、本机未实跑,已在状态表与边界表逐项标注。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。