☰
Java自定义注解从入门到实战:元注解、反射解析与常见坑
2026/10/3 9:57:30 网站建设 项目流程

上一篇我们聊了注解的基本概念,认识了@Override、@Deprecated这些内置注解。不少朋友在评论区问:光会用别人定义好的注解不够过瘾,怎么才能自己定义一个注解?这篇就是来解决这个问题的。我会从元注解的使用讲起,再一步步拆解自定义注解的语法细节,最后用一个完整的反射解析示例,把注解从"写出来"变成"用起来"。

整篇文章围绕"Java 元注解""自定义注解"两个核心展开,适合已经掌握 Java 基础语法、想深入理解注解机制的朋友。无论你是准备面试,还是想在项目里用注解干掉大量重复代码,这篇都能给你一个可以直接照搬的思路。

1. 元注解初印象:注解之上的注解

元注解听着玄乎,说白了就是"用在注解上的注解"。它自己不直接作用在业务代码上,而是用来声明白定义注解的行为特征。

Java 提供了六个元注解:@Retention、@Target、@Documented、@Inherited、@Repeatable。前两个是自定义注解时必须关心的,后面三个属于"按需使用"。我一个个拆开讲。

1.1 @Retention:决定注解的"保质期"

@Retention控制注解能存活到哪个阶段,这直接决定你有没有办法在运行时读到它。它有且只有一个 value 属性,取值来自RetentionPolicy枚举:

策略源码阶段编译后字节码运行时反射典型例子
SOURCE存在不存在不可读@Override、@SuppressWarnings
CLASS存在存在不可读默认策略,少用
RUNTIME存在存在可读自定义注解大多选它

SOURCE的注解在编译期干完活就被丢弃了。比如@Override,它的作用只是让编译器帮你检查方法签名是否正确,字节码里根本没有它的影子,运行期更不用说了。

CLASS是最容易产生误解的一个。注解会保留在字节码文件里,但 JVM 类加载之后并不会在内存中维护这些信息,所以运行时通过反射拿不到。

RUNTIME才是自定义注解的主战场。只要把注解标成@Retention(RetentionPolicy.RUNTIME),JVM 就会在运行期保留它,我们可以通过反射 API 读取到注解实例及其属性。

我习惯用一个生活化类比帮新同事理解:SOURCE像快递上的地址单,签收完就丢;CLASS像压在抽屉底层的保修卡,你知道它存在但平时想不起来看;RUNTIME则像手机背面的入网标签,任何时候想查都能揭开来看到。

一个很关键的经验:如果你自己写注解,但忘了写@Retention,它默认是CLASS。于是你在代码里注释标得漂漂亮亮,反射代码却怎么都拿不到null,这种问题排查起来特别费劲。

提示:自定义注解如果后续要结合反射或 AOP 读取,务必显式加上@Retention(RetentionPolicy.RUNTIME),别依赖默认值。

1.2 @Target:决定注解的"适用范围"

@Target用来声明注解可以贴在什么地方。它接收ElementType枚举数组,常用的取值如下:

  • TYPE:类、接口、枚举、注解类型上
  • FIELD:字段上
  • METHOD:方法上
  • PARAMETER:方法参数上
  • CONSTRUCTOR:构造器上
  • LOCAL_VARIABLE:局部变量上
  • ANNOTATION_TYPE:注解类型上,表示该注解可以修饰其他注解
  • PACKAGE:包上
  • TYPE_PARAMETER:类型参数上,比如泛型<T>
  • TYPE_USE:任何使用类型的地方,包括泛型参数、类型转换等

TYPE_PARAMETER和TYPE_USE是 Java 8 才加的,平时用得不算多。但TYPE_USE有个很有意思的场景,比如List<@NotNull String> list,意思就是这个 List 里的元素不允许为 null,这个约束可以通过注解处理器或者额外框架来检查。

日常自定义注解,最常用的组合就是@Target({ElementType.METHOD, ElementType.TYPE})表示既能标方法也能标类。

如果@Target不写呢?默认情况下这个注解可以应用在所有非类型声明的位置上,实际是啥都能标。但这并不是好事。我见过有人定义了一个@ApiLog注解,想标方法结果被队友标到了字段上,反射解析时一点反应都没有。所以自定义注解最好把目标范围收紧,语义明确,别人用起来也不会跑偏。

这里还有个容易忽略的细节:Java 8 之前,同一个位置同一个注解只能出现一次。如果你在方法上标了两个同名的自定义注解,编译直接报错。@Repeatable就是为解决这个问题而生的。

