Java 基础体系 · 第 43/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 Enum 深入:状态、策略、EnumSet、EnumMap 和持久化边界
enum 不只是“几个常量的集合”。在 Java 中,枚举类型同时具备:
- 一组受编译器约束的有限实例;
- 类型安全的状态空间;
- 可以携带字段、构造器和方法的对象;
- 可以承载策略行为的多态载体;
- 与
EnumSet、EnumMap配合的高效键空间; - 需要明确设计外部标识的持久化边界。
要正确使用枚举,不能只记住 OrderStatus.PAID 的写法,还需要区分三类问题:
- 状态是否允许从一个值转移到另一个值;
- 行为是否应该由枚举常量自己实现;
- 枚举常量的 Java 名称是否等同于数据库、消息或 API 中的稳定标识。
下面以 Java 25 LTS 的语言和标准库语义为基础,逐层说明这些问题。
1. Enum 的本质:受限实例集合,而不是整数别名
1.1 枚举声明定义了一个枚举类型和若干实例
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELLED
}
这段声明定义了:
- 一个名为
OrderStatus的枚举类型; - 四个枚举常量:
CREATED、PAID、SHIPPED、CANCELLED; - 每个常量都是
OrderStatus类型的一个实例。
可以近似理解为:
final class OrderStatus extends Enum<OrderStatus> {
public static final OrderStatus CREATED = ...;
public static final OrderStatus PAID = ...;
public static final OrderStatus SHIPPED = ...;
public static final OrderStatus CANCELLED = ...;
}
这不是可直接编写的等价源码,但能说明枚举的几个关键性质:
- 枚举类型继承
java.lang.Enum; - 枚举不能再被普通类继承;
- 每个枚举常量通常是该类型的唯一实例;
- 枚举常量可以有字段和方法;
- 枚举对象仍然是对象,不是裸整数。
枚举实例由 JVM 和类初始化过程创建。调用者不能使用普通 new 创建新的 OrderStatus 实例,因此以下写法非法:
// new OrderStatus(); // 编译错误
1.2 枚举常量适合用 == 比较
if (status == OrderStatus.PAID) {
// ...
}
枚举常量具有稳定的对象身份,同一个枚举常量在同一个类加载环境中只对应一个实例。因此,枚举比较通常使用 ==,而不是依赖 equals:
OrderStatus a = OrderStatus.PAID;
OrderStatus b = OrderStatus.valueOf("PAID");
System.out.println(a == b); // true
valueOf 返回的是已有的枚举常量,而不是创建一个新对象。
如果变量可能为 null,应把常量放在左侧,或者先处理空值:
if (OrderStatus.PAID.equals(status)) {
// status 为 null 时不会抛异常
}
不过,这种写法会掩盖“状态不应为 null”的建模问题。对于业务状态,通常更应该在边界处校验,而不是让 null 传播到业务核心。
1.3 name()、ordinal() 和 toString() 不是同一概念
每个枚举常量有三个容易混淆的表示:
OrderStatus status = OrderStatus.PAID;
System.out.println(status.name()); // PAID
System.out.println(status.ordinal()); // 1
System.out.println(status.toString()); // 通常是 PAID
它们的含义不同:
name():源代码中声明的常量名称;ordinal():按声明顺序从0开始的序号;toString():默认情况下返回名称,但可以被枚举类型重写。
ordinal() 是枚举在声明中的位置,不是业务编号。下面的修改会改变 ordinal():
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELLED
}
如果后来插入:
enum OrderStatus {
CREATED,
PENDING_REVIEW,
PAID,
SHIPPED,
CANCELLED
}
那么 PAID.ordinal() 从 1 变成 2。因此,ordinal() 不应作为数据库值、消息协议值或文件格式中的持久化标识。
2. 枚举的语言机制:构造器、字段、方法和常量特定类体
2.1 枚举可以携带数据
enum HttpMethod {
GET(false),
POST(true),
PUT(true),
DELETE(true);
private final boolean hasRequestBody;
HttpMethod(boolean hasRequestBody) {
this.hasRequestBody = hasRequestBody;
}
public boolean hasRequestBody() {
return hasRequestBody;
}
}
使用时:
System.out.println(HttpMethod.GET.hasRequestBody()); // false
System.out.println(HttpMethod.POST.hasRequestBody()); // true
枚举构造器通常声明为包可见或私有语义;外部代码不能调用它创建新常量。常量实参在类初始化时用于构造枚举实例。
2.2 枚举可以定义普通方法
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELLED;
public boolean isTerminal() {
return this == SHIPPED || this == CANCELLED;
}
}
这里的 isTerminal() 是状态的属性查询,而不是状态迁移。调用:
OrderStatus.SHIPPED.isTerminal(); // true
OrderStatus.PAID.isTerminal(); // false
把查询行为放进枚举的好处是调用方不必重复维护同一组常量判断。可是,如果方法开始包含大量跨对象业务逻辑,枚举就可能承担过多职责;这时应把领域服务或聚合对象引入,而不是把所有逻辑都堆入枚举。
2.3 常量特定类体提供有限的多态实现
枚举常量可以覆盖枚举声明中的抽象方法:
enum RoundingPolicy {
DOWN {
@Override
int round(double value) {
return (int) Math.floor(value);
}
},
UP {
@Override
int round(double value) {
return (int) Math.ceil(value);
}
},
NEAREST {
@Override
int round(double value) {
return (int) Math.round(value);
}
};
abstract int round(double value);
}
调用:
System.out.println(RoundingPolicy.DOWN.round(3.8)); // 3
System.out.println(RoundingPolicy.UP.round(3.2)); // 4
System.out.println(RoundingPolicy.NEAREST.round(3.6)); // 4
这里的关键不是“把 switch 写进了枚举”,而是每个常量都提供了同一抽象操作的不同实现。调用方只依赖:
policy.round(value)
而不需要知道每个策略的分支细节。
这种方式适合:
- 策略集合在编译期基本稳定;
- 每个策略的行为短小且内聚;
- 策略之间共享同一个清晰接口。
如果策略需要注入数据库、网络客户端或配置对象,则枚举常量无法像普通对象那样由依赖注入容器构造,外部策略类通常更合适。
2.4 枚举常量不一定是同一个运行时类
对于没有常量特定类体的枚举常量,通常所有常量的运行时类就是枚举类型本身。对于有常量特定类体的常量,编译器会生成相应的匿名子类结构。
因此,业务代码不应依赖:
status.getClass() == OrderStatus.class
来判断枚举常量。应使用枚举类型本身的语义,例如 ==、name() 或显式方法。
3. Enum 与状态建模:状态集合不等于状态机
3.1 状态、事件和迁移是三个不同概念
定义:
- 状态:对象当前处于什么阶段,例如
CREATED、PAID; - 事件:试图改变状态的动作,例如
PAY、SHIP; - 迁移:在某个当前状态下接收某个事件后得到的新状态。
可以形式化为:
其中:
- 是状态集合;
- 是事件集合;
- 是状态转移函数;
- 符号 表示这是一个部分函数,并非所有状态和事件组合都有效。
例如:
但:
没有定义,因为已发货订单不能再次支付。
仅仅声明:
enum OrderStatus {
CREATED, PAID, SHIPPED, CANCELLED
}
只定义了状态集合 ,没有定义事件集合 ,也没有定义迁移函数 。因此,枚举本身不是完整的状态机。
3.2 用 switch 显式表达迁移规则
enum OrderEvent {
PAY,
SHIP,
CANCEL
}
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELLED;
public OrderStatus apply(OrderEvent event) {
return switch (this) {
case CREATED -> switch (event) {
case PAY -> PAID;
case CANCEL -> CANCELLED;
case SHIP -> throw invalid(event);
};
case PAID -> switch (event) {
case SHIP -> SHIPPED;
case PAY, CANCEL -> throw invalid(event);
};
case SHIPPED, CANCELLED ->
throw invalid(event);
};
}
private IllegalStateException invalid(OrderEvent event) {
return new IllegalStateException(
"event " + event + " is invalid in state " + this);
}
}
调用:
OrderStatus status = OrderStatus.CREATED;
status = status.apply(OrderEvent.PAY); // PAID
status = status.apply(OrderEvent.SHIP); // SHIPPED
status = status.apply(OrderEvent.PAY); // 抛出异常
迁移过程是:
CREATED --PAY--> PAID --SHIP--> SHIPPED
最后一次调用对应:
δ(SHIPPED, PAY)
该组合没有合法结果,因此抛出异常,而不是悄悄返回原状态。
3.3 switch 的穷尽性和默认分支
对枚举使用表达式形式的 switch 时,可以覆盖所有枚举常量而不写 default。编译器会检查当前源代码中是否覆盖了所有常量:
static String description(OrderStatus status) {
return switch (status) {
case CREATED -> "订单已创建";
case PAID -> "订单已支付";
case SHIPPED -> "订单已发货";
case CANCELLED -> "订单已取消";
};
}
这提供的是编译期穷尽性检查。如果以后在源码中加入 REFUNDED,重新编译时,未覆盖该常量的 switch 会报告错误。
但这不等于运行时系统会自动适配新状态:
- 已编译的旧程序不会因为另一个版本新增常量而自动获得处理逻辑;
- 外部输入仍可能包含当前程序不认识的字符串;
default会隐藏“新增枚举常量但忘记补业务逻辑”的编译期信号。
因此,对于核心领域迁移,通常应避免无意义的 default。对于确实需要向前兼容的边界解析代码,则应显式设计未知值策略。
3.4 迁移表适合状态较多、规则数据化的场景
如果状态和事件数量增加,嵌套 switch 可能变得难以审查。可以用 EnumMap 表达迁移表:
import java.util.EnumMap;
import java.util.Map;
enum TransitionTable {
;
static Map<OrderEvent, OrderStatus> from(OrderStatus status) {
EnumMap<OrderEvent, OrderStatus> result =
new EnumMap<>(OrderEvent.class);
switch (status) {
case CREATED -> {
result.put(OrderEvent.PAY, OrderStatus.PAID);
result.put(OrderEvent.CANCEL, OrderStatus.CANCELLED);
}
case PAID -> result.put(OrderEvent.SHIP, OrderStatus.SHIPPED);
case SHIPPED, CANCELLED -> {
// 没有合法事件
}
}
return result;
}
}
这里 EnumMap 的键类型是 OrderEvent。它表达的是:
读取时可以把缺少的键视为非法迁移:
OrderStatus next = TransitionTable.from(status).get(event);
if (next == null) {
throw new IllegalStateException("非法状态迁移");
}
不过,这种实现每次调用都创建一张表。真实代码中可以在初始化阶段构建不可变或只读的表,或者继续使用 switch。这里重要的是建模关系,而不是机械地把所有 switch 改成 map。
4. Enum 作为策略:状态描述“在哪里”,策略描述“怎么做”
状态和策略经常同时出现,但含义不同。
例如:
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELLED
}
enum ShippingPolicy {
STANDARD {
@Override
int deliveryDays() {
return 5;
}
},
EXPRESS {
@Override
int deliveryDays() {
return 1;
}
};
abstract int deliveryDays();
}
OrderStatus.PAID 表示订单所处阶段;ShippingPolicy.EXPRESS 表示计算配送时采用的规则。前者通常随事件变化,后者通常由用户选择、配置或业务条件决定。
4.1 策略枚举的完整使用示例
import java.math.BigDecimal;
enum DiscountPolicy {
NONE {
@Override
BigDecimal apply(BigDecimal amount) {
return amount;
}
},
MEMBER {
@Override
BigDecimal apply(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.90"));
}
},
PROMOTION {
@Override
BigDecimal apply(BigDecimal amount) {
return amount.subtract(new BigDecimal("20.00"))
.max(BigDecimal.ZERO);
}
};
abstract BigDecimal apply(BigDecimal amount);
}
public class EnumStrategyDemo {
public static void main(String[] args) {
BigDecimal original = new BigDecimal("100.00");
for (DiscountPolicy policy : DiscountPolicy.values()) {
System.out.println(policy + ": " + policy.apply(original));
}
}
}
预期输出为:
NONE: 100.00
MEMBER: 90.0000
PROMOTION: 80.00
这里没有把折扣逻辑散落在调用方的 if 或 switch 中。每个策略满足同一个抽象操作:
但策略的集合是封闭的。增加新策略必须修改枚举源码并重新编译。
4.2 何时不应使用策略枚举
如果策略具有以下特征,普通接口和外部实现通常更合适:
- 策略由插件动态加载;
- 策略数量由部署配置决定;
- 策略需要独立版本和独立发布;
- 策略依赖复杂的运行时对象;
- 策略逻辑很长,需要多个协作者。
枚举策略的优势是封闭集合、类型安全和调用简单;代价是扩展必须修改代码,且枚举实例不适合作为带复杂生命周期的服务对象。
5. EnumSet:面向枚举全集的集合运算
5.1 EnumSet 的数学含义
假设:
enum Permission {
READ,
WRITE,
DELETE,
AUDIT
}
全部枚举常量组成全集:
一个权限集合是 的子集,例如:
EnumSet<Permission> 就是这种子集的类型安全表示。
import java.util.EnumSet;
EnumSet<Permission> permissions =
EnumSet.of(Permission.READ, Permission.WRITE);
常用操作对应集合运算:
permissions.add(Permission.AUDIT); // A ∪ {AUDIT}
permissions.remove(Permission.WRITE); // A - {WRITE}
permissions.contains(Permission.READ); // 成员判断
permissions.containsAll(other); // A 是否包含 other
permissions.retainAll(other); // A ∩ other
permissions.removeAll(other); // A - other
5.2 为什么 EnumSet 通常适合权限和标志集合
枚举类型的常量集合在编译期已知。EnumSet 可以利用每个枚举常量的序号把集合表示为位集合:
- 常量对应某个 bit 位置;
- bit 为
1表示该常量在集合中; - 并集、交集、差集可以对应位或、位与、位清除。
例如,假设四个常量按顺序对应:
READ -> 0001
WRITE -> 0010
DELETE -> 0100
AUDIT -> 1000
则:
{READ, WRITE} -> 0011
{WRITE, AUDIT} -> 1010
交集 -> 0011 & 1010 = 0010 -> {WRITE}
并集 -> 0011 | 1010 = 1011 -> {READ, WRITE, AUDIT}
这是实现层面的典型表示思路;EnumSet 的 API 契约保证其是专用于枚举类型的 Set,具体内部布局不应作为业务代码依赖。对常量数量较少的枚举,JDK 常见实现会使用单个机器字;常量较多时会使用更大的结构。具体性能仍需通过实际基准验证。
5.3 创建 EnumSet 时需要保留元素类型
EnumSet<Permission> empty = EnumSet.noneOf(Permission.class);
EnumSet<Permission> all = EnumSet.allOf(Permission.class);
EnumSet<Permission> readOnly = EnumSet.of(Permission.READ);
空集合没有元素可用于推断枚举类型,因此必须提供 Permission.class:
// 没有元素时不能写成某种“自动推断”的空 EnumSet
EnumSet<Permission> empty = EnumSet.noneOf(Permission.class);
从已有集合复制时可以推断:
EnumSet<Permission> copy = EnumSet.copyOf(readOnly);
但对于一个普通的空 Collection<Permission>,EnumSet.copyOf 无法从元素推断类型,可能抛出异常。此时应使用 noneOf。
5.4 range 和 complementOf 依赖枚举声明顺序
EnumSet<Permission> range =
EnumSet.range(Permission.READ, Permission.DELETE);
由于声明顺序是:
READ, WRITE, DELETE, AUDIT
所以结果是:
[READ, WRITE, DELETE]
range 不是按照字典序,而是按照枚举常量的声明顺序取闭区间。
complementOf 也相对于整个枚举全集:
EnumSet<Permission> nonWrite =
EnumSet.complementOf(EnumSet.of(Permission.WRITE));
结果是:
[READ, DELETE, AUDIT]
这意味着如果将来新增枚举常量,allOf、complementOf 和依赖全集的逻辑都会改变。对于权限系统,这种变化必须经过审查:新增权限可能被某个“排除少数项”的集合自动纳入。
5.5 EnumSet 的迭代顺序和并发边界
EnumSet 的迭代顺序通常与枚举声明顺序一致,API 也定义了其迭代器按枚举常量自然顺序遍历。不要把这个顺序误解为业务优先级;如果业务顺序重要,应显式建模。
EnumSet 不是线程安全集合。以下操作不是原子的:
if (!set.contains(permission)) {
set.add(permission);
}
多个线程共享时,需要:
- 外部锁;
- 正确的并发容器包装;
- 或者采用不可变快照并通过安全发布替换引用。
如果只是构造后只读,可以:
EnumSet<Permission> mutable = EnumSet.of(Permission.READ);
Set<Permission> readOnly = java.util.Collections.unmodifiableSet(mutable);
但这只是禁止通过包装视图修改;如果仍保留并使用 mutable 引用,底层变化会反映到视图中。要形成真正独立的只读快照,应先复制,再对副本包装。
6. EnumMap:把枚举类型作为完整键空间
6.1 EnumMap 的键必须是一个枚举类型
import java.util.EnumMap;
import java.util.Map;
Map<OrderStatus, String> labels =
new EnumMap<>(OrderStatus.class);
labels.put(OrderStatus.CREATED, "待创建");
labels.put(OrderStatus.PAID, "已支付");
labels.put(OrderStatus.SHIPPED, "已发货");
EnumMap 专门面向枚举键。与普通 HashMap 相比,它知道键的枚举类型和有限键空间,通常可以使用更紧凑的内部表示。
EnumMap 不允许 null 键,但允许 null 值:
// labels.put(null, "未知"); // NullPointerException
labels.put(OrderStatus.CANCELLED, null); // 合法
如果“没有映射”和“映射到 null”必须区分,应使用 containsKey:
if (labels.containsKey(OrderStatus.CANCELLED)) {
String label = labels.get(OrderStatus.CANCELLED);
}
6.2 EnumMap 的自然顺序不是插入顺序
Map<OrderStatus, Integer> counts =
new EnumMap<>(OrderStatus.class);
counts.put(OrderStatus.SHIPPED, 3);
counts.put(OrderStatus.CREATED, 10);
counts.put(OrderStatus.PAID, 7);
System.out.println(counts);
遍历顺序按枚举声明顺序,而不是上述插入顺序:
{CREATED=10, PAID=7, SHIPPED=3}
这适合状态统计、有限状态到配置的映射和策略查找。如果需要按插入顺序,应使用其他集合;如果需要按业务排序,应使用显式 Comparator 或排序后的视图。
6.3 EnumMap 适合表达“每个状态一个值”
例如统计订单数量:
EnumMap<OrderStatus, Integer> counts =
new EnumMap<>(OrderStatus.class);
for (OrderStatus status : OrderStatus.values()) {
counts.put(status, 0);
}
void increment(EnumMap<OrderStatus, Integer> counts,
OrderStatus status) {
counts.merge(status, 1, Integer::sum);
}
初始化后的状态是:
CREATED=0, PAID=0, SHIPPED=0, CANCELLED=0
收到一个 PAID 订单后:
CREATED=0, PAID=1, SHIPPED=0, CANCELLED=0
如果不先初始化,缺少的键通过 get 得到 null。可以使用 merge 或 getOrDefault,但这两种写法表达的语义不同:
int current = counts.getOrDefault(status, 0);
counts.put(status, current + 1);
这段代码在单线程中成立;在并发环境中,读取和写入不是一个原子操作。
6.4 EnumMap 不提供并发保证
EnumMap 本身不是并发 Map。下面的代码在多线程下可能丢失更新:
counts.put(status, counts.getOrDefault(status, 0) + 1);
如果确实需要并发统计,可以让值本身提供并发操作:
EnumMap<OrderStatus, java.util.concurrent.atomic.LongAdder> counters =
new EnumMap<>(OrderStatus.class);
for (OrderStatus status : OrderStatus.values()) {
counters.put(status, new java.util.concurrent.atomic.LongAdder());
}
counters.get(OrderStatus.PAID).increment();
这里仍然需要保证 EnumMap 的初始化和安全发布已经完成;之后只对已存在的 LongAdder 执行并发增量。若运行期间还要增删键,则需要额外同步。
7. EnumSet 与 EnumMap 的组合:状态能力和状态配置
一个常见模型是:
EnumSet表示某个角色拥有的一组能力;EnumMap表示每种状态对应的配置或处理器。
enum Capability {
VIEW,
EDIT,
APPROVE
}
enum UserRole {
READER,
EDITOR,
ADMIN
}
import java.util.EnumMap;
import java.util.EnumSet;
EnumMap<UserRole, EnumSet<Capability>> roleCapabilities =
new EnumMap<>(UserRole.class);
roleCapabilities.put(
UserRole.READER,
EnumSet.of(Capability.VIEW));
roleCapabilities.put(
UserRole.EDITOR,
EnumSet.of(Capability.VIEW, Capability.EDIT));
roleCapabilities.put(
UserRole.ADMIN,
EnumSet.allOf(Capability.class));
查询:
boolean canEdit =
roleCapabilities.get(UserRole.EDITOR)
.contains(Capability.EDIT);
这里有一个重要的别名风险:
EnumSet<Capability> editorCapabilities =
roleCapabilities.get(UserRole.EDITOR);
editorCapabilities.clear();
这会直接修改 map 中保存的集合。如果配置应该不可变,应在边界处复制或包装,而不是把可变集合直接暴露给调用者。
8. 枚举解析:valueOf 适合内部名称,不适合直接承担外部协议
8.1 valueOf 是精确匹配
OrderStatus status = OrderStatus.valueOf("PAID");
它要求输入与 name() 完全一致:
OrderStatus.valueOf("paid"); // IllegalArgumentException
OrderStatus.valueOf(" PAID"); // IllegalArgumentException
OrderStatus.valueOf(null); // NullPointerException
因此,以下代码不应直接处理用户输入、HTTP 参数或数据库历史数据:
OrderStatus status = OrderStatus.valueOf(externalValue);
失败路径至少包括:
- 外部值大小写不同;
- 外部值包含空格;
- 外部系统使用别名;
- 外部值对应已删除或未来新增的常量;
- 输入为
null。
8.2 显式外部代码映射
更安全的做法是区分 Java 名称和外部稳定代码:
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
enum OrderStatus {
CREATED("created"),
PAID("paid"),
SHIPPED("shipped"),
CANCELLED("cancelled");
private final String code;
OrderStatus(String code) {
this.code = code;
}
public String code() {
return code;
}
private static final Map<String, OrderStatus> BY_CODE =
Stream.of(values()).collect(Collectors.toUnmodifiableMap(
OrderStatus::code,
Function.identity()));
public static OrderStatus fromCode(String code) {
if (code == null) {
throw new IllegalArgumentException("status code must not be null");
}
OrderStatus result = BY_CODE.get(code);
if (result == null) {
throw new IllegalArgumentException(
"unknown order status code: " + code);
}
return result;
}
}
解析流程是:
- 输入
code; - 拒绝
null; - 通过稳定代码查找;
- 找到则返回枚举实例;
- 找不到则明确报告未知值。
如果系统需要兼容旧值,可以把兼容逻辑写入映射边界:
static OrderStatus fromPersistedCode(String code) {
return switch (code) {
case "created", "new" -> OrderStatus.CREATED;
case "paid" -> OrderStatus.PAID;
case "shipped" -> OrderStatus.SHIPPED;
case "cancelled", "canceled" -> OrderStatus.CANCELLED;
default -> throw new IllegalArgumentException(
"unknown persisted status: " + code);
};
}
兼容别名应有明确的生命周期和测试,否则旧协议名称会无限留在核心模型中。
9. 持久化边界:枚举名称不是天然的数据库设计
9.1 Java 内部身份与外部标识必须分离
在 Java 代码中:
OrderStatus.PAID
是类型安全的对象引用;在数据库、JSON、消息队列或文件中,通常需要一个字符串或整数表示。
这两个层次不应混同:
Java 内部值:OrderStatus.PAID
数据库代码:paid
API 文本值:paid
如果直接把 name() 当作外部标识:
status.name(); // "PAID"
那么重命名:
PAID -> PAYMENT_COMPLETED
会破坏历史数据和已有消费者。即使业务含义没有改变,外部字符串已经改变。
9.2 数据库整数和 ordinal 的错误映射
错误示例:
int dbValue = status.ordinal();
OrderStatus status = OrderStatus.values()[dbValue];
它依赖两个隐含条件:
- 枚举声明顺序永远不变;
- 数据库中的整数永远有效。
只要插入、删除或重排常量,旧整数就可能映射到另一个状态。删除常量还可能导致数组越界。
正确方式是显式定义稳定整数代码:
enum PaymentState {
UNPAID(10),
PAID(20),
REFUNDED(30);
private final int code;
PaymentState(int code) {
this.code = code;
}
public int code() {
return code;
}
public static PaymentState fromCode(int code) {
for (PaymentState state : values()) {
if (state.code == code) {
return state;
}
}
throw new IllegalArgumentException("unknown payment state: " + code);
}
}
数据库中保存的是 10、20、30,而不是 ordinal()。
9.3 字符串存储的取舍
保存字符串通常比保存 ordinal 安全,但仍然有重命名问题:
PAID
如果把常量重命名为 SETTLED,使用 name() 作为持久化值的历史数据就无法直接解析。
更稳妥的是保存专门的稳定代码:
paid
Java 常量名可以重构,而外部代码保持不变:
PAID("paid")
但“稳定代码”也必须遵循版本治理:
- 不随意复用旧代码;
- 删除状态时保留历史读取能力;
- 对未知代码定义拒绝、降级或隔离策略;
- 在迁移脚本中记录代码含义,而不是只记录 Java 常量名。
9.4 JPA 等框架的枚举映射边界
常见 ORM 映射方式包括:
@Enumerated(EnumType.ORDINAL)
private OrderStatus status;
和:
@Enumerated(EnumType.STRING)
private OrderStatus status;
ORDINAL 把声明顺序作为数据库值,风险最大。枚举顺序变化会改变含义。
STRING 保存枚举名称,避免顺序问题,但重命名常量会破坏历史数据,并且数据库值与 Java 内部名称耦合。
如果要求独立稳定代码,通常应使用框架提供的转换器机制,把:
OrderStatus.PAID <-> "paid"
显式转换。具体注解和配置属于框架行为,不是 Java enum 语言规范的保证,必须以所用 ORM 版本的文档和测试为准。
10. Java 原生序列化与枚举:名称稳定,但不代表无兼容成本
Java 原生序列化对枚举有特殊处理。枚举常量不是按 ordinal() 序列化,而是按常量名称处理。反序列化时,运行时需要在目标枚举类型中找到同名常量。
因此:
- 调整常量声明顺序通常不会改变原生序列化的枚举身份;
- 重命名或删除常量会导致旧数据无法正常恢复;
- 新增常量不会让旧程序自动理解该常量;
- 枚举不能通过普通的
readObject、writeObject机制改变其特殊序列化语义。
这仍然不是一个适合作为长期跨服务协议的理由。原生序列化还绑定 Java 类名、类加载环境和 Java 类型结构。跨服务、跨语言或长期归档时,通常应使用明确版本化的外部格式,并自行定义未知值策略。
11. 外部数据的完整处理流程
下面给出一个不依赖第三方框架的端到端示例。它模拟:
- 从数据库或消息中读取字符串代码;
- 转成内部枚举;
- 执行状态迁移;
- 转回稳定代码;
- 对未知输入走错误路径。
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class OrderWorkflowDemo {
enum Event {
PAY,
SHIP,
CANCEL
}
enum Status {
CREATED("created"),
PAID("paid"),
SHIPPED("shipped"),
CANCELLED("cancelled");
private final String code;
Status(String code) {
this.code = code;
}
public String code() {
return code;
}
private static final Map<String, Status> BY_CODE =
Stream.of(values()).collect(Collectors.toUnmodifiableMap(
Status::code,
Function.identity()));
public static Status fromCode(String code) {
if (code == null) {
throw new IllegalArgumentException(
"status code must not be null");
}
Status status = BY_CODE.get(code);
if (status == null) {
throw new IllegalArgumentException(
"unknown status code: " + code);
}
return status;
}
public Status apply(Event event) {
if (event == null) {
throw new IllegalArgumentException(
"event must not be null");
}
return switch (this) {
case CREATED -> switch (event) {
case PAY -> PAID;
case CANCEL -> CANCELLED;
case SHIP -> invalid(event);
};
case PAID -> switch (event) {
case SHIP -> SHIPPED;
case PAY, CANCEL -> invalid(event);
};
case SHIPPED, CANCELLED -> invalid(event);
};
}
private Status invalid(Event event) {
throw new IllegalStateException(
"cannot apply " + event + " in state " + this);
}
}
public static void main(String[] args) {
String persistedCode = "created";
Status status = Status.fromCode(persistedCode);
System.out.println(status); // CREATED
status = status.apply(Event.PAY);
System.out.println(status.code()); // paid
status = status.apply(Event.SHIP);
System.out.println(status.code()); // shipped
try {
status.apply(Event.PAY);
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
try {
Status.fromCode("unknown");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
预期输出类似:
CREATED
paid
shipped
cannot apply PAY in state SHIPPED
unknown status code: unknown
关键边界有两处:
fromCode负责外部值到内部模型的转换;apply负责内部状态机规则。
如果把字符串解析、数据库兼容、迁移规则和业务副作用都放进枚举的一个方法,枚举会同时成为协议适配器、状态机和领域服务,最终难以测试和演进。
12. 失败路径和诊断方法
12.1 IllegalArgumentException:外部代码无法映射
常见表现:
java.lang.IllegalArgumentException: No enum constant ...
原因通常是:
- 使用了
valueOf处理外部字符串; - 大小写不一致;
- 数据包含不可见空格;
- 历史数据使用了已删除的名称;
- 发送方升级后发送了接收方未知的新值。
诊断时应记录:
- 原始外部代码;
- 数据来源和版本;
- 目标枚举类型;
- 是否经过别名或迁移映射。
不要只记录 ordinal 或内部异常堆栈,否则难以定位协议问题。
12.2 NullPointerException:把 null 当成合法状态
这些代码都可能因为 null 出错:
status.name();
status.ordinal();
status.apply(event);
EnumSet.of(status);
应在外部边界尽早处理 null,并决定它代表:
- 缺失字段;
- 未知状态;
- 尚未初始化;
- 数据损坏。
如果这些情况在业务上不同,就不能都压缩成 null。
12.3 switch 编译错误:新增常量后的保护机制
新增枚举常量后,穷尽式 switch 可能无法编译。这不是编译器过于严格,而是它发现状态空间增加后,原有函数不再覆盖全部输入。
修复时需要回答业务问题:
- 新状态是否允许当前操作?
- 是否应迁移到某个已有状态?
- 是否应显式抛出非法迁移异常?
- 这个
switch是否真的需要对新状态做统一默认处理?
直接添加:
default -> ...
可以让代码重新编译,但可能把真正的状态遗漏隐藏起来。
12.4 EnumSet 或 EnumMap 的修改异常
如果使用只读视图:
Set<Permission> view =
java.util.Collections.unmodifiableSet(set);
调用:
view.add(Permission.DELETE);
会抛出 UnsupportedOperationException。这是视图的设计结果,不是 EnumSet 的随机故障。
如果多个线程同时修改同一个 EnumSet 或 EnumMap,可能出现:
- 数据竞争;
- 迭代期间结构变化;
- 丢失更新;
- 观察到不一致的中间状态。
诊断重点不是“EnumSet 是否快”,而是确认所有权、发布和修改协议。
13. 枚举演进时需要区分三种兼容性
假设当前版本有:
enum Status {
CREATED,
PAID,
SHIPPED
}
后来增加:
REFUNDED
13.1 源码兼容性
重新编译时,未穷尽的 switch 可能失败。这能促使开发者补充处理逻辑。
13.2 二进制兼容性
已经编译的客户端未必立即失败,因为它可能只引用旧常量。但它也不可能自动知道新常量的语义。
13.3 数据兼容性
数据库或消息中可能出现:
refunded
旧程序无法解析它。如果边界直接使用 valueOf 或严格代码映射,就会失败;如果边界设置了一个错误的默认状态,则可能造成数据误处理。
所以新增枚举常量不是单纯的代码改动,还可能是:
- 数据库取值范围变化;
- API 响应集合变化;
- 消费者状态机变化;
- 权限全集变化;
EnumSet.complementOf结果变化;EnumMap初始化和统计逻辑变化。
14. 常见误解对照
误解一:Enum 是可读的整数常量
错误:
int status = OrderStatus.PAID.ordinal();
枚举不是整数别名。需要外部数字代码时,显式定义字段并实现解析方法。
误解二:name() 就是稳定业务值
name() 是 Java 源码名称。它适合调试、日志和内部精确匹配,不天然适合作为跨版本协议字段。
误解三:EnumSet 只是更短的 HashSet
EnumSet 的键空间是单一枚举类型的全集。它利用这个限制获得专门的表示和集合操作语义;它不能混合不同枚举类型,也不自动提供线程安全。
误解四:EnumMap 的顺序由插入顺序决定
EnumMap 的迭代顺序按枚举自然顺序。若业务需要插入顺序或自定义优先级,必须另行建模。
误解五:有了状态枚举就有了状态机
状态机还需要事件、合法迁移、非法迁移行为以及必要的副作用约束。枚举只提供状态值集合。
误解六:把策略写进枚举后就不需要测试
策略枚举仍包含可执行业务逻辑。应分别测试:
- 每个策略的输出;
- 边界输入;
- 状态迁移的合法和非法组合;
- 外部代码映射;
- 新增或删除常量后的持久化行为。
15. 选择方式的判断依据
可以用下面的边界来判断设计:
使用简单枚举
当只需要表示有限分类,并且没有复杂状态转移:
enum LogLevel {
TRACE, DEBUG, INFO, WARN, ERROR
}
使用带方法的枚举
当每个常量都有稳定、局部、短小的属性或行为:
enum Compression {
NONE,
GZIP,
ZSTD
}
使用策略枚举
当策略集合封闭,并且不同常量实现同一个清晰操作:
enum PricingPolicy {
RETAIL,
WHOLESALE
}
使用独立状态机或领域服务
当状态迁移依赖:
- 外部资源;
- 当前用户;
- 时间和配置;
- 数据库条件;
- 多个聚合对象;
- 复杂副作用和事务边界。
这时可以继续使用枚举表示状态,但不要让枚举独自承担整个状态机。
使用 EnumSet
当数据本质是“某个枚举全集的子集”,例如:
- 权限;
- 功能开关;
- 支持的能力;
- 已完成的阶段集合。
使用 EnumMap
当键是一个枚举类型,并且需要保存:
- 每种状态的统计值;
- 每种策略的配置;
- 每种事件的处理器;
- 状态到下一步规则的映射。
在持久化边界使用显式代码
当数据需要跨版本、跨服务或长期保存时,使用:
enum Status {
PAID("paid");
}
而不是使用:
status.ordinal()
status.name()
除非你明确接受它们各自的兼容性约束。
枚举最有价值的地方,是把一个有限、可审查的概念空间交给编译器和类型系统管理。EnumSet 把这个空间当作集合全集,EnumMap 把它当作有限键空间,策略枚举把它当作一组多态对象,状态机则在其上增加事件和迁移规则。
真正的持久化边界发生在 Java 对象之外:数据库代码、消息字段和 API 文本都应有独立的稳定标识。只要把 ordinal()、name()、内部状态和外部协议清楚分开,枚举才能同时保持类型安全、可读性和可演进性。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 25 sealed 类型与模式匹配:封闭层次、switch 穷尽和建模
- 下一篇:Java 25 嵌套类、内部类、局部类与匿名类:捕获和生命周期
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论