- 第一篇:Java基础概念四连问,==与equals、hashCode约定、接口vs抽象类、深拷贝vs浅拷贝
- 第二篇:String、StringBuilder、StringBuffer深度剖析
- 第三篇:泛型深度解析------类型擦除与通配符的奥秘
- 第四篇:反射与动态代理------Java语言动态性的核心
- 第五篇:异常处理机制与最佳实践
- 第六篇:HashMap源码深度剖析------从JDK 7到JDK 8的演进
- 第七篇:注解与APT深度解析------从@Override到Lombok的底层原理
在《反射与动态代理》中我们体验了运行时动态操作类的强大。但有一种技术,它不需要运行时反射 ,就能在编译期自动生成代码,改变程序行为------这就是注解与APT(注解处理器) 。@Override、@Autowired、@Getter这些注解到底做了什么?为何Lombok只用@Data就能消灭样板代码?Java注解为什么有的只在源码有效、有的能保留到运行时?这些问题,需要从注解的本质和编译期处理机制中寻找答案。
本文核心问题:
- 注解到底是什么?它是不是接口?
@Retention三种保留策略的底层区别是什么?- 元注解
@Target、@Inherited是怎样控制注解行为的? - 注解的属性为什么只能用某些特定类型?
- 什么是 APT?注解处理器如何在编译期"篡改"代码?
- 实战:手写一个能生成Builder代码的注解处理器。
- Lombok 是如何通过 APT 工作的?它的局限在哪?
读完本文你将对注解拥有"定义 + 使用 + 处理"的闭环理解。
一、注解本身是一种特殊的接口------它到底长什么样?
疑问:@interface 定义的注解,class文件里是什么?
回答: 用 javap 反编译会看到,注解本质上是一个继承了 java.lang.annotation.Annotation 的接口。
java
public @interface MyAnnotation {
String value() default "";
}
反编译后等价于:
java
public interface MyAnnotation extends java.lang.annotation.Annotation {
String value();
}
这也解释了:注解里只能定义方法(属性),不能有字段、构造器 ,因为它是个接口。同时,注解的方法不能有参数,不能抛异常,返回值类型被严格限制为:基本类型、String、Class、枚举、注解,及上述类型的数组。原因在于:JVM规范为注解设计了一套**"常量化"存储方案**,值的表示必须能写入常量池。
java
public @interface Demo {
int a(); // ✔ 基本类型
String b(); // ✔ String
Class<?> c(); // ✔ Class
RetentionPolicy d(); // ✔ 枚举
Override e(); // ✔ 注解
int[] f(); // ✔ 数组
Object g(); // ✘ 编译错误
}
二、三大保留策略:你的注解能活到何时?
疑问:@Retention 的 SOURCE、CLASS、RUNTIME 有什么区别?各自用在什么场景?
回答: 注解的生命周期由 @Retention 决定,分别对应三个阶段的生存:
| 策略 | 存活范围 | 场景举例 |
|---|---|---|
SOURCE |
仅源码,编译时丢弃 | @Override、@SuppressWarnings |
CLASS |
保留到字节码,但JVM不加载 | 编译期代码生成(Lombok) |
RUNTIME |
保留到运行时,反射可读取 | @Autowired、@RequestMapping |
底层原理:
- SOURCE → 编译器检查即丢弃,不写入
.class。所以@Override在javap里完全看不见。 - CLASS → 存于字节码的
RuntimeVisibleAnnotations属性集吗?不,CLASS是RuntimeInvisibleAnnotations,JVM不会加载到内存中,反射API无法获取,但编译期APT可以读到,这就是Lombok的命脉。 - RUNTIME → 存入
RuntimeVisibleAnnotations,JVM解析并放进方法区的注解数据结构中,反射能访问。
因此,想做编译时代码生成,用 CLASS;想在运行时通过反射读取,用 RUNTIME;仅做编译期检查用 SOURCE。
三、@Target 和 @Inherited------约束与传递
疑问:为什么我的注解加在方法上编译报错?子类能继承父类的注解吗?
回答:
@Target 用 ElementType 枚举限制注解可修饰的目标。如果你的注解定义为 @Target(ElementType.TYPE),却用在方法上,编译器直接拒绝。
java
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Custom { }
@Inherited 是一个纯标记元注解,表示该注解会被子类自动继承 。但仅适用于类上的注解 ,对方法、字段无效。例如 @Transactional 的传播分析就用到该特性。
java
@Inherited
@Retention(RUNTIME)
@Target(TYPE)
public @interface MyAnnotation {}
@MyAnnotation
class Parent {}
class Child extends Parent {} // Child也会被"视作"拥有@MyAnnotation
四、APT------编译期的"代码魔术师"
疑问:什么是APT?它怎么在编译期生成代码?
回答: APT(Annotation Processing Tool),从JDK 6起集成在 javac 中,允许我们在编译期扫描指定的注解,并生成新的Java源文件。整个过程不修改原有代码,而是创建新的类参与后续编译。
机制:
javac在编译时启动一轮处理- 注解处理器扫描所有类上的指定注解
- 通过
javax.annotation.processing包下的 API 读取元信息 - 用
Filer创建新的.java文件 - 重新编译,新生成代码与原有代码一起输出
class
关键接口:
AbstractProcessor------ 实现核心逻辑ProcessingEnvironment------ 获取Filer、Messager、Elements、Types等工具RoundEnvironment------ 获取本轮处理的注解实例
五、手写一个Builder注解处理器(实战)
需求: 给类打上 @Builder 注解,自动生成 *Builder 类,实现建造者模式。
第一步:定义注解
java
@Retention(CLASS)
@Target(TYPE)
public @interface Builder { }
第二步:实现处理器
java
@SupportedAnnotationTypes("com.example.Builder")
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (Element e : roundEnv.getElementsAnnotatedWith(Builder.class)) {
TypeElement clazz = (TypeElement) e;
// 获取字段(略,含过滤静态、final)
List<VariableElement> fields = ...;
// 用Filer创建源文件
String builderName = clazz.getSimpleName() + "Builder";
// 拼接Builder源代码字符串(略)
// 写入 javaFile
}
return true;
}
}
关键步骤:
- 用
Element.getEnclosedElements()遍历字段 - 用
Filer.createSourceFile()生成新类 - 新类中为每个字段生成
setXxx()方法,返回this - 最终
build()方法:return new Target(xxx);
效果 :编译后自动生成 TargetBuilder 类,零反射开销。
六、Lombok 是怎么工作的?它的魔法有限制吗?
疑问:@Data 只一行注解,getter/setter/toString 等就全有了,它改变了字节码?
回答: Lombok 不是在运行时通过反射注入方法,而是在编译期直接修改抽象语法树(AST),将方法直接织入原类。
原理:
javac将源码解析成 AST- Lombok注册的
AnnotationProcessor在特定阶段访问 AST - 找到
@Data标记的类,直接在 AST 节点上添加方法节点 - 后续编译基于改造后的 AST 生成
class,所以 getter/setter 已"原生"存在于字节码
局限:
- 强依赖编译器和IDE支持,升级可能不兼容
- AST 修改属于内部API,可能在JDK新版被禁用
- 增量编译场景下易出问题
- 不建议在核心模块中使用(代码所有权模糊)
七、运行时注解 vs 编译时注解:我们该怎么选?
| 维度 | 运行时注解(RUNTIME) | 编译时注解(CLASS/SOURCE) |
|---|---|---|
| 实现方式 | 反射获取,动态处理 | APT生成新类,编译期织入 |
| 性能 | 反射有开销 | 无额外开销,纯编译器工作 |
| 适用场景 | 框架配置(Spring、JUnit) | 代码生成(Lombok、MapStruct) |
| 灵活性 | 高,动态处理 | 低,需提前生成 |
结论 :需要运行时动态处理用 RUNTIME,追求零性能开销、可生成代码用 APT。
总结
- 注解本质是接口,属性被限制为可常量化类型,以保证字节码存储。
@Retention决定注解在 SOURCE / CLASS / RUNTIME 哪一层存活,@Target约束修饰目标。- APT 利用
AbstractProcessor在编译期扫描注解、生成新源文件,实现代码"无感注入"。 - Lombok 更进一步,通过修改 AST 在不改变源码的情况下插入方法,但依赖编译器内部API。
- 选择运行时注解还是编译时注解,取决于性能需求和动态性需求。
至此,本系列文章完结,如果你觉得本文有帮助,欢迎点赞、评论、转发!