Skip to content

Java 枚举与注解

枚举和注解是 Java 后端开发里非常常见的基础能力。枚举用来表达有限状态,注解用来给类、字段、方法添加元信息。Spring、MyBatis、JUnit、Jackson 等框架大量依赖注解,订单状态、支付状态、审批状态则大量适合使用枚举。

如果要深入理解注解从源码、编译期、class 文件到运行期反射的完整流程,@Retention@Target@Inherited@Repeatable 的原理,运行期注解和编译期注解处理器区别,以及 Spring 注解为什么能生效,建议继续看:Java注解全过程原理

为什么需要枚举

假设订单状态用数字表示:

java
int status = 1;

问题是:1 到底表示待支付、已支付,还是已发货?如果各处手写数字,会产生大量魔法值。

枚举可以把有限值命名:

java
public enum OrderStatus {
    CREATED,
    PAID,
    SHIPPED,
    FINISHED,
    CANCELED
}

这样代码表达更清楚,也能避免传入非法状态。

枚举的工作原理

枚举不是简单常量,本质上是一个特殊的类。每个枚举值都是这个类的固定对象。

mermaid
flowchart TD
    A["OrderStatus 枚举类"] --> B["CREATED 对象"]
    A --> C["PAID 对象"]
    A --> D["SHIPPED 对象"]
    A --> E["FINISHED 对象"]
    A --> F["CANCELED 对象"]

枚举对象在类加载时创建,并且数量固定,外部不能随便 new 一个新的枚举值。

编译后可以粗略理解成类似下面的类:

java
public final class OrderStatus extends Enum<OrderStatus> {
    public static final OrderStatus CREATED = new OrderStatus("CREATED", 0);
    public static final OrderStatus PAID = new OrderStatus("PAID", 1);

    private OrderStatus(String name, int ordinal) {
        super(name, ordinal);
    }
}

这解释了几个现象:

  1. 枚举构造方法不能是 public,因为外部不能创建新的枚举对象。
  2. 枚举天然适合用 == 比较,因为每个枚举值是固定单例对象。
  3. 每个枚举都有 name()ordinal(),因为它继承自 java.lang.Enum
  4. 枚举可以写字段、方法、抽象方法,本质上它是特殊类,不是简单字符串。

name、ordinal 和 code 怎么选

枚举有三个容易混淆的值:

名称来源是否建议落库原因
name()枚举常量名谨慎改枚举名会影响历史数据
ordinal()枚举声明顺序不建议新增、调整顺序会让老数据含义错乱
自定义 code业务字段推荐可以保持稳定,并和数据库/接口约定一致

错误示例:

java
public enum PayStatus {
    CREATED,
    PAID,
    REFUNDED
}

// 把 ordinal 存数据库:CREATED=0, PAID=1, REFUNDED=2

如果后面在中间插入一个状态:

java
public enum PayStatus {
    CREATED,
    PAYING,
    PAID,
    REFUNDED
}

原来数据库里的 1 从“已支付”变成“支付中”,这是严重数据事故。商业系统中落库、接口传输、MQ 消息都应该使用稳定 code

带字段和方法的枚举

业务系统中更常见的是带编码和描述的枚举。

java
public enum OrderStatus {
    CREATED(0, "待支付"),
    PAID(1, "已支付"),
    SHIPPED(2, "已发货"),
    FINISHED(3, "已完成"),
    CANCELED(4, "已取消");

    private final int code;
    private final String text;

    OrderStatus(int code, String text) {
        this.code = code;
        this.text = text;
    }

    public int getCode() {
        return code;
    }

    public String getText() {
        return text;
    }

    public static OrderStatus ofCode(int code) {
        for (OrderStatus status : values()) {
            if (status.code == code) {
                return status;
            }
        }
        throw new IllegalArgumentException("未知订单状态:" + code);
    }
}

使用:

java
public class OrderStatusDemo {
    public static void main(String[] args) {
        OrderStatus status = OrderStatus.ofCode(1);
        System.out.println(status.getText());
    }
}

这种写法比到处写 if (status == 1) 更容易维护。

枚举比较和空指针

枚举比较推荐用 ==

java
if (status == OrderStatus.PAID) {
    System.out.println("已支付");
}

如果 statusnull,这段代码结果是 false,不会空指针。下面这种写法反而可能空指针:

java
if (status.equals(OrderStatus.PAID)) {
    System.out.println("已支付");
}

也可以把常量写左边:

java
if (OrderStatus.PAID.equals(status)) {
    System.out.println("已支付");
}

