ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java注解底层机制与工程实践:从字节码到APT注解处理器

Java注解底层机制与工程实践:从字节码到APT注解处理器

三年前接手一个老项目,登录接口的方法上挂着一个@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
  • String
  • Class(或其泛型形式,如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)
生效时机程序运行时编译阶段
注解保留策略要求必须RUNTIMESOURCE即可
运行开销有反射与查找成本零运行时开销
能否生成代码不能可以生成新源文件
能否做编译期校验不能可以主动报编译错误
调试成本低,断点能进高,处理器出问题难排查
适用场景行为增强、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 最容易翻车的地方。传统全量编译时,每次都会清空输出目录,生成文件不会重复。但增量编译只重新编译改动过的类,处理器如果不配合,就会出问题:要么旧生成文件还在、新的又生成一份导致重复类错误,要么改动被判定为“没有影响生成”,结果生成代码没更新。

排查时按这个顺序走:

  1. 先看有没有FilerException: Attempt to recreate a file。有的话,说明同一轮或者跨轮重复创建了同名文件,检查你的命名规则有没有把元素区分开,以及有没有在多轮里重复生成。
  2. 再看有没有cannot find symbol,且报错位置在生成类里。这通常是生成时机的顺序问题,处理器在一轮里生成了代码但引用了尚未生成的类。把依赖关系拆到不同轮,或者用Element的镜像 API 而不是直接拼字符串。
  3. 最后看生成结果是不是“旧版本”。这种情况往往和构建工具的增量判定有关,检查处理器的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),框架要做的就是拦截方法调用。

实现链路大致是:

  1. 容器启动时扫描所有 Bean,找出方法上带@RetryOnFailure的类。
  2. 为这些类生成代理对象(JDK 动态代理或字节码生成),原本注入到别处的是代理,不是原始实例。
  3. 代理的invoke方法里,先查这个方法有没有重试配置(这里就用上了 3.2 节的缓存)。
  4. 有配置就包一层循环:调用目标方法,捕获异常,判断是否可重试,等待后重试;没有配置就直接放行。

这个链路里有两个坑值得单独说。

第一,自调用失效。同一个类里 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对象。

最后分享一个我个人的习惯:新建注解时先写消费方,再写注解定义。反着来的话,很容易设计出一堆“看起来合理但实际用不上”的属性,最后要么空着,要么在消费方硬凑逻辑去用它。先想清楚读取端需要什么,注解的属性列表自然就收敛了。这个顺序调整看着小,省下的返工时间不少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进