第七篇:注解与APT深度解析——从@Override到Lombok的底层原理

  1. 第一篇:Java基础概念四连问,==与equals、hashCode约定、接口vs抽象类、深拷贝vs浅拷贝
  2. 第二篇:String、StringBuilder、StringBuffer深度剖析
  3. 第三篇:泛型深度解析------类型擦除与通配符的奥秘
  4. 第四篇:反射与动态代理------Java语言动态性的核心
  5. 第五篇:异常处理机制与最佳实践
  6. 第六篇:HashMap源码深度剖析------从JDK 7到JDK 8的演进
  7. 第七篇:注解与APT深度解析------从@Override到Lombok的底层原理

在《反射与动态代理》中我们体验了运行时动态操作类的强大。但有一种技术,它不需要运行时反射 ,就能在编译期自动生成代码,改变程序行为------这就是注解与APT(注解处理器)@Override@Autowired@Getter这些注解到底做了什么?为何Lombok只用@Data就能消灭样板代码?Java注解为什么有的只在源码有效、有的能保留到运行时?这些问题,需要从注解的本质和编译期处理机制中寻找答案。

本文核心问题:

  1. 注解到底是什么?它是不是接口?
  2. @Retention三种保留策略的底层区别是什么?
  3. 元注解 @Target@Inherited 是怎样控制注解行为的?
  4. 注解的属性为什么只能用某些特定类型?
  5. 什么是 APT?注解处理器如何在编译期"篡改"代码?
  6. 实战:手写一个能生成Builder代码的注解处理器。
  7. 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();
}

这也解释了:注解里只能定义方法(属性),不能有字段、构造器 ,因为它是个接口。同时,注解的方法不能有参数,不能抛异常,返回值类型被严格限制为:基本类型、StringClass、枚举、注解,及上述类型的数组。原因在于:JVM规范为注解设计了一套**"常量化"存储方案**,值的表示必须能写入常量池。

java 复制代码
public @interface Demo {
    int a();                       // ✔ 基本类型
    String b();                    // ✔ String
    Class<?> c();                  // ✔ Class
    RetentionPolicy d();           // ✔ 枚举
    Override e();                  // ✔ 注解
    int[] f();                     // ✔ 数组
    Object g();                    // ✘ 编译错误
}

二、三大保留策略:你的注解能活到何时?

疑问:@RetentionSOURCECLASSRUNTIME 有什么区别?各自用在什么场景?
回答: 注解的生命周期由 @Retention 决定,分别对应三个阶段的生存:

策略 存活范围 场景举例
SOURCE 仅源码,编译时丢弃 @Override@SuppressWarnings
CLASS 保留到字节码,但JVM不加载 编译期代码生成(Lombok)
RUNTIME 保留到运行时,反射可读取 @Autowired@RequestMapping

底层原理

  • SOURCE → 编译器检查即丢弃,不写入 .class。所以 @Overridejavap 里完全看不见。
  • CLASS → 存于字节码的 RuntimeVisibleAnnotations 属性集吗?不,CLASSRuntimeInvisibleAnnotations,JVM不会加载到内存中,反射API无法获取,但编译期APT可以读到,这就是Lombok的命脉。
  • RUNTIME → 存入 RuntimeVisibleAnnotations,JVM解析并放进方法区的注解数据结构中,反射能访问。

因此,想做编译时代码生成,用 CLASS;想在运行时通过反射读取,用 RUNTIME;仅做编译期检查用 SOURCE。


三、@Target@Inherited------约束与传递

疑问:为什么我的注解加在方法上编译报错?子类能继承父类的注解吗?
回答:
@TargetElementType 枚举限制注解可修饰的目标。如果你的注解定义为 @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源文件。整个过程不修改原有代码,而是创建新的类参与后续编译。

机制

  1. javac 在编译时启动一轮处理
  2. 注解处理器扫描所有类上的指定注解
  3. 通过 javax.annotation.processing 包下的 API 读取元信息
  4. Filer 创建新的 .java 文件
  5. 重新编译,新生成代码与原有代码一起输出 class

关键接口

  • AbstractProcessor ------ 实现核心逻辑
  • ProcessingEnvironment ------ 获取 FilerMessagerElementsTypes 等工具
  • 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),将方法直接织入原类。

原理

  1. javac 将源码解析成 AST
  2. Lombok注册的 AnnotationProcessor 在特定阶段访问 AST
  3. 找到 @Data 标记的类,直接在 AST 节点上添加方法节点
  4. 后续编译基于改造后的 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。
  • 选择运行时注解还是编译时注解,取决于性能需求和动态性需求。

至此,本系列文章完结,如果你觉得本文有帮助,欢迎点赞、评论、转发!

相关推荐
千寻girling3 小时前
五一劳动节快乐 [特殊字符][特殊字符][特殊字符]
java·c++·git·python·学习·github·php
计算机安禾3 小时前
【Linux从入门到精通】第47篇:SystemTap与eBPF——Linux内核观测的显微镜
java·linux·前端
user_admin_god3 小时前
企业级-实践-流式接口-TEXT_EVENT_STREAM_VALUE
java
庞轩px3 小时前
第1篇:Java内存模型(JMM)与volatile——并发编程的基石
java
是宇写的啊3 小时前
MyBatis-Plus
java·开发语言·mybatis
SamDeepThinking4 小时前
如何让订单系统和营销系统解耦
java·后端·架构
消失的旧时光-19434 小时前
线程池解决了什么?为什么还不够?(从线程到协程 · 第2篇)
java·大数据·数据库
jay神4 小时前
基于团队模式的C程序设计课程辅助教学管理系统
java·spring boot·vue·web开发·管理系统
薪火铺子4 小时前
Shiro权限框架深度解析
java·后端