JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · TYPE SYSTEM · JEP 395 / 409

record 与 sealed:用 Java 写代数数据类型

JDK 17 · 深钻主题 01 / 03 · 预计 55 分钟
核心四步深钻JEP 395JEP 409JEP 406
一句话record 描述「数据是什么形状」,sealed 描述「可能是哪几种形状」,模式匹配负责按形状分支。三者组合后,Java 可以表达代数数据类型,并让编译器检查分支是否穷尽。
本质:ADT 的两半是「组合」(record 把多个值组合成一个)与「选择」(sealed 把有限个变体封闭起来)。穷尽性不是语法糖,而是把「新增变体」变成编译错误。

学完你要能自己回答

  1. record 与 sealed 分别对应代数数据类型的哪一半?
  2. record 的 equals/hashCode/toString 在字节码里是怎么生成的?
  3. sealed 的 PermittedSubclasses 在 class 文件与反射里如何体现?
  4. 为什么 17 的 sealed switch 仍要 --enable-preview?穷尽性由谁检查?
  5. 给 sealed 层级加 default 为什么会失去保护?
01 组合与选择02 三个心智锚点

组合与选择01

代数数据类型(ADT)由两种构造组成:积类型把多个值组合成一个(record 的组件列表),和类型表示「若干变体之一」(sealed 的 permitted 子类型)。枚举是 and 类型的退化形式:所有变体共享同一形状。

概念Java 17 的表达回答的问题
积类型record 的组件这个值由哪些字段组成?
和类型sealed 的 permitted 子类型它可能是哪几种形状?
模式匹配instanceof 模式 / 预览 switch当前是哪种形状,字段是什么?
穷尽性sealed + 预览 switch 编译检查所有形状都覆盖了吗?

下面用 sealed + record 表达一个最小表达式树。Expr 是选择,Num 与 Add 是组合;eval 不需要 default,因为编译器知道只有两种变体。

public class AdtDemo {
    sealed interface Expr permits Num, Add {}
    record Num(int value) implements Expr {}
    record Add(Expr left, Expr right) implements Expr {}

    static int eval(Expr e) {
        return switch (e) {          // 17 预览:--enable-preview
            case Num n -> n.value();
            case Add a -> eval(a.left()) + eval(a.right());
        };
    }

    public static void main(String[] args) {
        Expr e = new Add(new Num(3), new Add(new Num(4), new Num(5)));
        System.out.println(eval(e));
    }
}
12
这段代码在 17 必须加 --enable-preview;在 21 才是稳定写法。record pattern 在 17 不存在,所以只能 case Add a 后调用 a.left(),不能写 case Add(Num l, Num r)。

三个心智锚点02

锚点一:record 是透明的积类型

record 的组件就是它的全部状态。没有隐藏字段、没有 setter,equals / hashCode 按组件生成,因此它可以安全地作为模式匹配的载体。需要隐藏状态的对象不应该用 record。

锚点二:sealed 是封闭的和类型

sealed 不提供多态行为,只提供「变体集合有限且已知」这一事实。这个事实让编译器可以在 switch 上做穷尽性检查,也让读者一眼看到所有可能。

锚点三:穷尽性把变化变成编译错误

新增一个 permitted 子类型后,所有没有 default 的穷尽 switch 都会编译失败。这不是麻烦,而是把「漏处理新变体」从运行时故障提前到编译期。

先猜再看record 能替代所有 DTO 吗?
不能。record 要求状态完全由组件公开,且不可变、不能继承。需要 setter、代理、懒加载、无参构造器或复杂生命周期的对象,仍应使用普通类。
01 record 的编译形态02 sealed 的 class 文件与约束03 穷尽性由谁检查

record 的编译形态01

record 编译成 final class,隐式继承 java.lang.Record。组件变成 private final 字段和同名访问器;equals / hashCode / toString 不是逐行手写,而是通过 invokedynamic 链接到 ObjectMethods 的启动方法。

$ javap -p Point.class
Compiled from "Point.java"
public final class Point extends java.lang.Record {
  private final int x;
  private final int y;
  public Point(int, int);
  public Point translate(int, int);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int x();
  public int y();
}
$ javap -p -c Point.class | grep invokedynamic
       1: invokedynamic #26,  0  // InvokeDynamic #0:toString:(LPoint;)Ljava/lang/String;
       1: invokedynamic #30,  0  // InvokeDynamic #0:hashCode:(LPoint;)I
       2: invokedynamic #34,  0  // InvokeDynamic #0:equals:(LPoint;Ljava/lang/Object;)Z