1.3 剩下的三个元注解:@Documented、@Inherited、@Repeatable

@Documented的作用比较简单,它只影响 Javadoc 生成。如果一个注解标了@Documented,那 Javadoc 工具生成文档时,就会把带注解的声明上标注的内容展示出来。比如标准库里的@Deprecated就标了@Deprecated和@Documented,所以文档里能看到弃用提示。

@Inherited表示注解的继承性。注意这里只对类上的注解有效,方法上的注解、字段上的注解都不具备继承性。比如我定义一个带@Inherited的@MyAnnotation注解,标在父类上,子类即使没标也能通过反射getAnnotation读到它。接口的方法实现、类的方法重写都不受@Inherited影响。

@Repeatable是个实用性很强的元注解。它允许在同一个地方重复标注同一个注解。比如定时任务可能有多个调度规则,如果一个注解只能写一次,那就得拆成两个字段来表示,很别扭。

用@Repeatable需要配套一个"容器注解":

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Repeatable(JobSchedules.class) public @interface JobSchedule { String cron(); } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface JobSchedules { JobSchedule[] value(); }

容器注解里必须有一个名为 value、类型为原注解数组的属性。使用的时候可以连续写:

@JobSchedule(cron = "0 0 6 * * ?") @JobSchedule(cron = "0 0 18 * * ?") public void pushDailyReport() { }

反射读取时可以通过getAnnotationsByType(JobSchedule.class)一次性拿到全部重复注解,也可以先拿容器注解再取数组。JDK 8 之后getAnnotationsByType是最推荐的方式。

2. 自定义注解实操:照着这个模板写就行

了解了元注解,现在动手定义一个真正可用的注解。以一个最典型的业务场景举例:记录操作日志。

2.1 注解定义语法拆解

注解的定义用@interface关键字,后面跟着注解名。它看起来像接口,但和普通接口有本质区别:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface LogRecord { String module() default ""; String action() default ""; String level() default "INFO"; boolean saveResult() default true; }

这段代码里要注意几个细节。

属性声明方式是"方法名 + 括号",括号里不能有任何参数,后面跟着类型和默认值。这和我们熟悉的抽象方法很像,但语义完全不同:这里是在定义注解的成员属性。

default关键字指定默认值。如果一个属性没有默认值,使用注解时就必须显式赋值,否则编译器会报错。反过来,如果所有属性都有默认值,那用注解时可以不写任何参数。

有一个属性名比较特殊:value。如果注解只有一个名为value的属性,使用时可以省略属性名直接写值:

public @interface SimpleAnnotation { String value(); } // 等价于 @SimpleAnnotation("hello") @SimpleAnnotation("hello") public void test() {}

但如果注解里有多个属性,只有 value 能省略名字,其他属性必须用key = value的形式。这个细节在阅读开源框架源码时非常常见,不理解会看着一头雾水。

2.2 注解属性类型的限制

注解属性的类型有严格限制,不是想写什么就写什么。官方规范允许的类型是:

  • Java 八种基本类型:byte、short、int、long、float、double、boolean、char
  • String
  • Class 或 Class<?>
  • 枚举类型
  • 注解类型
  • 以上类型的数组

这个限制很底层,javac 在编译期就直接拒绝。比如你想用Integer包装类型作为属性类型,编译会报错。刚接触时容易踩这个坑,下意识以为包装类型兼容,其实完全不行。我们项目中尽量用String、int、boolean这些最简单可靠的类型,除非有特别强的需求。

数组类型属性也有一个细节:如果给数组赋一个元素,可以省略花括号:

@LogRecord(level = "ERROR") // 如果 level 是 String[] 类型,等价于 level = {"ERROR"}

再说 Class 类型属性。它允许你指定某个类的 Class 对象作为元数据,比如很多框架喜欢用Class<?>[] groups()做分组校验。但如果你的业务并不需要,不用为了"看起来高级"加 Class 属性,因为 Class 属性在单元测试和序列化场景下往往很麻烦。

2.3 一个能用的例子:定义我们的 @LogRecord

基于上面的语法,我定义了一个最简版操作日志注解:

@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface LogRecord { String module() default ""; String action() default ""; String level() default "INFO"; }

设计意图是这样的:

module表示业务模块,比如"用户管理""订单管理"。只有字符串类型,以后存数据库方便统计。action表示具体行为,比如"新增用户""删除订单"。level表示日志级别,默认 INFO,比如异常场景可以标 ERROR。

