Java 基础体系 · 第 43/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 Enum 深入:状态、策略、EnumSet、EnumMap 和持久化边界

enum 不只是“几个常量的集合”。在 Java 中,枚举类型同时具备:

  • 一组受编译器约束的有限实例;
  • 类型安全的状态空间;
  • 可以携带字段、构造器和方法的对象;
  • 可以承载策略行为的多态载体;
  • EnumSetEnumMap 配合的高效键空间;
  • 需要明确设计外部标识的持久化边界。

要正确使用枚举,不能只记住 OrderStatus.PAID 的写法,还需要区分三类问题:

  1. 状态是否允许从一个值转移到另一个值
  2. 行为是否应该由枚举常量自己实现
  3. 枚举常量的 Java 名称是否等同于数据库、消息或 API 中的稳定标识

下面以 Java 25 LTS 的语言和标准库语义为基础,逐层说明这些问题。


1. Enum 的本质:受限实例集合,而不是整数别名

1.1 枚举声明定义了一个枚举类型和若干实例

enum OrderStatus {
    CREATED,
    PAID,
    SHIPPED,
    CANCELLED
}

这段声明定义了:

  • 一个名为 OrderStatus 的枚举类型;
  • 四个枚举常量:CREATEDPAIDSHIPPEDCANCELLED
  • 每个常量都是 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 状态、事件和迁移是三个不同概念

定义:

  • 状态:对象当前处于什么阶段,例如 CREATEDPAID
  • 事件:试图改变状态的动作,例如 PAYSHIP
  • 迁移:在某个当前状态下接收某个事件后得到的新状态。

可以形式化为:

δ:S×ES\delta: S \times E \rightharpoonup S

其中:

  • SS 是状态集合;
  • EE 是事件集合;
  • δ\delta 是状态转移函数;
  • 符号 \rightharpoonup 表示这是一个部分函数,并非所有状态和事件组合都有效。

例如:

δ(CREATED,PAY)=PAID\delta(CREATED, PAY) = PAID

但:

δ(SHIPPED,PAY)\delta(SHIPPED, PAY)

没有定义,因为已发货订单不能再次支付。

仅仅声明:

enum OrderStatus {
    CREATED, PAID, SHIPPED, CANCELLED
}

只定义了状态集合 SS,没有定义事件集合 EE,也没有定义迁移函数 δ\delta。因此,枚举本身不是完整的状态机。

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。它表达的是:

当前状态{合法事件下一状态}\text{当前状态} \rightarrow \{\text{合法事件} \mapsto \text{下一状态}\}

读取时可以把缺少的键视为非法迁移:

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

这里没有把折扣逻辑散落在调用方的 ifswitch 中。每个策略满足同一个抽象操作:

f:MoneyMoneyf: \text{Money} \rightarrow \text{Money}

但策略的集合是封闭的。增加新策略必须修改枚举源码并重新编译。

4.2 何时不应使用策略枚举

如果策略具有以下特征,普通接口和外部实现通常更合适:

  • 策略由插件动态加载;
  • 策略数量由部署配置决定;
  • 策略需要独立版本和独立发布;
  • 策略依赖复杂的运行时对象;
  • 策略逻辑很长,需要多个协作者。

枚举策略的优势是封闭集合、类型安全和调用简单;代价是扩展必须修改代码,且枚举实例不适合作为带复杂生命周期的服务对象。


5. EnumSet:面向枚举全集的集合运算

5.1 EnumSet 的数学含义

假设:

enum Permission {
    READ,
    WRITE,
    DELETE,
    AUDIT
}

全部枚举常量组成全集:

U={READ,WRITE,DELETE,AUDIT}U = \{READ, WRITE, DELETE, AUDIT\}

一个权限集合是 UU 的子集,例如:

A={READ,WRITE}A = \{READ, WRITE\}

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 rangecomplementOf 依赖枚举声明顺序

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]

这意味着如果将来新增枚举常量,allOfcomplementOf 和依赖全集的逻辑都会改变。对于权限系统,这种变化必须经过审查:新增权限可能被某个“排除少数项”的集合自动纳入。

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。可以使用 mergegetOrDefault,但这两种写法表达的语义不同:

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;
    }
}

解析流程是:

  1. 输入 code
  2. 拒绝 null
  3. 通过稳定代码查找;
  4. 找到则返回枚举实例;
  5. 找不到则明确报告未知值。

如果系统需要兼容旧值,可以把兼容逻辑写入映射边界:

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];

它依赖两个隐含条件:

  1. 枚举声明顺序永远不变;
  2. 数据库中的整数永远有效。

只要插入、删除或重排常量,旧整数就可能映射到另一个状态。删除常量还可能导致数组越界。

正确方式是显式定义稳定整数代码:

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);
    }
}

数据库中保存的是 102030,而不是 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() 序列化,而是按常量名称处理。反序列化时,运行时需要在目标枚举类型中找到同名常量。

因此:

  • 调整常量声明顺序通常不会改变原生序列化的枚举身份;
  • 重命名或删除常量会导致旧数据无法正常恢复;
  • 新增常量不会让旧程序自动理解该常量;
  • 枚举不能通过普通的 readObjectwriteObject 机制改变其特殊序列化语义。

这仍然不是一个适合作为长期跨服务协议的理由。原生序列化还绑定 Java 类名、类加载环境和 Java 类型结构。跨服务、跨语言或长期归档时,通常应使用明确版本化的外部格式,并自行定义未知值策略。


11. 外部数据的完整处理流程

下面给出一个不依赖第三方框架的端到端示例。它模拟:

  1. 从数据库或消息中读取字符串代码;
  2. 转成内部枚举;
  3. 执行状态迁移;
  4. 转回稳定代码;
  5. 对未知输入走错误路径。
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 的随机故障。

如果多个线程同时修改同一个 EnumSetEnumMap,可能出现:

  • 数据竞争;
  • 迭代期间结构变化;
  • 丢失更新;
  • 观察到不一致的中间状态。

诊断重点不是“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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。