Java 编译期优化实战——Lombok、MapStruct 背后的注解处理器原理
Java 编译期优化实战——Lombok、MapStruct 背后的注解处理器原理
适读人群:日常使用 Lombok 或 MapStruct 的 Java 开发者,想深入了解"魔法"背后的机制 | 阅读时长:约 14 分钟 | 核心价值:搞清楚注解处理器的运行原理,不再对 Lombok 等工具抱有神秘感,会排查相关编译问题
我用 Lombok 大概用了 5 年,一直以为它是某种字节码增强技术,类似于 ASM 在运行时动态修改字节码。
直到 2022 年有个同事问我:"Lombok 处理是在编译时还是运行时?"
我竟然答不上来。
然后我认真查了一遍,发现我对这个用了 5 年的工具的理解,其实是错的。这让我有点惭愧,也让我意识到,工具用着方便,但原理还是要懂。
注解处理器:发生在编译阶段的代码生成
Java 的编译流程大致是:
.java 源文件 → javac 编译 → .class 字节码注解处理器(Annotation Processor)插入在这个流程里:
.java 源文件
→ javac 读取 → 注解处理器介入 → 生成新的 .java 文件或修改 AST
→ javac 再次编译(包含生成的代码)
→ .class 字节码关键点:注解处理器在编译期运行,生成的代码是真实的 Java 代码,最终编译成 .class 文件。运行时没有任何额外开销。
这就是为什么 Lombok 的 @Getter、@Setter、@ToString 等注解生成的方法,在 IDE 里可以被直接调用(IDE 支持 Lombok 插件的情况下),因为这些方法在编译后真实存在于 .class 文件里。
Lombok 的特殊之处:它修改了 AST,不只是生成代码
MapStruct、QueryDSL 等标准的注解处理器走的是"生成新 Java 文件"的路线,这是 Java 注解处理规范允许的。
Lombok 走的是另一条路:直接修改 javac 的 AST(抽象语法树)。
这不在标准规范里,Lombok 利用了 javac 内部 API(com.sun.tools.javac.*)直接在原有类的 AST 上添加方法节点。这就是为什么 Lombok 的 @Getter 不需要生成一个新的 Java 文件,它直接把 getName() 方法"注入"到你的类里。
这种做法的代价是:Lombok 和 JDK 版本深度绑定,JDK 大版本升级时内部 API 变化,Lombok 有时候需要同步更新,不然编译失败。这也是一些团队不用 Lombok 的原因之一。
自己写一个简单的注解处理器
理解原理最好的方式是自己写一个。我写一个简单的注解处理器,功能是:给标注了 @AutoToString 的类,自动生成 toString() 方法的一段信息。
步骤一:定义注解
package com.example.apt;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE) // 只在源码阶段保留,不打包到 .class
public @interface AutoToString {
}步骤二:实现注解处理器
package com.example.apt;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 注解处理器:为 @AutoToString 标注的类生成一个辅助类
* 包含 toString 信息
* 注意:标准 APT 只能生成新类,不能修改已有类(不像 Lombok 那样)
*/
@SupportedAnnotationTypes("com.example.apt.AutoToString")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@AutoService(Processor.class) // 需要 google auto-service,自动注册处理器
public class AutoToStringProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : elements) {
if (element.getKind() == ElementKind.CLASS) {
TypeElement classElement = (TypeElement) element;
try {
generateToStringHelper(classElement);
} catch (IOException e) {
processingEnv.getMessager().printMessage(
javax.tools.Diagnostic.Kind.ERROR,
"生成失败: " + e.getMessage(),
element
);
}
}
}
}
return true; // 返回 true 表示本处理器消费了这些注解,不传递给其他处理器
}
private void generateToStringHelper(TypeElement classElement) throws IOException {
String className = classElement.getSimpleName().toString();
String packageName = processingEnv.getElementUtils()
.getPackageOf(classElement).toString();
String helperClassName = className + "ToStringHelper";
String fullClassName = packageName + "." + helperClassName;
// 获取所有字段名
List<String> fieldNames = classElement.getEnclosedElements().stream()
.filter(e -> e.getKind() == ElementKind.FIELD)
.map(e -> e.getSimpleName().toString())
.collect(Collectors.toList());
// 生成辅助类文件
JavaFileObject file = processingEnv.getFiler().createSourceFile(fullClassName);
try (PrintWriter writer = new PrintWriter(file.openWriter())) {
writer.println("package " + packageName + ";");
writer.println();
writer.println("// 由 AutoToStringProcessor 自动生成,请勿手动修改");
writer.println("public class " + helperClassName + " {");
writer.println(" public static String toString(" + className + " obj) {");
writer.print(" return \"" + className + "{");
writer.print(fieldNames.stream()
.map(f -> f + "=\" + obj.get" + capitalize(f) + "() + \"")
.collect(Collectors.joining(", ")));
writer.println("}\";");
writer.println(" }");
writer.println("}");
}
}
private String capitalize(String s) {
return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
}这个处理器会为 @AutoToString 标注的类生成一个 XxxToStringHelper 辅助类。
MapStruct:纯粹的代码生成,没有运行时开销
MapStruct 是我认为写得最好的注解处理器之一。它生成的代码是纯 Java,没有任何反射,比 BeanUtils、ModelMapper 快得多。
package com.example.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
// 字段名相同,自动映射;字段名不同,用 @Mapping 指定
@Mapping(source = "userId", target = "id")
@Mapping(source = "fullName", target = "name")
UserDTO toDTO(UserEntity entity);
UserEntity toEntity(UserDTO dto);
}MapStruct 会在编译时生成这样的实现类:
// MapStruct 生成的代码(真实代码,存在于 target/generated-sources 里)
public class UserMapperImpl implements UserMapper {
@Override
public UserDTO toDTO(UserEntity entity) {
if (entity == null) return null;
UserDTO userDTO = new UserDTO();
// 直接用 get/set,没有反射,没有运行时开销
userDTO.setId(entity.getUserId());
userDTO.setName(entity.getFullName());
// ... 其他字段
return userDTO;
}
}踩坑实录一:Lombok 和 MapStruct 同时用,注解处理器顺序有坑
Lombok 需要先于 MapStruct 执行,因为 MapStruct 要调用 Lombok 生成的 getter/setter。如果顺序反了,MapStruct 看不到这些方法,生成的代码会有编译错误。
Maven 配置时需要保证顺序:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- Lombok 必须在 MapStruct 之前 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>踩坑实录二:Lombok 的 @Data 在 JPA Entity 上用是个坑
@Data 包含了 @EqualsAndHashCode,默认会用所有非 static、非 transient 字段生成 equals() 和 hashCode()。
JPA Entity 不能这样:
- 如果包含关联对象(
@OneToMany等),Hibernate 懒加载时equals()会触发加载,可能引发 N+1 或循环调用 - 生成的
hashCode()依赖所有字段,Entity 保存前后 hashCode 会变(id 从 null 变成数据库生成的值),如果放在 HashSet 里就找不到了
我建议:JPA Entity 不要用 @Data,用 @Getter @Setter,手动写或者用 IDE 生成 equals() 和 hashCode()(只用 id 字段)。
踩坑实录三:注解处理器生成的代码没有 clean 就编译,出现奇怪错误
注解处理器生成的代码放在 target/generated-sources 里。如果你改了注解定义或处理器逻辑,但没有 mvn clean,可能会残留旧的生成代码,导致编译出现奇怪的"类重复定义"或方法签名不匹配的错误。
遇到这种错误,先 mvn clean compile,大多数情况都能解决。
这不是 bug,是编译增量编译机制和代码生成组合时的正常摩擦,了解了原理就不会懵了。
什么时候值得写自己的注解处理器
- 团队有大量重复的样板代码,而且模式很固定(比如每个 Service 类都要生成一个相同结构的 API 文档类)
- 编译期校验(比如强制检查某个注解的属性值合法性)
不值得的情况:
- 代码生成量不大,手写或模板也能搞定
- 逻辑复杂到注解处理器里要写几百行(说明设计有问题)
注解处理器是一个强大但相对小众的工具,知道它的原理,对你理解 Lombok 的限制、MapStruct 的优势、各种代码生成框架的工作方式都有帮助。
