跳到正文
Joeplover
学习笔记·2026-07-30·约 12 分钟阅读

Java 枚举完整总结:从常量列表到策略模式与状态机

从最简单的 SUCCESS、FAIL 枚举开始,理解字段、构造器、抽象方法、接口、单例、策略模式和状态机,并掌握 Spring Boot 参数校验与枚举选型。

代码编辑器中的 Java 枚举学习内容

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 常量

枚举不是“所有固定值的万能容器”。它最适合描述一组有限、稳定、具有明确类型语义的对象。动态配置、数据库可编辑字典、需要热更新的选项,不应该硬编码成枚举。

十三、最后怎么记?

可以用四句话记住枚举的使用边界:

  1. 只需要固定选项,就定义基础枚举。
  2. 需要稳定业务编码,就加字段,不要依赖 ordinal()。
  3. 不同枚举值有固定小行为,可以用抽象方法或接口封装。
  4. 行为复杂、依赖外部组件或需要动态配置时,把策略交给普通类和 Spring 管理。

枚举的核心价值不是语法更短,而是把“允许出现哪些值、每个值携带什么信息、每个值允许做什么”集中在一个类型里。这样,代码的约束从注释和约定变成了编译器可以检查、业务代码可以复用的结构。