Java 枚举完整总结:从常量列表到策略模式与状态机
从最简单的 SUCCESS、FAIL 枚举开始,理解字段、构造器、抽象方法、接口、单例、策略模式和状态机,并掌握 Spring Boot 参数校验与枚举选型。
Java 枚举完整总结:从常量列表到策略模式与状态机
刚接触 Java 枚举时,我通常只把它理解成一组固定字符串:SUCCESS、FAIL、PENDING。后来在 Spring Boot 项目里,我发现枚举并不只是“把几个常量放在一起”,它还可以携带字段、封装行为、实现接口,甚至表达一个完整的状态流转规则。
这篇文章按照从简单到复杂的顺序,回答几个实际问题:枚举到底解决了什么问题?什么时候需要给枚举加字段和方法?枚举什么时候会变成策略模式或状态机?在接口参数中又该怎样安全地使用它?
一、为什么不用普通的 int 或 String?
先看一个没有枚举的写法:
int status = 1;
String paymentType = "alipay";
这段代码的问题是,变量可以被赋予任何值。status = 999 可能根本不存在,paymentType = "alipayy" 也可能因为拼写错误而在运行时才暴露问题。
枚举把允许出现的值限制在一个类型内部:
public enum PublishStatus {
SUCCESS,
FAIL,
PENDING,
TIMEOUT
}
使用时,变量只能接收 PublishStatus 中已经定义的实例:
PublishStatus status = PublishStatus.SUCCESS;
if (status == PublishStatus.SUCCESS) {
System.out.println("发布成功!");
}
没有枚举时,状态值的合法范围要靠注释、文档或运行时判断维护;有了枚举,编译器可以帮助我们限制类型,IDE 也能自动提示所有可选值。
枚举常量是对象实例,所以比较枚举时推荐使用 ==。同一个枚举常量在整个 JVM 中只有一个实例,== 既直观又不会出现普通对象比较时的空指针问题。
二、最基本的枚举操作
1. 获取名称和位置
PublishStatus status = PublishStatus.SUCCESS;
System.out.println(status); // SUCCESS
System.out.println(status.name()); // SUCCESS
System.out.println(status.ordinal()); // 0
name() 返回枚举常量声明时的名称,ordinal() 返回它在声明列表中的位置,从 0 开始。
这里有一个重要边界:ordinal() 只适合临时展示或内部判断,不适合保存到数据库、传给前端或作为业务编码。以后如果调整枚举常量顺序,序号就会变化,历史数据会被错误解释。需要稳定编码时,应该显式定义 code 字段。
2. 遍历所有枚举值
for (PublishStatus status : PublishStatus.values()) {
System.out.println(status);
}
values() 返回当前枚举声明的全部常量,常用于下拉选项、初始化映射表或测试所有状态。
3. 根据字符串获取枚举
PublishStatus status = PublishStatus.valueOf("SUCCESS");
valueOf() 要求字符串与枚举名称完全匹配。如果字符串不存在,会抛出 IllegalArgumentException;如果传入 null,会抛出 NullPointerException。
所以,valueOf() 适合处理已经经过约束的内部值。对于用户输入或 HTTP 参数,不能直接把异常当作正常业务流程,应该统一转换、校验并返回清晰的参数错误。
三、什么时候需要 switch?
如果每个状态只是对应一段简单提示,switch 很直观:
public String getStatusMessage(PublishStatus status) {
return switch (status) {
case SUCCESS -> "发布成功!";
case FAIL -> "发布失败,请重试";
case PENDING -> "发布中,请稍候";
case TIMEOUT -> "发布超时";
};
}
这里使用的是现代 Java 的 switch 表达式。因为枚举值已经覆盖完整,所以不需要写 default。不写 default 有一个好处:以后新增枚举常量时,编译器会提醒我们检查这个方法是否需要补充新分支。
如果只是做一次简单分支判断,switch 足够;如果每个枚举值拥有一组稳定且复杂的行为,就可以考虑把行为放回枚举内部。
四、枚举携带字段:让状态码和描述属于状态本身
如果把状态码、提示语和颜色散落在多个 switch 中,后续很容易出现状态名称和状态信息不一致的问题。可以让每个枚举常量在定义时携带自己的数据:
public enum PublishStatus {
SUCCESS(200, "发布成功", "green"),
FAIL(500, "发布失败", "red"),
PENDING(102, "处理中", "yellow"),
TIMEOUT(408, "请求超时", "orange");
private final int code;
private final String message;
private final String color;
PublishStatus(int code, String message, String color) {
this.code = code;
this.message = message;
this.color = color;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public String getColor() {
return color;
}
}
枚举构造器默认就是 private,外部不能随意创建新的 PublishStatus。这保证了状态实例只能来自枚举声明,同时保证字段可以设计成不可变的 final。
根据 code 反向查找
业务系统经常需要把数据库里的状态码转换成枚举:
public static PublishStatus fromCode(int code) {
for (PublishStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("未知状态码: " + code);
}
这个方法适合枚举值很少的场景。如果枚举值很多、调用频率很高,可以提前建立 Map<Integer, PublishStatus>,把每次线性遍历变成近似 O(1) 的查找。但缓存不是枚举的默认必需品,只有在确实存在性能或调用频率问题时才需要引入。
如果未知 code 在业务上是允许的,也可以返回 Optional<PublishStatus> 或返回 null 后由上层处理,而不是所有场景都强制抛异常。选择哪种方式,取决于非法数据是程序错误还是正常的外部输入。
五、让每个枚举常量拥有不同行为
字段解决的是“每个状态有什么数据”,抽象方法解决的是“每个状态怎么做事”。例如四则运算:
public enum Operation {
ADD {
@Override
public int apply(int a, int b) {
return a + b;
}
},
SUBTRACT {
@Override
public int apply(int a, int b) {
return a - b;
}
},
MULTIPLY {
@Override
public int apply(int a, int b) {
return a * b;
}
},
DIVIDE {
@Override
public int apply(int a, int b) {
if (b == 0) {
throw new ArithmeticException("除数不能为 0");
}
return a / b;
}
};
public abstract int apply(int a, int b);
}
调用方只需要关心统一的 apply() 方法:
int result = Operation.ADD.apply(10, 3); // 13
这样做的价值是把“判断当前是哪种操作”和“执行操作”放到同一个地方。调用方不需要再写一长串 if-else 或 switch。
但也要注意边界:如果每个行为都依赖大量外部服务、数据库或配置,枚举就不再适合作为承载容器。此时应该使用普通的策略类和 Spring 依赖注入。
六、枚举实现接口:面向抽象使用
枚举可以实现接口,因此调用方可以依赖接口,而不是依赖某个具体枚举:
public interface Describable {
String getDescription();
String getDisplayName();
}
public enum PublishStatus implements Describable {
SUCCESS(200, "成功") {
@Override
public String getDescription() {
return "操作成功完成";
}
@Override
public String getDisplayName() {
return "成功";
}
},
FAIL(500, "失败") {
@Override
public String getDescription() {
return "操作失败,请检查日志";
}
@Override
public String getDisplayName() {
return "失败";
}
};
private final int code;
private final String message;
PublishStatus(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
使用接口引用时,调用方只看到接口约定:
Describable describable = PublishStatus.FAIL;
System.out.println(describable.getDisplayName());
这就是多态。枚举负责提供固定实现,业务代码依赖接口负责提供的能力。至于 getDisplayName() 是否要返回图标,建议交给前端或展示层处理,领域枚举本身最好只表达稳定的业务信息。
七、为什么枚举可以实现单例?
单例要求一个类在整个程序中只有一个实例。手写单例经常需要处理私有构造器、懒加载、反射和序列化等问题,而枚举天然只有固定的枚举实例:
public enum Singleton {
INSTANCE;
private String data;
public void setData(String data) {
this.data = data;
}
public String getData() {
return data;
}
}
使用时:
Singleton instance1 = Singleton.INSTANCE;
Singleton instance2 = Singleton.INSTANCE;
System.out.println(instance1 == instance2); // true
枚举单例由 JVM 保证实例创建和序列化语义,通常比手写双重检查锁更简单可靠。
不过,“线程安全地只有一个实例”不等于“内部业务状态天然线程安全”。上面的 data 是共享可变字段,多线程同时读写时仍然需要同步、不可变对象或并发容器。实际项目中,Spring Bean 通常已经提供单例生命周期管理;是否使用枚举单例,要结合依赖注入、测试和配置需求决定。
八、枚举作为策略模式
支付方式的共同点是都有 pay() 行为,但具体实现不同:
public enum PaymentStrategy {
ALIPAY {
@Override
public void pay(BigDecimal amount) {
System.out.println("使用支付宝支付 " + amount + " 元");
}
},
WECHAT {
@Override
public void pay(BigDecimal amount) {
System.out.println("使用微信支付 " + amount + " 元");
}
},
CREDIT_CARD {
@Override
public void pay(BigDecimal amount) {
System.out.println("使用信用卡支付 " + amount + " 元");
}
};
public abstract void pay(BigDecimal amount);
}
调用方只需要选择策略并调用统一方法:
PaymentStrategy strategy = PaymentStrategy.valueOf("ALIPAY");
strategy.pay(new BigDecimal("99.99"));
示例中的金额使用 BigDecimal,而不是 double。double 是二进制浮点数,不能精确表示很多十进制小数,金额计算应使用 BigDecimal 或最小货币单位的整数。
如果支付策略要注入支付宝客户端、微信客户端、重试组件或配置对象,就不建议把真实 API 调用直接写进枚举。枚举适合表达固定、轻量、无外部依赖的策略;复杂策略更适合定义为 Spring Bean,再用 Map 或工厂统一选择。
九、枚举表达状态机
订单状态机的重点不是“有哪些状态”,而是“当前状态允许转移到哪里、允许执行什么操作”:
public enum OrderStatus {
CREATED {
@Override
public OrderStatus next() {
return PAID;
}
@Override
public boolean canCancel() {
return true;
}
},
PAID {
@Override
public OrderStatus next() {
return SHIPPED;
}
@Override
public boolean canCancel() {
return false;
}
},
SHIPPED {
@Override
public OrderStatus next() {
return DELIVERED;
}
@Override
public boolean canCancel() {
return false;
}
},
DELIVERED {
@Override
public OrderStatus next() {
return COMPLETED;
}
@Override
public boolean canCancel() {
return false;
}
},
COMPLETED {
@Override
public OrderStatus next() {
return this;
}
@Override
public boolean canCancel() {
return false;
}
};
public abstract OrderStatus next();
public abstract boolean canCancel();
public boolean isFinal() {
return this == COMPLETED;
}
}
这里把 COMPLETED 定义为唯一终态。CREATED 虽然可以取消,但它仍然可以继续流转,所以不能因为“可以取消”就把它当成终态。终态表示不会再进入下一个正常状态,这是状态机中的概念;可取消是另一个独立能力。
如果状态流转还涉及库存、支付、消息和数据库事务,就不能只依赖枚举的 next()。状态变更必须由领域服务完成校验、持久化和并发控制,避免两个请求同时推进同一个订单。
十、Spring Boot 中如何校验枚举参数?
前端传来的通常是字符串,而不是 Java 枚举对象。例如请求体可能是:
{
"status": "SUCCESS"
}
可以直接让 DTO 使用枚举类型:
public class PublishRequest {
private PublishStatus status;
}
Jackson 会尝试把字符串转换为 PublishStatus。转换失败时,Spring Boot 会返回参数绑定错误。对于需要自定义大小写、错误消息或兼容旧编码的场景,可以使用自定义反序列化器或在服务层调用安全的解析方法。
如果 DTO 暂时必须接收 String,可以使用 Bean Validation 自定义约束:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface ValidEnum {
Class<? extends Enum<?>> value();
String message() default "无效的枚举值";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class EnumValidator
implements ConstraintValidator<ValidEnum, String> {
private Class<? extends Enum<?>> enumClass;
@Override
public void initialize(ValidEnum annotation) {
this.enumClass = annotation.value();
}
@Override
public boolean isValid(String value,
ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true;
}
for (Enum<?> item : enumClass.getEnumConstants()) {
if (item.name().equals(value)) {
return true;
}
}
return false;
}
}
null 是否允许应该交给 @NotNull 决定。验证器返回 true 表示“这个约束不负责处理空值”,这样可以把“值不存在”和“值存在但不合法”分开处理。
十一、枚举查找与缓存怎么选?
最简单的 fromCode() 每次遍历 values(),代码清楚,枚举值数量通常也很小。不要一看到循环就马上加缓存,先确认它是否真的出现在性能热点中。
如果确实需要高频按 code 查找,可以建立只读映射:
private static final Map<Integer, PublishStatus> BY_CODE =
Arrays.stream(values())
.collect(Collectors.toUnmodifiableMap(
PublishStatus::getCode,
Function.identity()));
public static PublishStatus fromCode(int code) {
return BY_CODE.get(code);
}
初始化时如果发现两个枚举常量使用了同一个 code,toUnmodifiableMap() 会直接失败,这反而能尽早暴露配置错误。缓存的关键不是“用了 ConcurrentHashMap 就一定更好”,而是初始化后保持只读,避免运行过程中被意外修改。
十二、枚举与常量类如何选择?
| 需求 | 更适合的选择 |
|---|---|
| 值的范围固定,调用方只能使用合法值 | 枚举 |
| 每个值有名称、编码和描述 | 带字段的枚举 |
| 每个值有少量固定行为 | 带抽象方法的枚举 |
| 行为依赖外部服务、配置或复杂生命周期 | 普通策略类 + Spring Bean |
| 需要跨语言共享协议编码 | 显式 code,必要时配合文档或 Schema |
| 只是数学常量、配置常量或无类型数据 | public static final 常量 |
枚举不是“所有固定值的万能容器”。它最适合描述一组有限、稳定、具有明确类型语义的对象。动态配置、数据库可编辑字典、需要热更新的选项,不应该硬编码成枚举。
十三、最后怎么记?
可以用四句话记住枚举的使用边界:
- 只需要固定选项,就定义基础枚举。
- 需要稳定业务编码,就加字段,不要依赖
ordinal()。 - 不同枚举值有固定小行为,可以用抽象方法或接口封装。
- 行为复杂、依赖外部组件或需要动态配置时,把策略交给普通类和 Spring 管理。
枚举的核心价值不是语法更短,而是把“允许出现哪些值、每个值携带什么信息、每个值允许做什么”集中在一个类型里。这样,代码的约束从注释和约定变成了编译器可以检查、业务代码可以复用的结构。