Java 注解处理器
Java 注解处理器(Annotation Processor)在编译阶段读取类型和注解,用于校验程序结构或生成文件。生成结果可以是 Java 源码、字节码或资源文件。本文先用运行时扫描说明框架的发现过程,再介绍处理轮次、代码生成与构建配置,最后比较两种方案的取舍。
1. 面试核心
先记住这几条,再读正文- 处理器职责。 注解处理器在编译期读取
Element和TypeMirror,通过Messager报错或通过Filer生成文件。卡 · 注解处理模型 - 处理轮次。 处理器生成的新类型会进入后续处理轮次,生成普通资源文件则不会触发新轮次。
- 注解认领。
process返回true表示认领传入的注解类型,使后续处理器跳过这些类型。 - 处理器发现。 Java 17、21 的
javac默认搜索注解处理器,未指定处理器路径时会搜索普通 classpath。卡 · 处理器发现与认领规则 - IoC 与反射。 IoC 容器管理对象创建、依赖注入和生命周期,反射为它提供运行时检查和调用能力。卡 · 运行时扫描
2. 运行时扫描
运行时扫描让框架在启动时发现应用提供的类,注解处理器可以把部分发现工作提前到编译阶段。以注册路由为例,框架会完成以下类型发现与注册流程:
- 确定需要扫描的包、模块或 classpath 位置。
- 找出候选类并通过类加载器加载。
- 使用反射读取类和方法上的运行时注解。
- 校验方法签名、访问权限和配置冲突。
- 创建对象并把调用信息放入注册表。
for (Class<?> candidate : scanner.findClasses("com.example")) {
for (Method method : candidate.getDeclaredMethods()) {
Route route = method.getAnnotation(Route.class);
if (route != null) {
registry.register(route.value(), candidate, method);
}
}
}
示例把标有 Route 的方法和对应路径放入注册表,供后续请求查找。应用把新类放进约定的包并添加注解后,框架就能在下一次启动时发现它。因此,应用无需另外维护候选类清单。
2.1 运行时扫描的成本
扫描的灵活性来自运行时发现,相关工作也要在应用运行时完成。评估这种方案时,需要考虑以下启动与兼容成本:
- 启动时需要枚举资源、加载类并解析注解。
- 签名错误和重复配置直到启动才出现。
- classpath、模块和自定义类加载器会增加发现难度。
- 反射结果需要缓存,避免进入请求热路径后重复查找。
- 闭世界编译要求在构建时确定可达代码,因此动态反射目标需要额外元数据。
扫描几个明确的类,与遍历所有依赖包,成本差别很大。所以,应先缩小候选范围,再测量编译期生成能否进一步降低启动耗时。
3. 注解处理模型
标准注解处理 API 让处理器直接检查编译器中的程序声明。Element 表示类、方法或字段等声明,TypeMirror 表示这些声明涉及的类型。它们共同构成语言模型,所以处理器可以检查业务类,无需加载并执行业务代码。
@SupportedAnnotationTypes("com.example.GenerateMapper")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public final class MapperProcessor extends AbstractProcessor {
@Override
public boolean process(
Set<? extends TypeElement> annotations,
RoundEnvironment roundEnvironment
) {
if (roundEnvironment.processingOver()) {
return false;
}
TypeElement marker = processingEnv
.getElementUtils()
.getTypeElement("com.example.GenerateMapper");
if (marker == null) {
return false;
}
for (Element element : roundEnvironment.getElementsAnnotatedWith(marker)) {
generateMapper((TypeElement) element);
}
return true;
}
}
示例声明了要处理的注解名称和支持的源码版本,再从当前轮次取得被标注的元素。generateMapper 表示生成映射器的逻辑,具体文件生成方式见下一节。
处理器由编译器调用。构建时需要提供处理器及其依赖,并配置注解处理器路径或显式指定处理器。业务代码使用的是生成结果,无需手动创建处理器实例。
3.1 多轮处理
处理器生成新源码或 class 文件后,新类型会在后续轮次进入语言模型。这样,另一个处理器还能继续处理新类型上的注解。没有新的类型产生时,编译器进入最后一轮,随后继续完成编译。仅生成普通资源文件不会触发新轮次。
最后一轮适合做汇总和诊断。为保证多轮处理稳定结束,处理器需要遵守以下约定:
- 能识别已经处理过的元素,避免重复生成。
- 不依赖多个处理器之间的执行顺序。
- 在最后一轮停止生成新的类型。
- 对相同输入产生稳定结果,便于增量构建和缓存。
RoundEnvironment.processingOver() 可以判断是否进入最后一轮,errorRaised() 可以判断此前是否报告过错误。
4. 代码生成
代码生成把重复的实现工作交给处理器。比如,业务只声明对象之间的映射接口,处理器再生成字段复制代码:
@GenerateMapper
public interface UserMapper {
UserDto toDto(User source);
}
处理器可以通过 Filer 生成实现类:
JavaFileObject file = processingEnv
.getFiler()
.createSourceFile("com.example.UserMapperGenerated", sourceElement);
try (Writer writer = file.openWriter()) {
writer.write("""
package com.example;
public final class UserMapperGenerated implements UserMapper {
@Override
public UserDto toDto(User source) {
return new UserDto(source.id(), source.name());
}
}
""");
}
生成类随后与业务源码一起编译。运行时调用它,与调用普通手写类相同:
UserMapper mapper = new UserMapperGenerated();
UserDto dto = mapper.toDto(user);
标准处理器可以生成新的源码、class 文件或资源文件,已有业务源码保持不变。Lombok 这类工具还会修改编译器内部的语法树,即编译器表示源码结构的数据。此类做法依赖编译器内部 API,因此升级 JDK 和 IDE 时需要额外验证兼容性。
4.1 编译期报错
处理器可以使用 Messager 把错误定位到具体程序元素:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"mapper method must have exactly one parameter",
methodElement
);
Diagnostic.Kind.ERROR 会让构建失败,并把错误关联到 methodElement 对应的方法。开发者因此能在编译阶段看到不符合约定的签名,避免等到启动或处理请求时才发现问题。
5. 运行时扫描与注解处理器的比较
两种方案的主要差别,是何时发现类型、校验约束和准备调用代码。选择时可以对照下表,判断应用需要多少运行时动态性。
| 维度 | 运行时扫描 | 编译期生成 |
|---|---|---|
| 发现时间 | 应用启动或首次使用 | 源码编译时 |
| 错误出现时间 | 启动或运行时 | 构建时 |
| 调用方式 | 常用反射、代理或动态分派 | 生成的普通类型和直接调用 |
| 扩展方式 | 放入运行时 classpath 即可发现 | 需要在编译阶段看到扩展源码或元数据 |
| 启动成本 | 扫描范围越大,成本越高 | 工作移到构建阶段 |
| 动态性 | 更适合运行时插件和未知类型 | 更适合闭世界和固定应用组成 |
| 调试重点 | 类加载、扫描条件、反射权限 | 生成源码、构建配置、处理轮次 |
编译期生成把部分运行工作移到构建阶段,也增加了构建维护成本。项目需要管理处理器模块和生成文件,还要让 IDE 集成与增量编译正常工作。因此,降低启动耗时的同时,也要评估开发和构建流程的复杂度。
6. 混合方案
同一个应用中,一部分类型在构建时已经确定,另一部分来自运行时插件。框架可以按这条边界组合两种方案,下面介绍三种常见做法。
6.1 编译期索引与运行时装配
候选类型已知、对象仍要在运行时创建时,可以先生成索引。处理器把候选类型写进清单或资源文件,运行时读取索引后只加载这些类型。这样省去了遍历整个 classpath 的工作,也无需生成完整调用代码。
6.2 构建分析与运行时扩展
应用自身的类型可以提前分析,第三方插件则保留运行时发现。比如,在构建时固定容器管理的对象(Bean)、路由和序列化器,同时用 ServiceLoader 或指定目录加载插件。ServiceLoader 会按服务声明查找实现,因此能保留明确的扩展入口,同时减少常规启动扫描。
6.3 生成工厂与运行时选择
候选实现已知、运行时才决定使用哪一个时,可以生成类型安全的工厂或注册表。应用启动后根据配置选择实现,候选范围由生成结果限定。这样仍保留动态选择,也省去了动态扫描。
选择方案时,先区分部署前已知的类型与运行中才加入的类型。前者提前处理能保留静态检查,后者保留发现入口能让插件继续扩展。把真实插件全部固定在构建阶段,会让新增插件也需要重新构建应用。
7. Native Image 与 AOT
AOT 指提前编译,Native Image 则会在构建时分析程序并生成原生可执行文件。它采用闭世界假设:构建时需要知道运行中会用到哪些代码。
按字符串加载类、反射访问成员和动态代理,其目标有时无法从静态调用关系推导。因此,需要显式注册目标,或让框架生成描述这些目标的元数据。提前完成以下代码生成与注册检查,有助于减少运行时配置:
- 根据注解生成直接调用代码。
- 生成序列化器、映射器和依赖注入工厂。
- 为仍需反射的成员生成精确注册元数据。
- 在构建阶段检查动态代理接口和资源文件。
长期运行的 JVM 服务如果对启动耗时不敏感,又依赖运行时插件,成熟的运行时模型仍然适用。是否采用 AOT,应同时考虑启动收益、构建成本和部署方式。
8. 处理器发现与认领规则
处理器写好后,还要让编译器找到并执行它。Java 17、21 的 javac 默认搜索注解处理器。未指定处理器路径时,会搜索普通 classpath,设置 -proc:none 则禁用注解处理。也可以显式指定处理器与路径:
javac --processor-path processor.jar \
-processor com.example.MapperProcessor \
-d out -s generated src/com/example/UserMapper.java
命令中的 -d out 指定 class 输出目录,-s generated 指定生成源码的目录。--processor-path 提供处理器路径,-processor 指定要运行的处理器类。
自动发现使用服务配置或模块的 provides 声明。classpath 服务文件名为 META-INF/services/javax.annotation.processing.Processor。文件内容是处理器的完整类名。
process 返回 true 表示认领传入的注解类型。后续处理器不再收到这些已认领类型。返回 false 允许后续处理器继续处理。该返回值与编译成功或失败无关。
一次编译的多轮处理过程中,重复创建同名文件会抛出 FilerException。所以,生成器需要记录已经生成的类型,避免后续轮次再次创建。
9. 常见问题
9.1 标准注解处理器能修改原有类吗?
标准 API 不提供修改既有语法树(AST)的可移植接口。它支持读取语言模型、生成新文件和报告诊断。依赖 javac 内部 API 修改语法树,会增加编译器、JDK 和 IDE 的兼容成本。
9.2 为什么当前轮读不到刚生成的类型?
因为 Filer 创建的新源码要在后续轮次才进入语言模型。生成文件后,处理器需要等待下一轮再查询完整的新类型。编译器会继续处理和编译这些文件,处理器无需手动编译。
9.3 编译期生成一定比反射快吗?
是否更快取决于实际执行路径,需要测量。生成代码可以省去运行时成员发现,直接调用也更容易优化。代价则包括构建时间、生成代码体积与生成器维护。
低频管理工具的迁移收益有限,高频序列化和函数调用路径更值得测量。评估时还要结合启动频率,区分节省的是启动工作还是每次调用的开销。
9.4 生成代码应该提交到 Git 吗?
可重复生成的文件应放入构建目录,由构建产生,不提交 Git。这样能避免手写源码已经变化,而仓库中的生成结果仍是旧版本。
目标环境无法运行生成器,或需要把生成结果作为稳定源码发布时,可以提交生成文件。这时应增加一致性校验,确认生成结果与输入源码匹配。
10 · 面试题卡片
10.1 怎样实现一个编译期注解
出现公司:飞书
考察重点
- 说明注解保留策略与编译期处理之间的关系。
- 解释
Processor、RoundEnvironment、Filer和Messager怎样配合检查与生成代码。 - 说明如何处理新类型的后续轮次、避免重复生成,并报告编译错误。
参考回答
编译期注解需要定义标记注解,并提供由编译器执行的处理器。
只在编译期使用的标记可以选择 SOURCE 保留策略,处理器则实现 Processor 或继承 AbstractProcessor。
编译器按轮次调用处理器,处理器通过 RoundEnvironment 取得带注解的元素。随后读取 Element、TypeMirror 和元素工具,检查类型与方法签名。校验失败时,通过 Messager 报告编译错误。
需要生成代码时,通过 Filer 创建新源码或资源。新类型进入后续轮次,所以要记录生成结果,避免重复创建。多个处理器配合时,生成逻辑也应独立于它们的执行顺序。构建还要提供处理器路径,确保编译器能发现或显式运行处理器。
10.2 Java 为什么可以实现 IoC,反射在其中做了什么
出现公司:网易
考察重点
- 解释容器怎样结合配置、类加载与反射发现用户类型。
- 说明容器怎样创建对象、注入依赖,并保存对象供调用方获取。
- 根据类型何时确定,判断采用运行时发现还是编译期生成。
相关内容:运行时扫描、运行时扫描与注解处理器的比较。
参考回答
Java 的类加载和反射能力,让 IoC 容器能在运行时发现用户类型并创建对象。
IoC 指控制反转,也就是把对象创建与依赖装配交给容器管理。Spring 等容器中的受管对象称为 Bean。
容器从配置、扫描范围或显式注册取得候选类型,再读取构造器、字段、方法和注解。它据此创建对象、解析依赖并完成注入,随后把对象放进注册表。调用方获取容器提供的实例,就能使用已装配的对象。
反射提供运行时检查与调用能力,对象生命周期、依赖解析和错误处理规则仍由容器实现。应用组成在构建时已确定时,可以提前生成部分发现和调用代码。部署后还要安装插件时,则保留明确的运行时发现入口。
10.3 Java 17、21 如何发现处理器,process 返回值表示什么?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明编译器如何找到处理器、process 返回值如何影响后续处理,以及怎样避免重复生成。
相关内容:处理器发现与认领规则。
参考回答
Java 17、21 的 javac 默认通过服务配置发现处理器,process 返回 true 表示认领传入的注解类型。
未指定处理器路径时,编译器会搜索普通 classpath。也可用 -processor 和 --processor-path 显式指定处理器与路径,用 -proc:none 禁用处理。
认领后,后续处理器跳过这些注解类型;返回 false 则允许继续处理。返回值不表示编译是否成功,编译错误应通过 Messager 报告。新类型会进入后续轮次,因此要记录已生成类型,避免重复创建同名文件引发 FilerException。