传统 Java 项目里,用 instanceof 做类型判断然后强转的写法几乎无处不在。每个写过两年以上 Java 的人都能在自己的代码库里找到几十处这样的模式:先判断是不是某个类型,再强转,再取值。JDK 16 到 21 这五个版本,把模式匹配和密封类的拼图一块块补齐了。现在你可以在 switch 里直接解构对象、让编译器帮你检查分支是否穷举完整,甚至把状态建模成一整套封闭的类型体系。本文用一个订单处理引擎的完整重构案例,把这三项特性的组合威力展示清楚。
一、三块拼图各自解决什么问题
在开始写代码之前,先把三个特性的角色说清楚。它们单独拿出来都有用,但合在一起才真正改变了 Java 处理类型分支的方式。
密封类是类型的”门禁”。用 sealed 关键字声明一个类或接口,然后明确列出所有允许的子类。编译器知道这个类型体系是封闭的,不会有未知的外部子类冒出来。这意味着当你遍历所有子类型时,编译器能判断你是否漏掉了某个分支。
模式匹配是把拆箱动作内置到判断里。以前写 if (obj instanceof String s) { ... } 还需要单独声明变量再强转。模式匹配让 instanceof 直接绑定变量,更关键的是它在 switch 里可以同时对类型和字段做解构——一条 case 语句既判断类型又取出你需要的值。
Record 类是数据的”快照”。不可变、字段自动生成、equals 和 hashCode 自动实现。用它做密封类的子类型特别合适,因为状态机里的每个状态节点本质就是一组不可变的数据。
三者叠加在一起,传统的”一个状态字段加一堆 if-else”的写法,就能被重构为类型安全、编译期可验证的状态机。
二、先看老写法:用一个字段驱动所有分支
假设有一个订单处理系统,订单在创建后依次经过待支付、已支付、配送中、已完成四个状态,如果用户取消则进入已取消状态。传统的实现大概长这样:
public class Order {
private String orderId;
private String status; // "pending", "paid", "shipping", "completed", "cancelled"
private BigDecimal amount;
private String paymentMethod;
private String trackingNumber;
private String cancelReason;
// 根据状态执行不同的操作
public String getDisplayInfo() {
if ("pending".equals(status)) {
return "订单待支付,金额:" + amount;
} else if ("paid".equals(status)) {
return "已支付,支付方式:" + paymentMethod;
} else if ("shipping".equals(status)) {
return "配送中,快递单号:" + trackingNumber;
} else if ("completed".equals(status)) {
return "已完成";
} else if ("cancelled".equals(status)) {
return "已取消,原因:" + cancelReason;
}
throw new IllegalStateException("未知状态");
}
public boolean canRefund() {
return "paid".equals(status) || "shipping".equals(status);
}
}
这段代码的问题不是一眼就能看出来的——它看起来挺简单。真正的问题在于:每个状态需要的字段完全不同。待支付状态需要金额,配送中状态需要快递单号,已取消状态需要取消原因。但它们全塞在一个类里,你没法从类型上区分哪些字段在哪个状态下是有效的。一个 Order 对象的 trackingNumber 在待支付状态下有意义吗?没有,但代码里任何地方都能访问它。
另一个隐藏问题是分支的穷举性。如果有人加了一个新状态 "refunding",getDisplayInfo() 和 canRefund() 里都得记得加上对应的分支。这种遗漏编译器不会提醒你,只能在运行时通过抛异常来发现——前提是测试覆盖到了这条路径。
三、用密封类和 Record 重新建模状态
新设计的核心思路:每个状态是一个独立的类型,只携带自己需要的字段。所有状态共享一个密封接口作为父类型。
import java.math.BigDecimal;
// 密封接口:列出所有合法的状态类型
public sealed interface OrderState
permits OrderState.Pending,
OrderState.Paid,
OrderState.Shipping,
OrderState.Completed,
OrderState.Cancelled {
String orderId(); // 所有状态都有的公共字段
// 用 Record 定义每个具体状态
record Pending(String orderId, BigDecimal amount) implements OrderState {}
record Paid(String orderId, String paymentMethod, BigDecimal paidAmount) implements OrderState {}
record Shipping(String orderId, String trackingNumber, String carrier) implements OrderState {}
record Completed(String orderId, String completedAt) implements OrderState {}
record Cancelled(String orderId, String reason, String cancelledAt) implements OrderState {}
}
现在每个状态都是一个不可变的 Record,只携带自己需要的字段。Pending 有金额,Shipping 有快递单号和承运商,Cancelled 有原因和时间。任何拿到一个 OrderState 对象的代码,都能确定某个字段到底存不存在——如果类型是 Shipping,快递单号就一定存在;如果类型是 Pending,根本就没有快递单号这个字段。
这个设计把以前靠”约定和注释”保证的东西,变成了靠”类型系统”强制保证的东西。
四、用模式匹配的 switch 替换 instanceof 链条
有了类型化的状态,接下来把业务逻辑写成 switch 表达式。JDK 21 的 switch 支持模式匹配,能直接解构 Record 的字段:
public class OrderService {
// 生成展示信息——switch 模式匹配直接取出字段
public String getDisplayInfo(OrderState state) {
return switch (state) {
case OrderState.Pending(var id, var amount) ->
"订单 %s 待支付,金额:¥%s".formatted(id, amount);
case OrderState.Paid(var id, var method, var paid) ->
"订单 %s 已支付,方式:%s,实付:¥%s".formatted(id, method, paid);
case OrderState.Shipping(var id, var tracking, var carrier) ->
"订单 %s 配送中,%s:%s".formatted(id, carrier, tracking);
case OrderState.Completed(var id, var at) ->
"订单 %s 已于 %s 完成".formatted(id, at);
case OrderState.Cancelled(var id, var reason, var at) ->
"订单 %s 已取消,原因:%s,时间:%s".formatted(id, reason, at);
};
}
// 判断是否可退款——同样穷举所有状态
public boolean canRefund(OrderState state) {
return switch (state) {
case OrderState.Paid _ -> true;
case OrderState.Shipping _ -> true;
case OrderState.Pending _ -> false;
case OrderState.Completed _ -> false;
case OrderState.Cancelled _ -> false;
};
}
// 状态流转:返回下一个状态
public OrderState transition(OrderState current, String event) {
return switch (current) {
case OrderState.Pending p when "pay".equals(event) ->
new OrderState.Paid(p.orderId(), "alipay", p.amount());
case OrderState.Paid d when "ship".equals(event) ->
new OrderState.Shipping(d.orderId(), "SF123456", "顺丰");
case OrderState.Shipping s when "complete".equals(event) ->
new OrderState.Completed(s.orderId(), "2025-01-15");
case OrderState.Pending p when "cancel".equals(event) ->
new OrderState.Cancelled(p.orderId(), "用户取消", "2025-01-14");
default ->
throw new IllegalStateException("不支持的状态流转: %s -> %s".formatted(current, event));
};
}
}
这段代码有几点值得单独拿出来说。
第一,switch 里的每个 case 直接解构了 Record 的字段。case OrderState.Pending(var id, var amount) 这一行同时做了三件事:判断类型是不是 Pending、把 Pending 强转、取出里面的 orderId 和 amount。以前需要三行代码,现在一行就表达清楚了。而且如果 Pending 的字段变了——比如加了一个 createdAt——所有解构 Pending 的 case 都会因为参数数量不匹配而编译失败,一个都不会漏。
第二,canRefund() 里用了未命名变量 _。当一个 case 不需要用到字段值时,用下划线表示”我知道你在,但我不需要”。这是 JDK 21 的功能,让代码意图更明确——不是忘了写变量名,是真的不需要。
第三,transition() 里用了 when 守卫条件。case OrderState.Pending p when "pay".equals(event) 的意思是”当前状态是 Pending 并且事件是 pay 时,走这个分支”。守卫条件把额外的判断逻辑嵌进了模式匹配里,不需要在 case 体内再写 if。
五、编译器替你检查分支完整性
密封类最有价值的地方还没说到:当你新增一个状态时,编译器会告诉你哪些 switch 需要修改。
假设产品经理要求增加一个”退款中”状态 Refunding。你需要做的第一步是在 OrderState 的 permits 列表里加上它:
public sealed interface OrderState
permits OrderState.Pending,
OrderState.Paid,
OrderState.Shipping,
OrderState.Completed,
OrderState.Cancelled,
OrderState.Refunding { // 新增
// ... 现有内容不变
record Refunding(String orderId, String reason, BigDecimal refundAmount) implements OrderState {}
}
加上这一行之后,项目里所有使用 switch 穷举 OrderState 的地方,编译时都会直接报错——因为你漏掉了 Refunding 这个分支。IDE 会立刻标红,告诉你哪些文件需要补上对 Refunding 的处理。这不是运行时抛异常,不是 code review 时互相提醒,是编译器强制要求。
对于维护周期超过两年的项目,这个特性能省掉大量的回归测试和线上事故。状态流转逻辑越复杂、涉及的业务方越多,密封类 + 模式匹配的编译期检查就越有价值。
六、一个完整的调用示例
把上面的各段代码串起来,看一个完整的业务流程:
public class OrderWorkflowDemo {
public static void main(String[] args) {
var service = new OrderService();
// 创建订单,初始状态为 Pending
OrderState order = new OrderState.Pending("ORD-001", new BigDecimal("299.00"));
System.out.println(service.getDisplayInfo(order));
// 支付
order = service.transition(order, "pay");
System.out.println(service.getDisplayInfo(order));
System.out.println("可退款: " + service.canRefund(order));
// 发货
order = service.transition(order, "ship");
System.out.println(service.getDisplayInfo(order));
// 完成
order = service.transition(order, "complete");
System.out.println(service.getDisplayInfo(order));
System.out.println("可退款: " + service.canRefund(order));
}
}
输出结果:
订单 ORD-001 待支付,金额:¥299.00
订单 ORD-001 已支付,方式:alipay,实付:¥299.00
可退款: true
订单 ORD-001 配送中,顺丰:SF123456
订单 ORD-001 已于 2025-01-15 完成
可退款: false
整个过程中,order 变量的声明类型是 OrderState 接口,但每次流转后实际指向的具体 Record 实例都不同。调用方不需要做任何类型判断,getDisplayInfo() 和 canRefund() 内部用 switch 模式匹配自动分发到正确的分支。
七、嵌套模式匹配:解构多层级数据结构
真实业务里经常遇到嵌套的数据结构。比如一个配送信息里包含了地址,地址又包含省市。JDK 21 的模式匹配支持嵌套解构,可以一次把多层数据都取出来:
record Address(String province, String city, String district) {}
record Shipment(String trackingNumber, Address destination) {}
public String extractCityFromShipment(Object obj) {
return switch (obj) {
// 嵌套解构:一层层拆到 city 字段
case Shipment(_, Address(_, String city, _)) -> city;
default -> "未知城市";
};
}
这种嵌套解构在处理复杂 DTO、解析外部 API 返回的 JSON 树时很有用。不需要写层层判空和 getter 调用,一条 case 模式就把你关心的字段直接绑定到变量上。
八、什么时候还不需要用这套方案
不是所有的状态判断都需要重构为密封类加模式匹配。以下场景保持传统写法反而更合适:
状态数量少且不会增长。比如一个只有”启用”和”禁用”两种状态的开关,用布尔值或简单枚举就足够了。密封类的优势在于防止新增状态时的遗漏,两个状态不会新增的场景,投入产出比不高。
状态之间的字段差异不大。如果所有状态携带的数据结构几乎一样,用 Record 区分反而增加了类型数量,不如用一个类加一个状态字段来得直接。
状态经常需要序列化到数据库或缓存。密封类 + Record 的层级结构在序列化时比单一对象复杂。虽然 Jackson 等库已经支持得很好,但如果团队里有人不熟悉这些新特性,排查序列化问题会多花时间。
判断标准很简单:如果你的 instanceof 链条超过三个分支,或者每次加新状态你都要全文搜索改 N 个文件,那就值得考虑迁移。
九、总结
密封类、Record 和模式匹配三者组合在一起,把 Java 的类型判断从”运行时靠自觉”变成了”编译期强制验证”。状态机代码从一个扁平的类加一堆 if-else,变成了有层次结构的类型体系。每个状态只携带自己需要的字段,每个业务方法必须显式处理所有已知状态,任何遗漏都会在编阶段就暴露出来。
这不是炫技式的语法糖。这些特性解决的是一个很具体的问题:业务复杂度增长时,如何让类型系统帮你记住那些容易忘记的分支。订单处理只是一个例子,同样的模式可以用在审批流、游戏状态机、网络协议解析、支付回调处理等任何需要根据类型分发的场景。
文中的所有代码都需要 JDK 21 及以上版本。如果你是 Spring Boot 3.2 的项目,JDK 21 已经是官方推荐的基线版本,这些特性开箱即用。

