Java 枚举与注解
枚举和注解是 Java 后端开发里非常常见的基础能力。枚举用来表达有限状态,注解用来给类、字段、方法添加元信息。Spring、MyBatis、JUnit、Jackson 等框架大量依赖注解,订单状态、支付状态、审批状态则大量适合使用枚举。
如果要深入理解注解从源码、编译期、class 文件到运行期反射的完整流程,@Retention、@Target、@Inherited、@Repeatable 的原理,运行期注解和编译期注解处理器区别,以及 Spring 注解为什么能生效,建议继续看:Java注解全过程原理。
为什么需要枚举
假设订单状态用数字表示:
int status = 1;问题是:1 到底表示待支付、已支付,还是已发货?如果各处手写数字,会产生大量魔法值。
枚举可以把有限值命名:
public enum OrderStatus {
CREATED,
PAID,
SHIPPED,
FINISHED,
CANCELED
}这样代码表达更清楚,也能避免传入非法状态。
枚举的工作原理
枚举不是简单常量,本质上是一个特殊的类。每个枚举值都是这个类的固定对象。
flowchart TD
A["OrderStatus 枚举类"] --> B["CREATED 对象"]
A --> C["PAID 对象"]
A --> D["SHIPPED 对象"]
A --> E["FINISHED 对象"]
A --> F["CANCELED 对象"]枚举对象在类加载时创建,并且数量固定,外部不能随便 new 一个新的枚举值。
编译后可以粗略理解成类似下面的类:
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);
}
}这解释了几个现象:
- 枚举构造方法不能是
public,因为外部不能创建新的枚举对象。 - 枚举天然适合用
==比较,因为每个枚举值是固定单例对象。 - 每个枚举都有
name()和ordinal(),因为它继承自java.lang.Enum。 - 枚举可以写字段、方法、抽象方法,本质上它是特殊类,不是简单字符串。
name、ordinal 和 code 怎么选
枚举有三个容易混淆的值:
| 名称 | 来源 | 是否建议落库 | 原因 |
|---|---|---|---|
name() | 枚举常量名 | 谨慎 | 改枚举名会影响历史数据 |
ordinal() | 枚举声明顺序 | 不建议 | 新增、调整顺序会让老数据含义错乱 |
自定义 code | 业务字段 | 推荐 | 可以保持稳定,并和数据库/接口约定一致 |
错误示例:
public enum PayStatus {
CREATED,
PAID,
REFUNDED
}
// 把 ordinal 存数据库:CREATED=0, PAID=1, REFUNDED=2如果后面在中间插入一个状态:
public enum PayStatus {
CREATED,
PAYING,
PAID,
REFUNDED
}原来数据库里的 1 从“已支付”变成“支付中”,这是严重数据事故。商业系统中落库、接口传输、MQ 消息都应该使用稳定 code。
带字段和方法的枚举
业务系统中更常见的是带编码和描述的枚举。
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);
}
}使用:
public class OrderStatusDemo {
public static void main(String[] args) {
OrderStatus status = OrderStatus.ofCode(1);
System.out.println(status.getText());
}
}这种写法比到处写 if (status == 1) 更容易维护。
枚举比较和空指针
枚举比较推荐用 ==:
if (status == OrderStatus.PAID) {
System.out.println("已支付");
}如果 status 为 null,这段代码结果是 false,不会空指针。下面这种写法反而可能空指针:
if (status.equals(OrderStatus.PAID)) {
System.out.println("已支付");
}也可以把常量写左边:
if (OrderStatus.PAID.equals(status)) {
System.out.println("已支付");
}但枚举最常见、最清晰的写法还是 status == OrderStatus.PAID。
枚举实现策略模式
枚举不仅能表示状态,还能把每种状态对应的行为放进去,减少大量 if/else。
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);
}使用:
BigDecimal payAmount = DiscountType.VIP.calculate(new BigDecimal("100.00"));适合场景:类型有限、每种类型逻辑不复杂,并且这些逻辑确实属于这个枚举语义。
不适合场景:每种策略依赖很多 Spring Bean、外部接口、数据库查询。这时更适合用策略接口 + Spring Bean Map。
EnumMap 和 EnumSet
当 key 是枚举时,可以优先考虑 EnumMap;当集合元素是枚举时,可以考虑 EnumSet。
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 内部可以用位图表示一组枚举值,适合权限、状态集合、支持渠道集合。
import java.util.EnumSet;
EnumSet<OrderStatus> canCancel = EnumSet.of(OrderStatus.CREATED, OrderStatus.PAID);不要为了“高级”强行用它们,但在枚举 key/集合场景下,它们是更贴合语义的数据结构。
枚举适合哪些业务场景
| 场景 | 示例 |
|---|---|
| 订单状态 | 待支付、已支付、已取消 |
| 支付渠道 | 支付宝、微信、银行卡 |
| 审批结果 | 通过、驳回、待审批 |
| 用户类型 | 普通用户、会员、管理员 |
| 消息类型 | 短信、邮件、站内信 |
枚举适合“值的集合有限,并且业务含义稳定”的场景。
switch 配合枚举
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:订单状态机
真实订单状态不是随便改的。比如不能从“待支付”直接变成“已完成”,不能从“已取消”变成“已发货”。枚举可以承载合法流转规则。
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);
}
}
}业务对象使用:
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 方法里散落状态判断更容易维护。后续新增“退款中、已退款”时,也能更集中地检查状态流转规则。
为什么需要注解
注解是给代码添加元信息的方式。它本身不直接执行业务逻辑,而是告诉框架或工具:“这个类、字段、方法有什么特殊含义”。
例如:
@Deprecated
public void oldMethod() {
}@Deprecated 表示这个方法不建议继续使用。编译器和 IDE 可以根据注解给出提醒。
注解的工作流程
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、参数校验 |
如果你写了一个运行期校验注解,却用了 SOURCE 或 CLASS,反射读取时会拿不到。
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),就不能把它标在方法上。这样可以让编译器提前帮你阻止错误用法。
自定义注解
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:字段校验
public class CreateUserRequest {
@NotBlank(message = "用户名不能为空")
private String username;
public CreateUserRequest(String username) {
this.username = username;
}
}校验器:
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());
}
}
}
}运行:
public class AnnotationDemo {
public static void main(String[] args) throws Exception {
CreateUserRequest request = new CreateUserRequest(" ");
Validator.validate(request);
}
}这就是很多参数校验框架的基本思想:注解负责声明规则,反射负责读取规则,校验器负责执行规则。
注解处理器和运行期反射区别
注解生效大体有两条路线:
flowchart TD
A["代码上写注解"] --> B{"处理时机"}
B -- "编译期" --> C["Annotation Processor"]
C --> D["生成代码或编译检查"]
B -- "运行期" --> E["反射或框架扫描"]
E --> F["创建对象、校验、拦截、映射"]对比:
| 对比项 | 编译期注解处理器 | 运行期反射 |
|---|---|---|
| 发生时机 | javac 编译阶段 | 应用启动或运行阶段 |
| 典型工具 | Lombok、MapStruct | Spring、JUnit、Hibernate Validator |
| 优点 | 运行期成本低,可生成代码 | 灵活,能结合运行期对象和配置 |
| 缺点 | 调试生成代码较复杂 | 反射有运行期开销 |
例子:
@Override是编译器检查,不需要运行期反射。@Service是 Spring 启动时扫描 classpath,读取注解并注册 Bean。@NotBlank是校验框架运行时读取字段或参数上的注解,再执行校验器。- MapStruct 根据注解在编译期生成 Mapper 实现类。
所以“注解为什么能生效”的答案永远是:有对应处理器读取它并执行逻辑。
组合注解
Spring 里很多注解是组合注解。比如 @RestController 可以理解为组合了 @Controller 和 @ResponseBody 的语义。
自定义组合注解示例:
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:接口审计注解
在医疗数据采集平台中,关键接口通常要记录审计日志:谁在什么时候操作了哪个资源。可以用注解声明审计动作。
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();
}业务方法:
public class AssetService {
@AuditLog(action = "发布数据资产")
public void publishAsset(Long assetId) {
System.out.println("publish asset " + assetId);
}
}简化版处理器:
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 或拦截器。但原理一样:读取注解,拿到元数据,执行增强逻辑。
注解不是逻辑本身
很多初学者会误以为“写了注解就会自动生效”。其实不会。
@NotBlank
private String username;这行代码只是在字段上放了一个标记。如果没有框架或代码去读取它,它什么都不会发生。
flowchart TD
A["只有注解"] --> B["只是元信息"]
C["注解加处理器"] --> D["产生实际行为"]商业开发常见场景
| 场景 | 枚举或注解的作用 |
|---|---|
| 订单状态 | 用枚举表达状态,避免魔法值 |
| 支付渠道 | 用枚举限制合法渠道 |
| 参数校验 | 用注解声明校验规则 |
| 接口路由 | Spring MVC 用注解声明 URL 和方法 |
| ORM 映射 | JPA、MyBatis 用注解描述表字段 |
| 单元测试 | JUnit 用注解识别测试方法 |
常见风险
| 问题 | 后果 | 建议 |
|---|---|---|
| 状态仍然到处写数字 | 可读性差,容易传错 | 使用枚举封装 |
| 枚举 code 被随意修改 | 数据库老数据含义错乱 | code 一旦落库要谨慎变更 |
| 注解保留策略写错 | 运行时读取不到 | 需要反射读取时用 RUNTIME |
| 只有注解没有处理器 | 注解不生效 | 明确谁来读取和执行 |
| 用 ordinal 落库 | 新增枚举导致历史数据含义错乱 | 使用稳定业务 code |
| 枚举里塞太复杂逻辑 | 枚举变成上帝类,难测试 | 复杂策略用接口 + Spring Bean |
| 运行期大量反射扫描 | 启动或执行变慢 | 缓存注解解析结果 |
线上排查流程
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 是否标在正确位置,框架扫描范围是否包含该类。
本章小结
枚举用来表达有限且稳定的业务值,注解用来给代码添加元信息。枚举解决魔法值问题,注解解决框架识别和规则声明问题。真正让注解生效的不是注解本身,而是编译器、框架、反射或注解处理器。