但枚举最常见、最清晰的写法还是 status == OrderStatus.PAID

枚举实现策略模式

枚举不仅能表示状态,还能把每种状态对应的行为放进去,减少大量 if/else

java
import java.math.BigDecimal;

public enum DiscountType {
    NONE {
        @Override
        public BigDecimal calculate(BigDecimal amount) {
            return amount;
        }
    },
    VIP {
        @Override
        public BigDecimal calculate(BigDecimal amount) {
            return amount.multiply(new BigDecimal("0.90"));
        }
    },
    NEW_USER {
        @Override
        public BigDecimal calculate(BigDecimal amount) {
            return amount.subtract(new BigDecimal("10.00"));
        }
    };

    public abstract BigDecimal calculate(BigDecimal amount);
}

使用:

java
BigDecimal payAmount = DiscountType.VIP.calculate(new BigDecimal("100.00"));

适合场景:类型有限、每种类型逻辑不复杂,并且这些逻辑确实属于这个枚举语义。
不适合场景:每种策略依赖很多 Spring Bean、外部接口、数据库查询。这时更适合用策略接口 + Spring Bean Map。

EnumMap 和 EnumSet

当 key 是枚举时,可以优先考虑 EnumMap;当集合元素是枚举时,可以考虑 EnumSet

java
import java.util.EnumMap;
import java.util.Map;

public class EnumMapDemo {
    public static void main(String[] args) {
        Map<OrderStatus, String> actions = new EnumMap<>(OrderStatus.class);
        actions.put(OrderStatus.CREATED, "去支付");
        actions.put(OrderStatus.PAID, "等待发货");

        System.out.println(actions.get(OrderStatus.CREATED));
    }
}

EnumMap 内部可以用数组按枚举顺序保存值,比普通 HashMap 更轻量。EnumSet 内部可以用位图表示一组枚举值,适合权限、状态集合、支持渠道集合。

java
import java.util.EnumSet;

EnumSet<OrderStatus> canCancel = EnumSet.of(OrderStatus.CREATED, OrderStatus.PAID);

不要为了“高级”强行用它们,但在枚举 key/集合场景下,它们是更贴合语义的数据结构。

枚举适合哪些业务场景

场景示例
订单状态待支付、已支付、已取消
支付渠道支付宝、微信、银行卡
审批结果通过、驳回、待审批
用户类型普通用户、会员、管理员
消息类型短信、邮件、站内信

枚举适合“值的集合有限,并且业务含义稳定”的场景。

switch 配合枚举

java
public class OrderActionDemo {
    public static String nextAction(OrderStatus status) {
        switch (status) {
            case CREATED:
                return "去支付";
            case PAID:
                return "等待发货";
            case SHIPPED:
                return "确认收货";
            case FINISHED:
                return "查看订单";
            case CANCELED:
                return "重新购买";
            default:
                throw new IllegalArgumentException("不支持的状态");
        }
    }
}

如果后续新增枚举值,要检查所有 switch 逻辑是否需要补充分支。

商业 Demo:订单状态机

真实订单状态不是随便改的。比如不能从“待支付”直接变成“已完成”,不能从“已取消”变成“已发货”。枚举可以承载合法流转规则。

java
import java.util.EnumSet;
import java.util.Set;

public enum OrderStatus {
    CREATED("待支付"),
    PAID("已支付"),
    SHIPPED("已发货"),
    FINISHED("已完成"),
    CANCELED("已取消");

    private final String text;

    OrderStatus(String text) {
        this.text = text;
    }

    public boolean canTransferTo(OrderStatus target) {
        return nextStatuses().contains(target);
    }

    private Set<OrderStatus> nextStatuses() {
        switch (this) {
            case CREATED:
                return EnumSet.of(PAID, CANCELED);
            case PAID:
                return EnumSet.of(SHIPPED, CANCELED);
            case SHIPPED:
                return EnumSet.of(FINISHED);
            default:
                return EnumSet.noneOf(OrderStatus.class);
        }
    }
}

业务对象使用:

java
class Order {
    private OrderStatus status = OrderStatus.CREATED;

    public void transferTo(OrderStatus target) {
        if (!status.canTransferTo(target)) {
            throw new IllegalStateException("订单状态不能从 " + status + " 变为 " + target);
        }
        this.status = target;
    }
}

这比在多个 Service 方法里散落状态判断更容易维护。后续新增“退款中、已退款”时,也能更集中地检查状态流转规则。

为什么需要注解

注解是给代码添加元信息的方式。它本身不直接执行业务逻辑,而是告诉框架或工具:“这个类、字段、方法有什么特殊含义”。