为什么只标方法?因为操作日志的核心载体就是方法,一个业务动作对应一个方法。把@Target设为ElementType.METHOD,能防止有人随手把注解标到字段或类上造成混乱。

用起来也很自然:

@Service public class UserService { @LogRecord(module = "用户管理", action = "新增用户") public void addUser(User user) { // 业务逻辑 } }

到了这一步,注解只是"写了但没人管"的装饰品。它还没有任何实际行为。要想让它真正产生作用,必须写对应的注解处理器。

3. 注解解析:让注解从"写"到"用"的关键一步

注解本身不干活,干活的是解析注解的代码。这是理解注解最重要的心智模型。

3.1 反射读取注解的三种姿势

Java 反射包提供了java.lang.reflect.AnnotatedElement接口,Class、Method、Field、Constructor都实现了它。最常用的方法有这些:

// 判断是否存在指定注解 boolean isAnnotationPresent(Class<? extends Annotation> annotationClass) // 获取指定注解实例 <T extends Annotation> T getAnnotation(Class<T> annotationClass) // 获取全部注解 Annotation[] getAnnotations() // 获取直接声明的注解(不含继承来的) Annotation[] getDeclaredAnnotations() // 获取指定类型的注解,支持 @Repeatable <T extends Annotation> T[] getAnnotationsByType(Class<T> annotationClass)

getAnnotation和getDeclaredAnnotation的区别很关键。对类来说,getAnnotation会往上查找父类里带有@Inherited标记的注解,而getDeclaredAnnotation只认本类直接标的那一个。

对方法和字段来说,两者一般情况下结果一致,因为方法和字段的注解不涉及继承。但如果一个类实现了接口,并且接口方法上标了注解,实现类的方法上是拿不到这个注解的。这一点容易与 Spring 里的某些场景混淆,后面会再细说。

3.2 手写一个注解处理器

我写了一个简单的LogRecordProcessor,通过反射把模块和操作名打印出来:

public class LogRecordProcessor { public static void processClass(Class<?> targetClass) { // 获取目标类所有公开方法 Method[] methods = targetClass.getMethods(); for (Method method : methods) { LogRecord record = method.getAnnotation(LogRecord.class); if (record != null) { System.out.printf("方法:%s, 模块:%s, 操作:%s, 级别:%s%n", method.getName(), record.module(), record.action(), record.level()); } } } }

调用方式:

LogRecordProcessor.processClass(UserService.class);

这种写法有一个小坑:getMethods只返回公有方法,并且如果类继承自其他类,会包含父类的公有方法。如果只想处理当前类自己声明的方法,应该用getDeclaredMethods。

更严谨一点的处理器还要过滤掉编译器生成的桥接方法,比如带泛型返回值的方法。用method.isBridge()可以判断。大多数业务场景不需要这么做,但如果你在做一个通用基础组件,这一步不能漏。

如果需要读取方法参数上的注解,可以用method.getParameterAnnotations(),它返回一个二维数组,外层对应参数位置,内层对应该参数上的注解集合。

3.3 生产环境中的注解解析:Spring 到底帮你做了什么

很多同学会有疑问:我自己写注解还得写反射代码,但使用@Transactional、@RequestMapping时也没写过解析器,Spring 到底是怎么处理注解的?

答案是:框架基于反射封装了一套完整的解析机制,并且在 Bean 初始化阶段就完成了扫描和包装,我们只是没有感知而已。

Spring 里有AnnotationUtils和AnnotatedElementUtils这几个工具类,它们处理了代理类、组合注解、@AliasFor属性别名等复杂情况。比如@RequestMapping本身是一个复合注解,它内部组合了@Target、@Retention、@Documented,还被@Controller等注解引用,Spring 能通过属性别名机制把各个组合属性合并起来表现成一套逻辑属性,这套机制如果全部自己写,工作量非常大。

所以日常开发中我建议:如果你是在 Spring 项目里做自定义注解,优先考虑结合 AOP 或 Spring 的BeanPostProcessor,让框架帮你解析,而不是自己裸写反射。但学习阶段,用纯反射写一遍处理器,对理解底层非常有帮助,面试时也能讲得更清楚。

4. 实战场景:自定义注解能搞定的三类常见需求

理解了注解定义和反射解析,我们来看看实际项目中怎么用。我挑了三个最常见的场景:参数校验、权限控制、日志埋点。

4.1 参数校验注解:摆脱一堆 if-else

后端接口参数校验是刚需。很多老代码里长这样:

if (user.getName() == null || user.getName().isEmpty()) { throw new IllegalArgumentException("用户名为空"); } if (user.getAge() <= 0) { throw new IllegalArgumentException("年龄不合法"); }

这种代码写多了既啰嗦又容易漏。我们用自定义注解简化它:

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

再写一个校验器:

public class Validator { public static void validate(Object obj) throws IllegalAccessException { Class<?> clazz = obj.getClass(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); NotBlank notBlank = field.getAnnotation(NotBlank.class); if (notBlank != null) { Object value = field.get(obj); if (value == null || (value instanceof String && ((String) value).trim().isEmpty())) { throw new IllegalArgumentException(notBlank.message()); } } } } }

注意,field.setAccessible(true)能绕过私有访问限制,但前提是项目没有启用严格的模块化安全策略。在普通 Java SE 项目里没问题,在强模块化或带 SecurityManager 的环境下,这里可能抛出异常,需要额外做异常处理。

使用时:

public void createUser(User user) { Validator.validate(user); // 业务逻辑 }

入口统一校验,字段加注解声明规则,业务代码清爽不少。当然,实际项目直接用 Jakarta Bean Validation 的@NotNull、@NotBlank也是同理,理解自定义注解能让你看懂这些注解底层到底在做什么。

4.2 权限控制注解:一注解控制接口访问

权限控制如果用 if-else 写,每个接口都要重复判断角色,稍微复杂一点就很容易漏。用注解声明权限是个更好的思路:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String[] value(); }

然后配合一个简单的 AOP 切面(Spring 环境):

@Aspect @Component public class PermissionAspect { @Around("@annotation(requiresPermission)") public Object checkPermission(ProceedingJoinPoint joinPoint, RequiresPermission requiresPermission) throws Throwable { // 从 SecurityContext 获取当前用户 // 判断用户权限是否匹配 requiresPermission.value() // 如果不匹配则抛出权限异常 return joinPoint.proceed(); } }

使用:

@RequiresPermission({"user:add", "user:update"}) public void updateUser(User user) { }

这样权限规则和业务逻辑彻底分离,新增接口时只需声明注解,不需要关心权限校验实现细节。

有几个点需要注意。切面类和方法必须被 Spring 管理,也就是注解所在的类要注册为 Bean,切面本身也要是@Component或通过配置注册。如果直接 new 出来的对象调方法,注解不会被切面拦截,因为 Spring 的 AOP 基于代理,代理只对 Spring 容器管理的对象生效。

4.3 日志埋点注解:减少重复代码

日志埋点也是注解的高频场景。很多团队要求每个核心方法记录操作日志,如果手动写日志代码,每个方法都要加一段 try-catch-finally,代码污染严重。

我们用之前定义的@LogRecord配合 AOP:

@Aspect @Component public class LogRecordAspect { private static final Logger log = LoggerFactory.getLogger(LogRecordAspect.class); @Around("@annotation(logRecord)") public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { long startTime = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); log.info("模块[{}] 动作[{}] 耗时[{}ms] 返回[{}]", logRecord.module(), logRecord.action(), System.currentTimeMillis() - startTime, result); return result; } catch (Exception ex) { log.error("模块[{}] 动作[{}] 异常", logRecord.module(), logRecord.action(), ex); throw ex; } } }

切面通知方法里,@annotation(logRecord)这种写法会把方法上的注解实例自动绑定到参数上,非常方便。

实际使用中我发现一个细节:同一个方法上如果标了多个注解,且多个切面分别监听不同的注解,它们的执行顺序受@Order或Ordered接口控制。如果你希望在权限校验通过后再记录日志,就需要给权限切面和日志切面排优先级,否则可能出现"日志记录了,但方法根本没执行"的误导信息。

5. 实战中容易踩的坑:注解失效与排查

自定义注解本身不难,但实际项目中"注解没生效"的排查往往很费时间。我把这些年遇到过的问题整理成几类,希望能帮你少走弯路。

5.1 运行时拿不到注解?先检查这两个地方

第一个地方是@Retention。如果注解没写RUNTIME,默认是CLASS,反射读取全部返回 null。这一点前面强调过,但报错率仍然很高。

第二个地方是代理对象。Spring 的默认代理机制是 JDK 动态代理,如果你给一个类标了注解,但这个类实现的接口上没有标注解,而你在注入时用的是接口类型,此时目标方法有可能是代理对象上的方法,反射获取注解时获取不到。解决方案有两个:一是把注解也标到接口方法上;二是使用 CGLIB 代理(类代理),也就是配置spring.aop.proxy-target-class=true,这会让代理对象保留类的真实类型,但方法上的注解也可能通过桥接方式暴露,具体情况依赖框架版本。

第三个地方是权限修饰符。getMethod和getDeclaredMethod对方法的可见性有要求。getMethod只能拿到公有方法,getDeclaredMethod可以拿到私有方法。如果业务方法用private修饰,又想被 AOP 拦截,需要确认切面配置允许操作私有方法,很多框架默认只对公有方法做代理。

5.2 注解属性的继承与覆盖

@Inherited只对类级别的注解有效。如果父类上有带@Inherited的注解,子类可以通过反射读到。但方法重写、接口实现时,注解不会跟着方法走。比如:

interface UserApi { @LogRecord(module = "用户管理", action = "查询用户") User findById(Long id); }

实现类重写findById时,反射读取实现类方法,拿不到接口方法上的@LogRecord。Spring 的@Transactional也有类似现象:如果事务注解标在接口方法上,而实现类使用了 CGLIB 代理,某些情况下事务标注会丢失。所以我的习惯是:注解标在实现方法上,而不是接口上,避免各种隐藏问题。

还有一个容易踩的坑:如果子类重写了带注解的方法,并自己加了一个注解,那么父类注解并不会和子类注解合并。读取子类方法注解时,拿到的只有子类自己标的那一个。Spring 的@AliasFor可以解决部分合并问题,但那属于框架层的高级特性。

5.3 反射解析的性能与缓存技巧

反射调用本身有一定开销,如果每次业务执行都通过反射去获取注解和属性,性能会受到影响。尤其在并发量大的接口上,重复反射造成的不必要损耗可以被明显感知。

解决的思路通常是缓存。项目里可以维护一个本地缓存,把"方法名或注解类型 -> 解析结果"存起来。我第一次优化时就用了ConcurrentHashMap,后续接入 Caffeine 效果更好。不过要特别注意缓存 key 的设计,如果用方法对象作为 key,要注意 JVM 的类卸载问题导致的内存泄漏,实际项目更推荐用Class + 方法名 + 参数类型列表做 key。

// 伪代码 private final ConcurrentHashMap<String, LogMeta> cache = new ConcurrentHashMap<>(); private LogMeta getLogMeta(Method method) { return cache.computeIfAbsent(cacheKey(method), key -> parse(method)); }

Spring 内部也是这么做的,AnnotationUtils会有findAnnotation缓存,目的就是避免重复扫描。自己写解析器时完全可以参考这个思路。

5.4 注解处理器和编译期扫描的差异

前面讲的都是运行时注解,使用反射读取。还有一类是编译期注解,最典型的就是 Lombok 的@Getter、@Setter。它们通过 Java 注解处理器(Annotation Processor)在javac编译期间生成代码。

如果自定义注解想走编译期处理路线,需要实现javax.annotation.processing.Processor接口或用AbstractProcessor类,并通过 SPI 方式注册到META-INF/services。实际项目编译时还可能要配置maven-compiler-plugin的annotationProcessorPaths,否则处理器的依赖在运行时可能漏掉。

顺便说一句,很多人会混淆 Lombok 和运行期自定义注解。Lombok 是编译期生成 getter/setter,完全不依赖运行期反射;而我们的@LogRecord是运行期注解,依赖反射或 AOP。两者的"生效时机"完全不同,排查问题时一定要先搞清楚自己使用的是哪种模式。

再提一个很常见的面试问题:"注解到底能不能被继承?"答案要分成两层:@Inherited可以让类注解被子类继承;方法注解、字段注解、参数注解都不支持继承,哪怕加了@Inherited也不生效。如果你在面试时能把这层说清楚,面试官通常会比较认可。

最后分享一个我个人的习惯:设计自定义注解之前,先想清楚三个问题。第一,这个注解会在哪个阶段读取,编译期还是运行期;第二,谁来解析它,AOP 切面、反射工具还是编译器处理器;第三,如果用反射,是否已经考虑缓存和继承边界。把这几个问题想透了,再写代码基本一次过。社区里那些看似花哨的注解,拆开来看也无非就是"标注 + 解析 + 元数据映射"这三个大脑皮层动作。搞懂这套机制之后,再看 Spring 源码、读各种框架的约定式开发,都会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询