ObjectMethods.bootstrap 接收组件名、访问器句柄和 toString 模板,在第一次调用时生成方法体。这样 JVM 不需要为每个 record 复制一份 equals 逻辑,语义也由语言规范统一保证。

  • record 是 final,不能通过继承改变 equals/hashCode 的契约。
  • 不能添加实例字段,保证「组件列表 = 全部状态」。
  • 紧凑构造器在字段赋值前执行,适合校验和归一化。
  • 数组组件的 equals 按引用比较;需要内容比较时必须显式重写。
不要因为字节码里有 invokedynamic 就认为 record 的 equals 很慢。启动方法只链接一次;真正的比较仍是逐组件调用。性能判断要靠测量,不要靠指令形状。

sealed 的 class 文件与约束02

sealed 在 class 文件里体现为 PermittedSubclasses 属性。JVM 用它记录允许的直接子类型;反射的 getPermittedSubclasses() 读取的也是同一份信息。

$ javap -v Shape.class | grep -A2 "PermittedSubclasses:"
PermittedSubclasses:
  Circle
  Square
  • permits 省略时,编译器把同一编译单元中的直接子类型写入 PermittedSubclasses。
  • 命名模块中,子类型必须同模块;未命名模块中,必须同包。
  • 每个直接子类型必须声明 final、sealed 或 non-sealed,不能留空。
  • non-sealed 会重新打开继承,这一支不再参与穷尽性计算。
  • sealed 只约束继承,不约束实例化;构造器可见性才决定能否 new。

sealed 的封闭性主要在编译期和 class 文件层面表达,不是 JVM 的运行期访问控制。不要把它当成安全边界;它约束的是类型设计,不是恶意代码。

把 sealed 类型作为公共 API 时,新增 permitted 子类型是二进制不兼容变更:外部已经编译的穷尽 switch 不认识新类型。版本规划里要明确这一点。

穷尽性由谁检查03

穷尽性由 javac 在编译期检查,依据是 selector 的静态类型和 permitted 子类型集合。17 的 switch 类型模式是预览,编译后会通过 invokedynamic 链接到 SwitchBootstraps.typeSwitch,运行时按模式顺序匹配。

sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}