例如:

java
@Deprecated
public void oldMethod() {
}

@Deprecated 表示这个方法不建议继续使用。编译器和 IDE 可以根据注解给出提醒。

注解的工作流程

mermaid
flowchart TD
    A["代码上写注解"] --> B["编译器处理"]
    B --> C["注解信息进入 class 或源码处理阶段"]
    C --> D["框架通过反射读取注解"]
    D --> E["根据注解执行规则"]

Spring 的 @Controller@Service@Autowired,JUnit 的 @Test,MyBatis 的 @Mapper,本质上都是注解加框架处理逻辑。

Retention 三种保留策略

@Retention 决定注解保留到哪个阶段。

策略保留阶段典型场景
SOURCE只在源码中,编译后丢弃@Override、Lombok 风格处理
CLASS进入 class 文件,运行期通常不能反射读取字节码工具、编译后增强
RUNTIME进入 class 文件,运行期可反射读取Spring、JUnit、参数校验

如果你写了一个运行期校验注解,却用了 SOURCECLASS,反射读取时会拿不到。

mermaid
flowchart TD
    A["源代码中的注解"] --> B{"Retention"}
    B -- "SOURCE" --> C["编译后丢弃"]
    B -- "CLASS" --> D["进入 class 文件"]
    B -- "RUNTIME" --> E["进入 class 文件并可反射读取"]
    E --> F["框架运行期读取并执行逻辑"]

Target 常见位置

@Target 限制注解能写在哪里。

ElementType含义
TYPE类、接口、枚举
FIELD字段
METHOD方法
PARAMETER方法参数
CONSTRUCTOR构造方法
ANNOTATION_TYPE注解上
TYPE_USE类型使用位置,例如泛型、类型转换

如果 @Target(ElementType.FIELD),就不能把它标在方法上。这样可以让编译器提前帮你阻止错误用法。

自定义注解

java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NotBlank {
    String message() default "不能为空";
}

关键元注解:

元注解作用
@Retention注解保留到哪个阶段
@Target注解能写在哪里
@Documented是否进入文档
@Inherited子类是否能继承类上的注解

RetentionPolicy.RUNTIME 很重要。只有运行期仍然保留的注解,才能被反射读取。

注解加反射 Demo:字段校验

java
public class CreateUserRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;

    public CreateUserRequest(String username) {
        this.username = username;
    }
}

校验器:

java
import java.lang.reflect.Field;

public class Validator {
    public static void validate(Object target) throws IllegalAccessException {
        Class<?> clazz = target.getClass();
        for (Field field : clazz.getDeclaredFields()) {
            NotBlank notBlank = field.getAnnotation(NotBlank.class);
            if (notBlank == null) {
                continue;
            }

            field.setAccessible(true);
            Object value = field.get(target);
            if (value == null || value.toString().trim().isEmpty()) {
                throw new IllegalArgumentException(notBlank.message());
            }
        }
    }
}

运行:

java
public class AnnotationDemo {
    public static void main(String[] args) throws Exception {
        CreateUserRequest request = new CreateUserRequest(" ");
        Validator.validate(request);
    }
}

这就是很多参数校验框架的基本思想:注解负责声明规则,反射负责读取规则,校验器负责执行规则。

注解处理器和运行期反射区别

注解生效大体有两条路线:

mermaid
flowchart TD
    A["代码上写注解"] --> B{"处理时机"}
    B -- "编译期" --> C["Annotation Processor"]
    C --> D["生成代码或编译检查"]
    B -- "运行期" --> E["反射或框架扫描"]
    E --> F["创建对象、校验、拦截、映射"]

对比:

对比项编译期注解处理器运行期反射
发生时机javac 编译阶段应用启动或运行阶段
典型工具Lombok、MapStructSpring、JUnit、Hibernate Validator
优点运行期成本低,可生成代码灵活,能结合运行期对象和配置
缺点调试生成代码较复杂反射有运行期开销

例子:

  1. @Override 是编译器检查,不需要运行期反射。
  2. @Service 是 Spring 启动时扫描 classpath,读取注解并注册 Bean。
  3. @NotBlank 是校验框架运行时读取字段或参数上的注解,再执行校验器。
  4. MapStruct 根据注解在编译期生成 Mapper 实现类。

所以“注解为什么能生效”的答案永远是:有对应处理器读取它并执行逻辑。

组合注解

Spring 里很多注解是组合注解。比如 @RestController 可以理解为组合了 @Controller@ResponseBody 的语义。

自定义组合注解示例:

java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdminOnly {
    String value() default "ADMIN";
}

权限拦截器或 AOP 可以读取 @AdminOnly,判断当前用户是否有管理员角色。注解本身只声明规则,真正拦截逻辑仍然要由 AOP、拦截器或过滤器执行。

商业 Demo:接口审计注解

在医疗数据采集平台中,关键接口通常要记录审计日志:谁在什么时候操作了哪个资源。可以用注解声明审计动作。

java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuditLog {
    String action();
}

业务方法:

java
public class AssetService {
    @AuditLog(action = "发布数据资产")
    public void publishAsset(Long assetId) {
        System.out.println("publish asset " + assetId);
    }
}

简化版处理器:

java
import java.lang.reflect.Method;

public class AuditRunner {
    public static void run(Object target, String methodName, Object... args) throws Exception {
        Method method = target.getClass().getMethod(methodName, Long.class);
        AuditLog auditLog = method.getAnnotation(AuditLog.class);
        if (auditLog != null) {
            System.out.println("审计动作:" + auditLog.action());
        }
        method.invoke(target, args);
    }
}

真实项目不会手写这种 runner,而是用 Spring AOP 或拦截器。但原理一样:读取注解,拿到元数据,执行增强逻辑。

注解不是逻辑本身

很多初学者会误以为“写了注解就会自动生效”。其实不会。

java
@NotBlank
private String username;

这行代码只是在字段上放了一个标记。如果没有框架或代码去读取它,它什么都不会发生。

mermaid
flowchart TD
    A["只有注解"] --> B["只是元信息"]
    C["注解加处理器"] --> D["产生实际行为"]

商业开发常见场景

场景枚举或注解的作用
订单状态用枚举表达状态,避免魔法值
支付渠道用枚举限制合法渠道
参数校验用注解声明校验规则
接口路由Spring MVC 用注解声明 URL 和方法
ORM 映射JPA、MyBatis 用注解描述表字段
单元测试JUnit 用注解识别测试方法

常见风险

问题后果建议
状态仍然到处写数字可读性差,容易传错使用枚举封装
枚举 code 被随意修改数据库老数据含义错乱code 一旦落库要谨慎变更
注解保留策略写错运行时读取不到需要反射读取时用 RUNTIME
只有注解没有处理器注解不生效明确谁来读取和执行
用 ordinal 落库新增枚举导致历史数据含义错乱使用稳定业务 code
枚举里塞太复杂逻辑枚举变成上帝类,难测试复杂策略用接口 + Spring Bean
运行期大量反射扫描启动或执行变慢缓存注解解析结果

线上排查流程

mermaid
flowchart TD
    A["枚举或注解问题"] --> B{"表现是什么"}
    B -- "状态含义错乱" --> C["检查是否用 ordinal 落库或改了 code"]
    B -- "新增状态不生效" --> D["检查 switch 和状态机是否漏分支"]
    B -- "注解没效果" --> E["检查 Retention、Target、扫描范围和处理器"]
    B -- "反射读取不到" --> F["检查是否 RUNTIME 保留和读取位置是否正确"]
    B -- "启动变慢" --> G["检查注解扫描范围和是否缓存解析结果"]

面试常问

枚举本质是什么?
枚举本质是继承 Enum 的特殊类,每个枚举值是固定的静态对象。枚举构造方法不能 public,外部不能 new 新枚举值,所以它适合表达有限、稳定的业务状态。

枚举为什么可以用 == 比较?
每个枚举值在 JVM 中是固定单例对象,== 比较引用即可判断是不是同一个枚举常量。相比 equals== 还能避免变量为 null 时空指针。

为什么不要用 ordinal 落库?
ordinal 依赖枚举声明顺序,一旦中间新增或调整顺序,历史数据对应的业务含义会错乱。落库和接口传输应使用稳定的自定义 code。

注解是什么?
注解是 Java 的元数据机制,用来给类、字段、方法、参数等元素添加额外信息。注解本身不执行业务逻辑,真正产生行为的是编译器、注解处理器、反射、框架扫描、AOP 或拦截器。

为什么写了注解不生效?
因为注解只是标记。要生效必须有处理器读取它并执行逻辑,还要确认 @Retention 是否保留到需要的阶段,@Target 是否标在正确位置,框架扫描范围是否包含该类。

本章小结

枚举用来表达有限且稳定的业务值,注解用来给代码添加元信息。枚举解决魔法值问题,注解解决框架识别和规则声明问题。真正让注解生效的不是注解本身,而是编译器、框架、反射或注解处理器。