三年前接手一个老项目,登录接口的方法上挂着一个@OpLog注解,注释里写着“记录操作日志”。我把业务逻辑改完提测,日志一条都没多出来。翻了大半天代码才发现,真正解析这个注解的切面类早被人在一次合并里注释掉了,而注解依然老老实实挂在方法签名上方,像一个没人接收的信号。这件事之后我对 Java 注解 的理解彻底变了——它压根不是什么语法糖,也不是注释的升级版,它就是一段被打包进 class 文件的静态数据,没有代码去消费它,它就什么都不是。
这篇东西想系统性地把 Java 注解从“字节码里长什么样”一直聊到“怎么自己写一个编译期处理器”,中间夹着我这些年踩过的坑:@Target 写错导致注解静默失效、@Inherited 在接口上完全不起作用、反射读注解把接口 QPS 拉低三成、注解处理器在增量编译下莫名重复生成代码。适合已经会用注解但说不清它底层机制的同学,也适合想自己造一个注解驱动组件的中高级开发者。看完你至少能做到两件事:定义注解时知道每个元注解该不该加,以及注解不生效时知道从哪几个方向排查。
1. 注解在字节码里到底是个什么物件
很多人学注解的第一课是“怎么定义、怎么用”,但真正决定你会不会用错的,是搞清楚它在 class 文件里以什么形式存在。这一层想明白了,后面所有的坑基本都能自己推出来。
1.1 一份注解被编译后的真实形态
先看一个最简单的注解定义:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface OpLog { String module(); int level() default 1; }当你在某个方法上写@OpLog(module = "login", level = 2)并编译之后,这个注解并不会变成某种可执行代码,而是变成了方法属性表里的一个RuntimeVisibleAnnotations属性。这个属性内部是一串annotation结构,展开后大概是这样的逻辑形态:
RuntimeVisibleAnnotations: type = "com/example/annotation/OpLog" // 指向常量池的类描述符 element_value_pairs: module -> "login" // 常量池 UTF8 项 level -> 2 // 常量池 Integer 项关键点在于:注解本身就是一堆键值对数据,挂在被标注的元素上,随 class 文件一起走。它没有方法体,不会被执行,不会被 JVM 主动调用。你写@OpLog(module = "login")的效果,等价于在一个 Map 里塞了{"module": "login", "level": 2},然后把这个 Map 挂到方法的元数据区。
这就是为什么开头那个项目的日志一条都没产生——注解只是把数据挂上去了,没人读它。理解了这一点,你就不会再问“为什么我的注解没生效”这类问题,而会直接问“谁负责读它,它还在不在”。这个思维转换很重要。
顺带说一个容易忽略的细节:注解属性的值必须是编译期常量表达式。你写@OpLog(module = getModuleName())是一定编译不过的,因为方法调用不是常量。默认值也是同理,int level() default 1 + 1;合法,因为它能在编译期折叠成 2,但default System.currentTimeMillis()就不行。这不是设计者偷懒,而是因为注解数据要静态写进 class 文件,JVM 没有机制在类加载时执行任意代码来算默认值。
1.2 RetentionPolicy 三档策略决定了你能不能用反射读到它
@Retention是新手最容易随手写错、也最容易导致“注解看起来加了却没反应”的元注解。它有三档取值,直接决定注解在什么时候还活着:
| 策略 | 保留位置 | 能否反射读取 | 典型用途 |
|---|---|---|---|
SOURCE | 只存在于源码,编译后丢弃 | 否 | 编译期校验、代码生成(如语法检查) |
CLASS | 写入 class 文件属性表,但 JVM 加载时不载入 | 否 | 字节码分析工具、静态扫描 |
RUNTIME | 写入 class 文件,且 JVM 加载后可通过反射获取 | 是 | 运行时框架、AOP、参数校验 |
默认值是CLASS,不是RUNTIME,这一条坑过无数人。你定义了一个注解,加在 Service 方法上,写了反射代码method.getAnnotation(MyAnno.class),结果返回null,找半天业务代码,最后发现是忘了写@Retention(RetentionPolicy.RUNTIME)。这个 bug 之所以难查,是因为它完全不报错,静默返回 null,一路往下走直到某个地方空指针或者逻辑不生效。
我的习惯是:只要是给运行时代码读的注解,第一行就写@Retention(RetentionPolicy.RUNTIME),不要依赖默认值,也不要相信 IDE 的补全。团队里如果有代码规范评审,这条值得单独列一条规则。
再说CLASS这一档,它存在的意义很多人不清楚。它的场景是:注解信息需要留在 class 文件里供外部工具分析,但运行时不需要。典型例子是某些静态代码扫描器、字节码增强工具,它们在类加载前读字节码,读得到CLASS级别的注解,但 JVM 正常运行时不把它暴露给反射。所以如果你写的是一个“运行时会去读”的组件,CLASS和SOURCE对你来说完全等价——都读不到。
还有一点,@Retention本身能被继承吗?不能。注解类型上没写@Retention时默认CLASS,子注解不会从父注解那里继承 retention 策略——虽然注解之间也根本没有“父注解”这个概念(后面会专门讲注解为什么不能继承)。
1.3 注解属性允许的类型清单,以及它背后的约束
注解属性能用的类型是一张固定清单,超出这个范围一律编译报错:
- 八种基本类型:
byteshortintlongfloatdoublebooleanchar StringClass(或其泛型形式,如Class<? extends Throwable>)- 枚举类型
- 其他注解类型
- 以上任意类型的一维数组
注意几个“不能”:不能是包装类型(Integer不行,只能int)、不能是Object、不能是二维数组(String[][]不行)、不能是自定义的普通类。还有一个很隐晦的:属性值不能是null,注解里根本没有 null 这个概念,想表达“没设置”只能用空字符串、-1或者Void.class这类哨兵值。
为什么要设计成这样?回到 1.1 的结论——注解数据要静态写进 class 文件,class 文件里能高效表达的就那些常量池项:基本类型常量、UTF8 字符串、类引用、字段引用。你要塞一个任意对象进去,就得序列化,就得处理版本兼容,成本远超收益。这套限制本质上是为可静态序列化而设计的。
顺带说个实际影响:很多人想在注解里存一个复杂配置对象,比如@RetryPolicy(maxAttempts = 3, backoff = new ExponentialBackoff(1000, 2.0)),写不出来。常见的替代方案是拆成多个基本类型属性(initialDelay、multiplier、maxAttempts),让消费方自己组装对象。这个模式在实际项目里非常普遍,理解了限制的原因,你设计注解时会自然地把“声明式元数据”和“运行时状态对象”分开。
2. 元注解的取舍:@Target、@Inherited、@Repeatable 各管什么
元注解是“用来修饰注解的注解”,一共就那么几个,但每一个的语义边界都比看上去细。写错一个,注解的适用范围就会歪掉;理解错一个,调试时会怀疑人生。
2.1 @Target 写错导致的“静默失效”现场
@Target声明注解能贴在哪些程序元素上。取值清单不算长,但有一半的人只用过前三个:
| ElementType | 可标注位置 | 常见误用 |
|---|---|---|
TYPE | 类、接口、枚举、注解类型 | 以为只管 class,其实注解定义本身也算 |
FIELD | 字段(含枚举常量) | 标在局部变量上报错,以为是 bug |
METHOD | 方法(不含构造器) | 想把构造器也标上,得加CONSTRUCTOR |
PARAMETER | 方法参数 | 参数校验场景必备 |
CONSTRUCTOR | 构造器 | 常被遗漏 |
LOCAL_VARIABLE | 局部变量 | 保留策略一般只能 SOURCE |
ANNOTATION_TYPE | 注解类型 | 写组合注解时必需 |
PACKAGE | 包声明(package-info.java) | 用得少但确实存在 |
TYPE_PARAMETER | 泛型类型参数<T> | 类型系统扩展用 |
TYPE_USE | 任何类型使用处 | 最灵活也最容易被滥用 |
一个真实踩坑:某次给参数做非空校验,注解定义写了@Target(ElementType.FIELD),然后加在方法参数上——编译器直接报错annotation type not applicable to this kind of declaration。这个还好,至少报错了。更坑的是反过来:注解@Target范围写得过宽(比如只写了TYPE,但实际想同时支持字段),结果加在字段上时 IDEA 会标红,但如果是从别处复制来的代码、或者用了某些动态生成手段,可能就悄悄过了。
还有一种更隐蔽的情况:@Target完全没写。这时注解可以用在除TYPE_PARAMETER和TYPE_USE之外的所有位置,相当于全开了。这看起来很省事,但我建议永远显式声明@Target,理由是:注解的语义应当和它的适用位置强绑定,范围放宽会让调用方产生错误期待。比如一个“接口幂等”注解,贴在字段上毫无意义,那就应该明确只允许METHOD。
2.2 @Inherited:只在类继承链上生效,接口完全不认
@Inherited大概是误解率最高的一个元注解。它的语义是:如果注解标在类 A 上,且 A 有子类 B,那么通过反射在 B 上查询这个注解时,会沿着父类链向上找到 A 上的声明。注意几个限定词:
第一,只对类继承生效。接口上标@Inherited注解,实现类反射查询是拿不到的。我见过有人把注解标在接口上,期待所有实现类自动获得这个标记,结果全部返回 null。这不是 bug,是规范就这么定的。
第二,方法覆写不继承。父类方法上标了@Inherited注解,子类覆写这个方法,反射查子类方法拿不到注解。因为方法上的注解不是“继承”来的,覆写是全新的方法声明。想要拿到父类方法上的注解,得手动getSuperclass().getDeclaredMethod(...)往上找。
第三,@Inherited只影响getAnnotation这类“查找”行为,不影响getDeclaredAnnotations(后者永远只返回本元素直接标注的注解)。
这三条限制合起来,意味着@Inherited的实用场景其实很窄,主要用于“类级别标记”,比如某个标记某个类是某个框架的组件、或者标记某个类的序列化策略。写方法级注解时加上@Inherited基本是无效的,我甚至建议除非明确需要,否则不要加——加了会让继承链上出现隐式的行为传递,调试时容易懵。
2.3 @Repeatable 的容器机制与 getAnnotationsByType 的行为差异
Java 8 之前,同一个注解不能在一个元素上写两次。想标多个,只能塞数组属性:@Role({"admin", "auditor"})。Java 8 引入了@Repeatable,让你能写成:
@Role("admin") @Role("auditor") public void sensitiveOp() { ... }但它内部其实还是“容器”机制。你定义@Role时,得额外定义一个容器注解:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Repeatable(Roles.class) public @interface Role { String value(); } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Roles { Role[] value(); }编译器会把你写的两个@Role自动包装成@Roles({@Role("admin"), @Role("auditor")})存进 class 文件。这意味着读取方式变了:
getAnnotation(Roles.class)—— 拿到容器,能取到数组getAnnotationsByType(Role.class)——推荐用这个,返回Role[],容器和单个注解都能正确处理getAnnotation(Role.class)—— 只在恰好标了一个@Role时返回非 null,标了多个返回 null
最后这条是高频坑点。一个方法上标了两个@Role,你用getAnnotation(Role.class)查询,得到 null,然后判断“没有角色限制”,直接把权限校验放过了。我建议所有使用@Repeatable的地方,消费端一律用getAnnotationsByType,并且在代码评审时把getAnnotation出现的位置都过一遍。
另外容器注解本身要不要暴露给外部?一般建议加上@Documented和相同的@Retention、@Target,让 Javadoc 和反射行为保持一致。容器注解千万别漏@Retention(RUNTIME),否则外层容器保留不到运行时,里面包着的重复注解自然也读不到——这个错误特别隐蔽,因为编译完全通过。
3. 自定义注解从设计到落地的完整链路
定义注解只要几行,难的是“设计对”和“消费得高效”。这一节按我平时做组件的顺序走一遍:先定语义,再写解析,最后选编译期还是运行期。
3.1 先划语义边界:注解该承载什么
我在设计注解前会先问三个问题:这个信息是声明期确定的吗?是跨层传递的吗?需要被工具读取吗?三个都是“是”,才考虑用注解。
举个例子,一个“接口限流”需求。如果限流阈值来自配置中心、需要热更新,那它就不适合放进注解——注解是编译期固化的,改阈值必须重新编译发布。这种场景更适合注解只标“这个接口需要限流”,阈值从配置里按方法签名去查。反过来,如果阈值是固定的(比如“这个接口内部使用,QPS 上限 50”),放注解里就很自然。
再举一个反例:有人设计过@UserContext注解,想在注解里传当前登录用户的 ID。这完全走错了方向,注解是类级别的静态数据,所有线程共享同一份,塞运行时状态进去只会出各种脏数据。运行时数据要么走方法参数,要么走线程上下文,注解只能承载“元信息”。
我的经验规则是:注解描述“是什么”,代码决定“怎么做”。注解里不要出现动词,不要出现状态,只放描述性、声明性的键值对。这条规则能过滤掉 90% 的设计错误。
3.2 用反射解析注解的完整写法与缓存优化
假设我们有一个@Throttle注解,标在方法上表示该方法需要限流:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface Throttle { int qps() default 50; String key() default ""; }最直接的解析方式:
Method method = target.getClass().getMethod("queryOrder", String.class); Throttle throttle = method.getAnnotation(Throttle.class); if (throttle != null) { int qps = throttle.qps(); String key = throttle.key().isEmpty() ? method.getName() : throttle.key(); // 用 qps 和 key 去构造限流器 }这段代码能跑,但放进高频调用的拦截器里性能会有问题。getAnnotation内部会遍历注解数组并做类型匹配,还要处理@Inherited的父类查找,虽然单次开销不大,但一秒钟几十万次调用就显出来了。我实测过一个类似的拦截器,把注解解析放到每次调用里,接口平均耗时从 0.8ms 涨到 1.1ms 左右,P99 更明显。
解决办法是缓存,但缓存 key 要选对。很多人用method.toString()做 key,这玩意儿包含返回类型和参数类型,字符串拼接本身就有开销,还不如直接用Method对象做 key:
private static final ConcurrentHashMap<Method, ThrottleConfig> CACHE = new ConcurrentHashMap<>(); static ThrottleConfig resolve(Method method) { return CACHE.computeIfAbsent(method, m -> { Throttle t = m.getAnnotation(Throttle.class); if (t == null) { return ThrottleConfig.NONE; // 用空对象占位,避免重复反射 } String key = t.key().isEmpty() ? m.getName() : t.key(); return new ThrottleConfig(t.qps(), key); }); }这里有两个细节值得说。一是用空对象占位(ThrottleConfig.NONE),而不是缓存 null——ConcurrentHashMap不允许 null value,而且空对象能让后续判断逻辑统一。二是Method对象作为 key 是安全的,同一个类的同一个方法在同一个类加载器下,反射拿到的Method对象是相等的(equals基于声明类、方法名、参数类型),哈希也稳定。但要注意:如果涉及多类加载器场景(比如热部署),缓存可能一直增长,得配个弱引用或者定期清理。
再补一个更彻底的优化方向:如果解析逻辑本身很重(比如要做字符串模板替换),可以在类加载时预处理,把结果缓存到Class级别的静态字段里,或者用字节码增强在编译期就把配置生成好。后者就是下一节要聊的路线。
3.3 编译期处理还是运行期反射:两条路线的取舍
自定义注解落地时有两条完全不同的技术路线,很多人默认走运行期反射,但其实不少场景编译期更合适:
| 维度 | 运行期反射 | 编译期处理器(APT) |
|---|---|---|
| 生效时机 | 程序运行时 | 编译阶段 |
| 注解保留策略要求 | 必须RUNTIME | SOURCE即可 |
| 运行开销 | 有反射与查找成本 | 零运行时开销 |
| 能否生成代码 | 不能 | 可以生成新源文件 |
| 能否做编译期校验 | 不能 | 可以主动报编译错误 |
| 调试成本 | 低,断点能进 | 高,处理器出问题难排查 |
| 适用场景 | 行为增强、AOP、参数校验 | 样板代码生成、静态约束检查 |
判断标准其实很朴素:如果注解的作用是“在运行时改变行为”,走反射;如果注解的作用是“在编译时发现错误或生成代码”,走 APT。两者不互斥,一个注解可以同时被 APT 校验、又被运行时反射消费。
有个折中方案值得提一下:编译期生成 + 运行期查表。注解还是RUNTIME,但你用 APT 在编译时把每个方法上的注解信息抽取出来,生成一个Map<String, Config>的初始化代码。运行时直接查这个 Map,不用反射。我在一个高并发的网关项目里用过这个套路,把注解解析的耗时从每次调用几十微秒降到了常量级的 HashMap 查找。代价是构建配置复杂一点,生成代码要给编译器“认路”。
4. 动手写一个编译期注解处理器
APT 是 Java 注解体系里最有“造物感”的部分,也是最容易卡住的地方。这一节按实际动手顺序讲:处理器怎么被加载、round机制是什么、生成代码和校验代码的写法差异,最后说说常见报错。
4.1 AbstractProcessor 的加载机制与 round 循环
注解处理器不是靠 new 出来的,而是靠SPI(Service Provider Interface)机制被编译器发现。具体做法是在你的处理器模块里放一个固定路径的文件:
src/main/resources/META-INF/services/javax.annotation.processing.Processor文件内容就是处理器类的全限定名,一行一个:
com.example.processor.AuditLogProcessor编译时,javac会读取所有 classpath 上这个路径的文件,实例化里面列出的处理器,然后按@SupportedAnnotationTypes声明的类型分发。
处理器主体的骨架长这样:
@SupportedAnnotationTypes("com.example.annotation.AuditLog") @SupportedSourceVersion(SourceVersion.RELEASE_17) public class AuditLogProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if (roundEnv.processingOver()) { return false; } for (Element element : roundEnv.getElementsAnnotatedWith(AuditLog.class)) { if (element.getKind() != ElementKind.METHOD) { processingEnv.getMessager().printMessage( Diagnostic.Kind.ERROR, "@AuditLog 只能标注在方法上", element); continue; } // 生成代码或校验 } return true; // 返回 true 表示已处理,其他处理器不再收到 } }关键是理解round。编译器会把处理过程分成多轮:第一轮处理源码里原始的注解;如果你的处理器生成了新的源文件,编译器会编译这些新文件,进入第二轮;如此循环,直到没有新文件产生。每一轮process都会被调用一次,所以你的代码必须是幂等的——不能因为被调用两次就生成两份代码。
一个常见事故:在process里直接生成文件,没有判断roundEnv.processingOver(),也没检查“这个元素是不是我在上一轮生成的”。结果就是处理器生成的文件里又带了触发注解,下一轮又生成,直到编译器报 “maximum number of rounds reached”。我在一个项目里见过堆栈信息刷了好几百行,最后定位到处理器让每个类都生成了一个对应的辅助类,而辅助类上恰好又标了同一个注解。
4.2 生成代码与校验代码:两种用途的写法差异
APT 最常见的两种用途,写法差别挺大。
生成代码用的是Filer:
Filer filer = processingEnv.getFiler(); try (Writer w = filer.createSourceFile("com.example.generated.Config_" + className).openWriter()) { w.write("package com.example.generated;\n"); w.write("public class Config_" + className + " {\n"); w.write(" public static final int QPS = " + qpsValue + ";\n"); w.write("}\n"); }几个实操要点:一是createSourceFile的类名必须是全限定名;二是重复创建同名文件会抛FilerException,所以要么用try/catch吞掉并检查元素归属,要么在生成前先算好唯一名字;三是写文件用try-with-resources,不然编译器可能读不到完整内容。
校验代码用的是Messager:
processingEnv.getMessager().printMessage( Diagnostic.Kind.ERROR, "qps 必须大于 0,当前值: " + qps, element);第三个参数传Element很重要,它让错误信息能精确定位到源码位置,IDE 里直接点过去就是出错的那一行。不传的话,错误会挂在文件开头或者编译器默认位置,排查体验很差。我见过不少手写处理器只报文本不带元素,用起来跟看天书一样。
还有个坑:校验别只做一次。process每轮都调用,如果你的校验逻辑放在getElementsAnnotatedWith遍历里,多轮会重复报同样的错误。稳妥做法是加个Set<String>记录已报错的元素名,或者把校验逻辑放进一个只在第一轮执行的分支里。
4.3 增量编译下的重复生成与常见报错排查
增量编译是 APT 最容易翻车的地方。传统全量编译时,每次都会清空输出目录,生成文件不会重复。但增量编译只重新编译改动过的类,处理器如果不配合,就会出问题:要么旧生成文件还在、新的又生成一份导致重复类错误,要么改动被判定为“没有影响生成”,结果生成代码没更新。
排查时按这个顺序走:
- 先看有没有
FilerException: Attempt to recreate a file。有的话,说明同一轮或者跨轮重复创建了同名文件,检查你的命名规则有没有把元素区分开,以及有没有在多轮里重复生成。 - 再看有没有
cannot find symbol,且报错位置在生成类里。这通常是生成时机的顺序问题,处理器在一轮里生成了代码但引用了尚未生成的类。把依赖关系拆到不同轮,或者用Element的镜像 API 而不是直接拼字符串。 - 最后看生成结果是不是“旧版本”。这种情况往往和构建工具的增量判定有关,检查处理器的
getSupportedOptions有没有正确声明增量支持(部分构建工具认org.gradle.annotation.processing.aggregating这类选项),以及处理器有没有去读外部文件——读了的话,增量判定就失效了,得显式声明依赖。
实话说,APT 的调试体验谈不上好。我个人的习惯是:先在命令行下用javac -processor单独跑一遍,把处理器相关的问题隔离出来,确认生成的文件内容正确之后,再放回构建工具里跑增量。这样能避开大量构建工具层面的干扰信息。
5. 注解在框架里到底扮演什么角色
理解了注解得怎么造,回头看主流框架里那些注解,你会发现它们的套路高度一致:注解负责声明契约,框架的代理或反射负责履行契约。这一节拆三个典型场景。
5.1 声明式参数校验:注解只是标记,校验逻辑在别处
参数校验是最容易理解的注解应用。一个典型的写法:
public class OrderRequest { @NotBlank(message = "订单号不能为空") private String orderNo; @Min(value = 1, message = "数量至少为 1") private Integer quantity; }很多人第一次用会以为“注解自动生效”,其实背后一定有个校验器在跑。它的工作流程大概是:拿到方法参数对象 → 遍历字段 → 取每个字段上的注解 → 按注解类型分派到对应的校验器实现 → 收集错误信息。
这里的注解里塞了message,这个属性是典型的“元数据”用法:它是静态的提示文本,和运行时状态无关,非常符合 3.1 节讲的边界。相反,如果有人设计一个@CheckResult注解,想把校验结果写回注解里,那就跑偏了——注解实例是代理对象,反射拿到的是缓存的单例,你改它的属性可能影响全局。
校验场景有一个性能注意点:字段遍历和注解查找应该在启动时做一次,缓存成元数据。如果一个请求进来才去反射遍历所有字段,压测时会很难看。我见过一个接口因为这个原因,单机 QPS 只有正常水平的四成左右。
5.2 注解加动态代理:切面是怎么把这些注解串起来的
注解驱动的行为增强,几乎都绕不开动态代理。以“方法级重试”为例,你标了@RetryOnFailure(times = 3),框架要做的就是拦截方法调用。
实现链路大致是:
- 容器启动时扫描所有 Bean,找出方法上带
@RetryOnFailure的类。 - 为这些类生成代理对象(JDK 动态代理或字节码生成),原本注入到别处的是代理,不是原始实例。
- 代理的
invoke方法里,先查这个方法有没有重试配置(这里就用上了 3.2 节的缓存)。 - 有配置就包一层循环:调用目标方法,捕获异常,判断是否可重试,等待后重试;没有配置就直接放行。
这个链路里有两个坑值得单独说。
第一,自调用失效。同一个类里 A 方法调 B 方法,B 上有注解,A 是通过this调用的,走不到代理,注解就不生效。这个现象太常见了,几乎所有 AOP 框架都一样。解决办法要么把 B 挪到另一个 Bean,要么注入自己的代理(会有循环依赖风险),要么直接用编程式调用。我一般在代码评审时盯着“注解标在方法上但同类内部有调用”的地方。
第二,注解标在接口方法上还是实现方法上。JDK 动态代理生成的代理类实现接口,invoke里拿到的Method对象通常是接口方法的。如果你把注解标在了实现类的方法上,通过代理可能查不到。稳妥做法是标在接口上,或者在代理逻辑里通过target.getClass().getMethod(...)二次查找实现类方法。这个细节在排查“注解时而生效时而不生效”时特别有用。
5.3 测试框架里的注解生命周期:为什么 @BeforeEach 会执行多次
测试框架的注解用法是另一个经典场景,而且它能很好地说明“注解只是数据,执行逻辑在框架”。你写:
@Test void shouldCalculateTotal() { ... } @BeforeEach void setUp() { ... }这些注解本身没有任何魔法,是测试运行器在跑之前,反射扫描测试类:先找所有@BeforeEach方法,按某种顺序(通常是不保证顺序的,所以别写有依赖的多个 setup)执行;再找@Test方法,逐个调用;每个测试之间重新实例化测试类(这是为什么每个测试的环境是隔离的)。
@BeforeEach在多个测试方法之间会各执行一次,很多人一开始以为是“整个类跑一次”。如果确实需要一次性的初始化,得用@BeforeAll,且方法必须是静态的(在默认生命周期下)。这两个注解的差别,本质上是注解的语义由框架定义,不是由名字暗示——名字起得再清楚,也得看框架怎么消费。
还有一类注解是纯粹的“元信息”,比如@DisplayName("下单流程-正常路径"),它只影响报告里的显示文本,不参与执行逻辑。这类注解最容易理解,也最容易被滥用——我见过有人在注解里写很长的说明文档,结果 IDE 里方法签名那一块被撑得很长,可读性反而下降。
6. 这些坑我替你踩过了
前面各节零散提了一些注意事项,这一节集中整理几个高频问题,都是能省下半天排查时间的那种。
6.1 反射读注解的开销到底有多大
先给结论:单次getAnnotation在微秒级,但放进每秒几十万次的热点路径里,就是明显的损耗。而且它有个隐藏成本——注解实例是动态代理对象,每次取属性都要经过一次代理调用,不是直接读字段。你写throttle.qps(),底层是AnnotationInvocationHandler.invoke走一遍 Map 查找,比读一个普通字段慢得多。
优化手段按性价比排序:
- 缓存解析结果(3.2 节的方案),最有效,改动最小。
- 把多次属性访问合并成一次,解析时一次性把所有属性读出来存进 POJO,之后不再碰注解对象。
- 用 APT 生成静态配置,彻底消除运行期反射,成本是构建复杂度。
- 避免在循环里调用
getAnnotations()(返回全部注解数组),只查你需要的那一个。
有个反直觉的点:getDeclaredAnnotations()在部分实现里比getAnnotation()快,因为它不做@Inherited的父类链查找。如果你确定注解不会从父类继承,用这个能省一点。
6.2 注解不能继承,也不能带泛型
这条得单独强调:注解类型不能extends另一个注解。Java 语言层面直接禁止,你写public @interface B extends A会编译报错。
想实现“注解组合”只能用另一种方式:定义一个组合注解,把想要的注解标在它自己身上,再让消费方递归展开。这就是所谓的元注解组合模式,很多框架的@RestController之类都是这么做的——它内部标了若干个更基础的注解,消费方读取时做递归查找。
但注意,递归查找需要你自己实现。getAnnotation(A.class)不会帮你穿透组合注解。常见的写法是写一个工具方法,先查直接注解,没有就取注解上的注解,递归一层(通常只递归一层就够,太深可读性差)。这个工具方法建议在团队内统一,不然每个人写一版,行为不一致。
另外,注解的属性不能是泛型类型参数。你不能写@MyAnno(T value()),因为注解类型本身不能声明类型参数。需要泛型语义时,只能用Class<?>加运行时判断。
6.3 编译期代码生成与注解的冲突处理
现在很多项目会引入编译期代码生成插件(那种靠注解在编译期改 AST、生成 getter/setter、生成 Builder 的工具)。这类工具和自定义 APT 处理器部署在一起时,容易出问题:
- 处理顺序不确定。多个处理器同时存在时,谁先跑没有保证。如果你的处理器需要读取生成插件生产的字段或方法,可能在那一轮还看不到。
- 元素镜像失效。在
round之间,Element对象不能被缓存复用,因为编译器内部结构会重建。缓存了Element到下一轮用,可能拿到失效引用,表现为属性读出来是空的或者抛异常。 - 增量编译互相干扰。生成插件改了文件,你的处理器可能被判定为需要重跑,也可能不被判定,取决于构建工具的依赖分析。
应对策略比较土但有效:处理器里尽量避免依赖其他处理器生成的内容,如果必须依赖,就放在最后一轮(processingOver()为 true 之前的那一轮)处理。另外,所有跨轮的状态都存成字符串(类名、方法签名),不要存Element对象。
最后分享一个我个人的习惯:新建注解时先写消费方,再写注解定义。反着来的话,很容易设计出一堆“看起来合理但实际用不上”的属性,最后要么空着,要么在消费方硬凑逻辑去用它。先想清楚读取端需要什么,注解的属性列表自然就收敛了。这个顺序调整看着小,省下的返工时间不少。