static int eval(Expr e) {
    return switch (e) {
        case Num n -> n.value();
    };
}
javac -J-Duser.language=en --release 17 --enable-preview MissingCase.java
MissingCase.java:7: error: the switch expression does not cover all possible input values
        return switch (e) {
               ^
1 error

如果补上 case Add a,编译通过;如果加 default,也能通过,但新增变体时不会再报错。default 把「穷尽性检查」降级成「兜底逻辑」,这是设计上的取舍,不是语法问题。

$ javap -p -c AdtDemo.class | grep typeSwitch
      11: invokedynamic #13,  0  // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
守卫条件不参与穷尽性证明。case Num n && n.value() > 0 不能代表覆盖了 Num,仍需要一个无守卫的 Num 分支或 default。
01 建模:事件与状态02 渐进迁移:从 if 链到 ADT03 边界与反模式

建模:事件与状态01

订单事件是典型的「有限变体,每个变体数据不同」。用 sealed interface 定义变体集合,用 record 定义每个变体的数据,再用预览 switch 做分支,可以把「漏处理事件」变成编译错误。

sealed interface Event permits Created, Paid, Shipped {}
record Created(String id) implements Event {}
record Paid(String id, int cents) implements Event {}
record Shipped(String id, String carrier) implements Event {}

static String describe(Event e) {
    return switch (e) {
        case Created c -> "created " + c.id();
        case Paid p && p.cents() > 0 -> "paid " + p.cents();
        case Paid p -> "invalid payment";
        case Shipped s -> "shipped via " + s.carrier();
    };
}
Event[] events = {
        new Created("A1"),
        new Paid("A1", 1200),
        new Paid("A1", 0),
        new Shipped("A1", "SF")
};
for (Event e : events) {
    System.out.println(describe(e));
}
created A1
paid 1200
invalid payment
shipped via SF

注意 Paid 出现了两次:带守卫的分支在前,无守卫的兜底分支在后。守卫分支不证明穷尽,编译器仍要求后面有覆盖 Paid 的分支。

渐进迁移:从 if 链到 ADT02

  1. 先识别「有限变体」:状态机、事件、AST、协议消息。变体由你控制、不会第三方扩展,才适合 sealed。
  2. 把每个变体改成 record,组件只保留该变体真正需要的数据;不要把所有字段堆进一个大类。
  3. 用 sealed interface 封闭变体集合,先保留现有 if / instanceof 逻辑,确保行为不变。
  4. 在测试覆盖后,把 if 链改成预览 switch(17)或等升级到 21 再正式使用;预览代码隔离在适配层。
  5. 新增变体时先让编译失败,再逐个补分支;不要用 default 图省事。
旧写法ADT 写法收益
一个类 + type 字段 + 大量可空字段sealed interface + 每个变体一个 record非法状态不可表示
if (e instanceof ...) 长链穷尽 switch漏分支在编译期暴露
每个分支强转后取字段类型模式绑定访问器少一层强转
default 抛异常不写 default新增变体时编译失败
迁移顺序不要反过来:先把数据形状改对,再改控制流。否则你会在一个不封闭的类型层级上写 switch,最后还是得靠 default 兜底。

边界与反模式03

  • 不要为了用 sealed 而封闭公共 SPI:第三方无法实现,扩展点就死了。
  • 不要把所有领域对象都改成 record:需要生命周期、缓存、连接、监听器的对象不适合。
  • 不要在 sealed switch 里加 default 后又声称「穷尽性受保护」;default 已经让保护失效。
  • 不要把预览 switch 暴露到公共 API;升级 JDK 时语法和类文件都会变。
  • 不要忽略二进制兼容:新增 permitted 子类型会让外部已编译的穷尽 switch 不再覆盖新类型。
  • record 的数组组件要显式处理 equals/hashCode;默认按引用比较。

ADT 的价值在「非法状态不可表示」和「新增变体必须处理」,不在语法本身。如果类型集合其实是开放的,或者变体之间没有共同语义,强行 sealed 只会增加维护成本。

17 是一个过渡点:sealed + record 已经正式,但消费它们的穷尽 switch 还是预览。生产项目可以把数据模型先落下来,把 switch 留在 21 升级时再正式启用。
01 先猜再看02 费曼自检:能否讲清因果

先猜再看01

先猜再看record 和 sealed 谁是「积」,谁是「和」?
record 是积类型:所有组件同时存在,状态是它们的组合。sealed 是和类型:一个值属于若干变体之一。ADT 就是「积的和」,例如 Expr = Num | Add。
先猜再看sealed 能让普通 switch 自动穷尽吗?
不能。17 的普通 switch 只接受常量;要按子类型分支并使用穷尽性检查,需要 JEP 406 的预览 switch,并加 --enable-preview。21 起才正式。
先猜再看给 sealed switch 加 default 有什么代价?
新增 permitted 子类型时,default 会静默接住它,编译器不再报「未覆盖」。你失去了 ADT 最重要的保护:变化被变成编译错误。

费曼自检:能否讲清因果02

01. 用一句话解释 record 为什么适合做 ADT 的变体。
因为它的状态完全由组件列表公开,equals/hashCode/toString 按组件生成,没有隐藏状态,模式匹配可以安全地按形状解构。
02. sealed 的 PermittedSubclasses 存在哪里?反射怎么读?
存在 class 文件的 PermittedSubclasses 属性里;反射用 Class.isSealed() 判断,用 getPermittedSubclasses() 读取直接子类型数组。
03. 17 的穷尽 switch 为什么必须 --enable-preview?
因为 switch 类型模式在 17 是 JEP 406 预览,class 文件带 61.65535 的 preview minor,编译和运行都要显式开启预览。
04. record pattern 在 17 能用吗?
不能,连预览都没有。17 只能 case Num n 后调用 n.value();解构要等到 21 的 JEP 440。
05. ADT 新增变体时,编译器如何帮你?
所有没有 default 的穷尽 switch 会编译失败,迫使你为每个消费点补分支。这正是把运行时遗漏提前到编译期的机制。
如果你能用一个表达式树的例子讲清「record 组合、sealed 选择、switch 穷尽」三件事,并说清 17 与 21 的预览边界,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。