Java 密封类(Sealed Classes)自 Java 17 正式转正,它让你可以声明「一个类或接口只允许指定的少数子类继承」。这种封闭层次(closed hierarchy)把”有哪些子类型”从运行时约定变成编译期契约,再配合模式匹配的穷尽检查,就能在编译阶段消灭遗漏分支、非法子类这两类最常见的类型安全隐患。本文用可运行的代码,讲清密封类怎么写、什么时候该用、以及它和枚举、普通继承的边界。
一、什么是密封类(Sealed Classes / Interfaces)
普通类的继承是开放的:任何人都能写 extends 派生新子类。这在框架里很灵活,但在”领域模型”场景下恰恰是 bug 温床——你本以为某个类型的子类只有三种,半年后同事加了一种,所有 switch 分支悄无声息地漏掉了它。密封类就是用来把这种”封闭世界假设”写进类型系统的:父类用 sealed 修饰,并显式列出被允许的子类(permits)。
从 Java 17 起,sealed、non-sealed、final 三者共同构成一套完整的权限修饰,让层次结构的开放程度一目了然。
二、为什么需要封闭层次
开放继承有两个典型痛点。其一,分支遗漏:对父类做 switch 或 instanceof 判断时,编译器无法知道”是不是还有没处理的子类”,只能靠人肉保证。其二,非法扩展:一个表示”支付状态”的类,本不该被业务代码随便派生出第 N 种状态,否则校验逻辑会失控。密封类从语言层面同时解决这两个问题:子类集合固定,且编译器能据此要求 switch 必须穷尽。
如果你对 Java 的现代语法演进感兴趣,这篇 Java 模式匹配实战 讲透了 instanceof 与 switch 类型模式,本文的穷尽 switch 正是建立在它之上。
三、语法:sealed / permits / non-sealed / final
3.1 三种权限修饰
sealed:封闭类,必须用permits列出允许的子类。final:叶子子类,不能再被继承(最严格的封闭)。non-sealed:反向开放,允许后续任意继承(在密封层次中”开个口子”)。
注意:密封类的直接子类必须三选一,不能用普通的”既不 final 也不 sealed”——编译器会报错,逼你显式决策开放程度。
3.2 同一个编译单元约束
被 permits 的子类,要么写在同一个 Java 文件(同一个编译单元)里,要么显式声明自己也是 sealed/non-sealed/final。下面的例子把整套层次放在一个文件内,最直观:
// 封闭接口:只允许 Circle 与 Rectangle 两种实现
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
}
// final 叶子:彻底封闭,不能再被继承
public final class Circle implements Shape {
private final double r;
public Circle(double r) { this.r = r; }
@Override public double area() { return Math.PI * r * r; }
}
// 继续 sealed:Triangle 还可以再限制自己的子类
public sealed class Rectangle implements Shape permits Square {
protected final double w, h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
@Override public double area() { return w * h; }
}
// non-sealed:在封闭层次里"开个口子",允许外部随意扩展
public non-sealed class Triangle implements Shape {
@Override public double area() { return 0; /* 简化 */ }
}
这里 Square 只能继承 Rectangle(因为 Rectangle 是 sealed 且 permits 了它),而不能直接实现 Shape——封闭约束在每一层都生效。
四、密封类 + 模式匹配:编译器保证穷尽
密封类的真正威力,是让 switch 表达式具备”穷尽性检查”。因为编译器确切知道 Shape 只有三种可能,当你写 switch 漏掉任一分支时,它会直接编译报错,而不是留到线上才爆 NullPointerException 或默认分支。
public String describe(Shape s) {
return switch (s) {
case Circle c -> "圆形,半径 " + c.r();
case Rectangle r -> "矩形," + r.w() + "x" + r.h();
case Triangle t -> "三角形";
// 如果将来给 Shape 新增第 4 个 permits 子类,
// 这里会立即编译失败,逼你补齐分支
};
}
对比传统写法:用 if (s instanceof Circle) 链或普通 switch,编译器根本不知道还有没有别的子类,漏处理只能靠 review 和人肉测试兜底。密封类把这种”结构性保证”交给了编译器。
五、实战场景一:结果类型 Result 替代异常滥用
很多团队用”抛异常”表达业务失败,导致正常分支和错误分支混在一起。用密封类定义一个 Result<T>,成功与失败成为两个显式子类型,调用方必须处理失败——编译器强制,逃不掉。
public sealed interface Result<T> permits Ok, Err {
static <T> Result<T> ok(T value) { return new Ok<>(value); }
static <T> Result<T> err(String msg) { return new Err<>(msg); }
}
public final class Ok<T> implements Result<T> {
public final T value;
public Ok(T value) { this.value = value; }
}
public final class Err<T> implements Result<T> {
public final String message;
public Err(String message) { this.message = message; }
}
// 使用时编译器要求处理两种可能
public <T> T unwrap(Result<T> r) {
return switch (r) {
case Ok<T> ok -> ok.value;
case Err<T> err -> throw new IllegalStateException(err.message);
};
}
这套手法在 Scala、Rust 里是标配,Java 17 之后用密封类也能干净地落地,比到处 try/catch 更易读,也更好测试。
六、实战场景二:表达式 / AST 建模
解释器、规则引擎、配置 DSL 经常需要表示一棵表达式树。密封类 + record 模式(Java 21 正式)能把”求值”写成穷尽且解构的 switch,新增节点类型时旧代码必然编译失败,不会悄悄漏算。
public sealed interface Expr permits Num, Add, Mul { }
public record Num(int value) implements Expr { }
public record Add(Expr left, Expr right) implements Expr { }
public record Mul(Expr left, Expr right) implements Expr { }
public int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
// 新增 Sub 节点时,这里编译报错,杜绝漏算
};
}
把这部分逻辑放进 Spring Boot 服务时,注意 JDK 版本要与运行环境一致,Spring Boot 3 升级踩坑实录 里有一份 Jakarta 与 JDK 17+ 的兼容清单可参考。
七、实战场景三:订单状态机
订单状态如果用字符串或枚举表达,状态流转常常散落在各处 if 里。用密封类把”状态”建模为类型,再把”流转”写成穷尽 switch,非法流转在编译期就被挡住。
public sealed interface OrderState permits Created, Paid, Shipped, Done { }
public final class Created implements OrderState { }
public final class Paid implements OrderState { }
public final class Shipped implements OrderState { }
public final class Done implements OrderState { }
public boolean canShip(OrderState s) {
return switch (s) {
case Created c -> false; // 未付款不能发货
case Paid p -> true; // 已付款可发货
case Shipped s -> false; // 已发货不可重复
case Done d -> false;
};
}
如果业务后来又引入 Refunded 状态,所有 switch 都会立刻编译失败,提示你补全每个分支——这正是封闭层次相对于”字符串 + 注释文档”的核心优势。
八、与枚举、普通继承的取舍
三者不是替代关系,而是按”是否需要携带不同结构的数据”来选:
| 方案 | 适合场景 | 能否携带不同字段 | 子类能否带状态 |
|---|---|---|---|
| 枚举 enum | 固定常量集合,如星期、状态码 | 每个常量结构相同 | 有限(靠字段) |
| 密封类 sealed | 固定但结构各异的子类型 | 能,每个子类自由定义 | 能,可含字段与方法 |
| 普通继承 | 需要长期、开放地扩展 | 能 | 能 |
经验法则:当”子类型的种类固定,但每个子类型的数据结构不同”时,密封类是最优解;如果只是几个同构常量,用 enum 更轻;只有当未来确实要开放扩展,才用普通继承。
九、常见坑与版本边界
- JDK 版本:密封类是 Java 15 预览、17 正式,低于 17 无法使用,部署前确认运行时 JDK。
- permits 必须穷尽:声明
sealed却漏写某个子类,或子类不在同一编译单元且未显式修饰,都会编译失败。 - 不要过度密封:把本该开放扩展的 SPI / 插件接口也封死,会牺牲框架的扩展性,只在”领域模型”层面使用。
- 序列化风险:密封类被反序列化时仍可能被构造出未预期实例,安全敏感场景配合
readObject防护。
在 Java 21 上,密封类还能与虚拟线程、record 模式组合出更现代的写法;想了解 Java 21 的新特性落地,Java 21 虚拟线程实战 值得一读。若你用 Java 做 AI 应用编排,LangChain4j 实战 也用到了密封风格的结果建模思路。
十、落地检查清单
- 这个父类”子类型集合”是否真的固定?固定才用
sealed。 - 每个直接子类是否都显式声明了
final/sealed/non-sealed? - 对所有父类的
switch是否利用了穷尽检查(不写default分支,让编译器兜底)? - 新增子类型时,是否确认了所有
switch都已重新编译通过? - CI 是否锁定 JDK 17+?建议在流水线里加一条
javac -source 17的编译门禁。
密封类不是语法糖,而是把”领域边界”写进类型系统的工程手段。它让”有哪些可能”从文档约定升级为编译器契约,配合模式匹配,把一大类运行时隐患消灭在编译期。下次当你想用字符串或枚举表达一个”固定但结构各异”的模型时,先想想:这能不能用密封类?




