简介:围绕Lombok实现原理的代码示例工程,面向需要深入理解Java编译期注解处理与字节码操作的开发人员。整套代码以Maven多模块方式组织,共40个文件、约88KB,包含10个class(编译产物)、9个java(源码)、5个lst、4个xml(工程配置)、4个iml(模块描述)、2个bpmn与2个dmn(流程及决策模型),以及jar、properties、processor等辅助文件,清晰覆盖了注解定义、处理器实现、测试验证与流程集成等多个阶段。通过研读这些代码,可以学习Lombok如何利用JSR 269注解处理器在编译时解析注解,借助ASM库在字节码层面插入getter/setter等方法;同时资源中提供的自定义注解处理器示例和Camunda流程模型,能帮助开发者理解如何扩展生成逻辑并应对实际集成场景。已有665人学习下载,适合作为Java开发者研读Lombok核心机制及自定义注解处理器实践的入门参考。
1. 为什么说Lombok是个“编译期魔法”:一个注解如何变成getter/setter
Lombok 这个 Java 插件,第一次用的时候我以为它就是个帮人少敲样板代码的工具,直到我把它的源码拆开才意识到,它干的事情远比“生成 getter/setter”激进得多——它直接在 javac 编译阶段改写了 Java 的抽象语法树(AST),把已经解析好的类再加工一遍,产出新的语法节点后才交给编译器继续处理。这意味着 Lombok 不是运行时反射,也不是普通的注解处理器去生成额外源文件,而是把整个编译过程当成了一个可插拔的流水线。想改编译行为的库不少,但像它这样把 Java 内部 API 用到极致的几乎没有。这篇笔记就从头讲一遍我拆解它的过程,从注解处理器启动,到 AST 改写,再到仿写一个 @Builder 处理器,最后是那些坑。
2. 源码架构拆解:注解处理器从SPI注册到AST改写的完整链路
2.1 先看目录结构:Lombok的源码包怎么组织
我拿到的源码包是一个标准的 Maven 多模块工程,命名和功能划分非常清楚。核心模块负责注解处理器的启动、内部 API 封装和 handler 分发,测试模块则针对每个注解写了大量编译期断言,比如检查生成的字节码里是否存在某个方法。整个源码树的大致布局是这样的:
lombok/ ├── src/main/java/ │ ├── lombok/ │ │ ├── core/ // 注解处理器核心入口 │ │ ├── javac/ // javac 相关 handler │ │ ├── eclipse/ // eclipse 编译器相关 handler │ │ ├── eclipseAgent/ // eclipse 的 agent 支持 │ │ └── launch/ // 隔离类加载相关 ├── src/test/java/ │ ├── lombok/ // 测试用例,每个注解都有对应测试 │ └── ... └── build.xml / pom.xml // 构建脚本这里最值得留意的是它同时维护了 javac 和 eclipse 两套 handler。JDK 编译器只是 Java 生态里的一路,Eclipse 用的是一套完全不同的编译器实现,Lombok 为了“两头通吃”,在核心层做了抽象,把注解处理的公共逻辑抽出来,再为每种编译器写适配器。这个设计和很多只支持 javac 的注解处理库拉开了差距。
2.2 从SPI注册到AnnotationProcessorHider:处理器怎么被发现的
Java 注解处理器的标准启动方式是通过META-INF/services/javax.annotation.processing.Processor文件注册,javac 在编译时会读取这个文件,加载里面列出的处理器类。Lombok 的特别之处在于,这个文件里注册的类只是一个“空壳转发器”,真正的处理器逻辑被藏在了内部类里。
# META-INF/services/javax.annotation.processing.Processor lombok.launch.AnnotationProcessorHider$AnnotationProcessor之所以要绕这么一圈,是因为如果直接注册 Lombok 的主处理类,某些 IDE 的注解处理开关可能会因为扫描到它而触发额外的类加载,导致用户工程还没开始编译就先报错。藏一层之后,IDE 和 javac 看到的只是一个干净的入口,复杂的加载逻辑全在AnnotationProcessorHider内部完成。
AnnotationProcessorHider$AnnotationProcessor的process方法里真正干的活是:收集当前编译轮的注解信息,再交给JavacHandler去匹配对应的 handler。这里有个容易忽略的细节——标准注解处理机制规定处理器是在“某一轮”拿到注解的,被处理过的注解如果不再生成新源文件,后续轮次不会再收到。Lombok 的策略是头一轮就把所有需要的注解都消费掉,同时通过Generated机制告诉编译器“我已经处理完了”,避免重复处理。
2.3 核心handler操作AST:TreeMaker与JCTree的配合
Lombok 处理注解的“原子操作”是拿到一个JCTree.JCCompilationUnit(编译单元的语法树),然后对树上的节点做增删改。javac 的语法树节点都是只读语义,想安全修改必须通过TreeMaker这个工厂类来创建新节点,再用TreeScanner或TreeTranslator去遍历和替换。
// 简化版的 HandleGetter 处理逻辑,仅示意核心链路 public class HandleGetter { public void handle(AnnotationValues<Getter> annotation, JCTree.JCClassDecl classDecl, JavacResolution.ResolveMirrors mirrors, TreeMaker make) { // 1. 遍历类里的所有字段 for (JCTree tree : classDecl.defs) { if (!(tree instanceof JCTree.JCVariableDecl varDecl)) continue; // 2. 跳过 static 字段,Lombok 默认不给静态字段生成 getter if ((varDecl.mods.flags & Flags.STATIC) != 0) continue; // 3. 根据字段名构建 getter 方法名 String fieldName = varDecl.getName().toString(); String getterName = "get" + capitalize(fieldName); // 4. 用 TreeMaker 创建方法定义节点 JCTree.JCMethodDecl getterMethod = make.MethodDef( make.Modifiers(Flags.PUBLIC), // 访问修饰符 make.Name(getterName), // 方法名 varDecl.vartype, // 返回类型 = 字段类型 List.nil(), // 泛型参数 List.nil(), // 方法参数 List.nil(), // 抛出的异常 make.Block(0L, List.of( make.Return(make.Select(make.Ident(make.Name("this")), varDecl)) )), // 方法体:return this.field; null); // 5. 把新方法插入到类定义的方法列表末尾 classDecl.defs = classDecl.defs.append(getterMethod); } } }这段代码把整个 Lombok 的工作方式说透了:不是靠字符串拼接生成.java源文件,而是直接在编译器的语法树上“种”新方法。方法体里make.Return(make.Select(...))这行是关键,它构造的是return this.field;这个 AST 表达式,而不是写一行源码再重新解析。参数里的TreeMaker是 javac 内部的节点工厂,拿到它之后,方法、字段、表达式都能逐个节点构建出来。
对比一下传统注解处理器:标准的AbstractProcessor只能生成新的源文件或资源文件,不能修改已有类。Lombok 绕过了这个限制,直接调用了 javac 的内部 API,这也是为什么它在每次 JDK 大版本升级时都要跟着修一遍——JDK 内部 API 没有兼容性承诺,一改版就可能编译不过。
2.4 ShadowClassLoader:Lombok的类加载隔离设计
Lombok 的处理器运行时需要依赖它自己编译时的那套类,但注解处理器是跑在 javac 的类加载器里的,如果直接加载,很容易和用户工程的类发生冲突。尤其是用户工程里也有名为lombok.*的类时,谁先加载谁说了算,结果可能完全不可控。
Lombok 的解决方案是自己实现了一个ShadowClassLoader,它不把 Lombok 的类直接交给 javac 的类加载器,而是把 Lombok 的 jar 包当成一个资源库,懒加载地按需取出类。这个类加载器先查自己管理的 jar 包,查不到再委托父加载器,等于把 Lombok 的运行时空间和用户工程隔离开。
这个设计在插件类库里有很强的参考价值。如果你自己写一个需要在 IDE 里作为插件运行的代码生成器,直接依赖宿主类加载器迟早会踩到类冲突的坑,提前做一层隔离能省掉大量排错时间。
3. 核心模块实战:仿写一个@Builder注解处理器
3.1 定义注解与处理器骨架
只看源码不动手,永远觉得自己懂了。我照着 Lombok 的设计思路自己写了一个极简版@Builder注解处理器,过程比预想的曲折——最大的感受是“直接操作 AST”这件事,看起来代码量不多,但要对 javac 的节点模型足够熟,否则很容易构造出语义错误的节点。
先定义注解:
package cn.demo.builder; import java.lang.annotation.ElementType; import java.lang.annotation.Target; @Target(ElementType.TYPE) public @interface Builder { String builderMethodName() default "builder"; String buildMethodName() default "build"; }注解里定义了配置项:生成builder()静态工厂方法的方法名,以及build()终结方法的方法名。这个设计参考了 Lombok 的注解属性风格,给使用者留出配置空间。
3.2 处理器入口与AST准备
处理器继承AbstractProcessor,但做的事和常规注解处理器完全不同——不生成新源文件,只改内存里的语法树。
package cn.demo.builder.processor; import com.sun.tools.javac.api.JavacTrees; import com.sun.tools.javac.code.Flags; import com.sun.tools.javac.tree.JCTree; import com.sun.tools.javac.tree.TreeMaker; import com.sun.tools.javac.tree.TreeTranslator; import com.sun.tools.javac.util.List; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.RoundEnvironment; import javax.annotation.processing.SupportedAnnotationTypes; import javax.annotation.processing.SupportedSourceVersion; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.TypeElement; import java.util.Set; @SupportedAnnotationTypes("cn.demo.builder.Builder") @SupportedSourceVersion(SourceVersion.RELEASE_17) public class BuilderProcessor extends AbstractProcessor { private JavacTrees javacTrees; private TreeMaker make; private com.sun.tools.javac.util.Context context; @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); // 从处理环境中取出 javac 的 Trees 工具 this.javacTrees = JavacTrees.instance(processingEnv); this.context = ((com.sun.tools.javac.processing.JavacProcessingEnvironment) processingEnv).getContext(); this.make = TreeMaker.instance(context); } @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if (roundEnv.processingOver()) return false; for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) { if (!(element instanceof TypeElement typeElement)) continue; // 把 Element 转换成 javac 的语法树节点 JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl) javacTrees.getTree(typeElement); classDecl.accept(new BuilderTranslator(make, typeElement)); } return true; } }这里的init方法里做了一个关键动作:从ProcessingEnvironment强转成JavacProcessingEnvironment,再拿TreeMaker。之所以要强转,是因为 TreeMaker 不是标准 JDK API,它藏在编译器的内部包里。在 JDK 17 以上的环境里,如果没配置--add-exports,这一步会直接抛模块访问异常,这个我在后面专门讲。
3.3 TreeTranslator实现类生成逻辑
真正的生成逻辑放在TreeTranslator的visitClassDef里。这个方法是 javac 语法树遍历的“钩子”,每遇到一个类定义就会回调一次。
public class BuilderTranslator extends TreeTranslator { private final TreeMaker make; private final TypeElement originalType; public BuilderTranslator(TreeMaker make, TypeElement originalType) { this.make = make; this.originalType = originalType; } @Override public void visitClassDef(JCTree.JCClassDecl tree) { super.visitClassDef(tree); // 1. 收集类里的实例字段 java.util.List<JCTree.JCVariableDecl> instanceFields = new java.util.ArrayList<>(); for (JCTree def : tree.defs) { if (def instanceof JCTree.JCVariableDecl varDecl && (varDecl.mods.flags & Flags.STATIC) == 0) { instanceFields.add(varDecl); } } // 2. 创建 Builder 内部类结构 JCTree.JCClassDecl builderClass = makeBuilderClass(tree, instanceFields); tree.defs = tree.defs.append(builderClass); // 3. 给原类添加 builder() 静态方法 JCTree.JCMethodDecl builderMethod = makeBuilderStaticMethod(tree, builderClass); tree.defs = tree.defs.append(builderMethod); } }makeBuilderClass里要生成:一个private T value字段、一个返回this的字段方法、一个build()方法。字段方法接收该字段类型参数并赋值,build()方法创建原始类的实例再赋值返回。这段代码最繁琐的部分是构造new 原类()表达式,需要用到make.NewClass和make.App的嵌套组合,任何一个参数传错,生成出来的代码都会在接下来的编译阶段报类型不匹配。
builder()静态工厂方法相对简单,方法体只有一行return new Builder<>();,但别小看这行,它要求你能正确构造一个带有泛型参数的内部类实例化表达式,泛型类型要传递给 Builder 的类声明节点。对 javac 节点模型不熟的人,第一次写这几个方法基本都会在某一个make.NewClass的泛型参数上传错,我之前就在这里翻过车,后来是在反编译生成的字节码才定位到问题。
3.4 编译与验证:用javap看我们生成的字节码
处理器写完还不能直接断言成功,我用一个测试类验证:
@Builder public class User { private String name; private Integer age; // 没有写任何 getter/setter/builder }编译命令需要额外加几个--add-exports参数,因为处理器用到了jdk.compiler模块的内部包。跑通之后用javap -p User$UserBuilder.class查看生成的 Builder 内部类,能看到完整的name(String)、age(Integer)、build()方法。那一刻才算真正明白 Lombok 的实现原理——原来是往语法树上硬插了这么一段逻辑。
4. 运行与调试:从命令行到IDE,把Lombok跑起来
4.1 命令行编译:一条命令看清处理器的全貌
很多人在 IDE 里用 Lombok 用得顺,但从来没在命令行裸跑过 javac。其实命令行才是最能看清 Lombok 工作机制的方式,因为没有 IDE 帮你隐藏细节。假设你的工程结构如下:
demo/ ├── src/main/java/ │ ├── cn/demo/User.java │ └── lombok.jar // lombok 以 jar 形式放在这里编译命令是:
javac -cp lombok.jar -processorpath lombok.jar -d target/classes src/main/java/cn/demo/User.java这里重点解释两个参数:
-processorpath lombok.jar:指定注解处理器的搜索路径。编译器不会在 classpath 里自动找处理器,必须通过这个参数告诉它去哪里加载服务文件。你也可以不加这个参数,但那样 javac 只会把 Lombok 当成普通依赖,不会触发任何注解处理。-d target/classes:指定字节码输出目录,生成后到该目录下用javap检查。
如果用 Maven 或 Gradle,它们会在编译插件内部自动把lombok的依赖加进annotationProcessorPaths,这就是你写annotationProcessor '...'那行配置的作用。理解这个机制后,再看那些“Lombok 不生效”的帖子,基本一眼就能判断问题出在哪。
4.2 IDE配置:注解处理开关与几个关键参数
IDE 的 Lombok 插件本质上做的事比命令行复杂:它不仅要让 javac 触发处理器,还要让 IDE 自身的代码分析引擎(比如语法高亮、自动补全)也能看到生成的方法。否则编译能过,但编辑器里到处是红色波浪线提示找不到getName()。
在主流 IDE 里配置的要点基本一致:
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| Enable annotation processing | 开启 | 让 IDE 在编译时跑处理器 |
| 处理器路径 | 指向 lombok.jar | 和命令行-processorpath等价 |
| 存储生成的源文件 | 开启 | 方便查看处理器的中间产物 |
| Delegate IDE build to Maven/Gradle | 开启 | 避免两套编译机制冲突 |
把“存储生成的源文件”打开是我强烈建议的做法。IDE 会把注解处理器生成的中间源码写到generated目录里,你能直接看到 Lombok 为你的类生成了什么,这在调试“为什么生成的代码不对”时非常好用——很多问题看一眼生成的源文件就能定位。
4.3 断点调试:让处理器停在我们的handler上
调试 Lombok 处理器比调试普通 Java 代码难一些,因为它的代码不在你的工程里。我在调自己的BuilderProcessor时用的是远程调试方式,思路很简单:给 javac 加上调试参数,让它跑起来后把处理器挂在调试端口上。
javac -J-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000 \ -cp lombok.jar -processorpath lombok.jar \ src/main/java/cn/demo/User.java参数里的-J表示把后面的参数传给启动 javac 的 JVM,suspend=y表示 javac 启动后立刻停下来等调试器接入。然后在 IDE 里配置一个 Remote JVM Debug 连接,端口 8000,源码路径指向你的源码包。断点打在process()方法上,就能一步步看到注解是怎么被分发到 handler 的。
这个调试方式我自己用下来,最大的价值是能看到编译环境的全貌——比如你可以直接在调试器里执行表达式javamask查看当前 JDK 版本对应的内部 API 状态,这对排查“为什么在这个 JDK 版本上 Lombok 不工作”特别有用。
4.4 用javap验证生成结果
不管是在命令行还是 IDE,确认 Lombok 是否生效的最可靠手段都是反编译字节码,而不是看源代码。示例:
javap -p -c target/classes/cn/demo/User.class-p参数会显示所有成员,包括私有的 getter/setter;-c参数会输出每个方法的字节码。如果User.class里有getName()和setName()方法以及对应的aload_0/getfield指令序列,说明处理器正常工作了。如果不加-p,你会发现私有方法被过滤掉,看起来就像 Lombok 没生效,这是我在初期排查时犯过的错。
5. 常见问题与避坑:注解失效、重复生成和JDK兼容性
5.1 运行时抛“找不到getter方法”,但编译期没报错
现象是:编译通过了,代码里也用了user.getName(),但运行时NoSuchMethodError。这个症状迷惑性很强,因为编译期没有报错意味着编译器确实知道这个方法是存在的。
原因:编译和运行用了两套不同的 classpath。编译时target/classes里确实有 Lombok 生成的字节码,但运行时 classpath 里没有把target/classes加进去,或者打出来的 jar 里漏掉了这些类;另一种常见情况是热部署工具用的是旧 class 目录,新编译的类没有被同步进去。
解决:先javap检查生成类是否真的存在,再检查运行时的 classpath 拼接。经验是“编译通过但运行时找不到方法”十有八九不是 Lombok 的问题,而是构建产物没有正确分发。
5.2 升级JDK之后“cannot find symbol”或“module not found”
这是个老生常谈的问题。现象是项目从 JDK 11 升到 JDK 17/21 后,编译时报大量符号找不到的错误,甚至 JDK 内部 API 访问异常。
原因:Lombok 直接使用 javac 内部 API,JDK 9 开始模块化后,com.sun.tools.javac.*默认不开放给外部模块。JDK 11 之前没有强制的模块边界,所以还能用;JDK 17 之后强封装,不显式开放直接拒绝加载。Lombok 主版本跟进有延迟,经常出现“IDE 里能用但命令行编译失败”的错位情况。
解决:如果你是普通用户,升级 Lombok 到支持当前 JDK 的版本;如果你是二次开发者,在 Maven 编译插件里加:
<compilerArgs> <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg> <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg> <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg> </compilerArgs>这个配置的作用是把 javac 内部包对外开放,让处理器能调用到 TreeMaker、JCTree 这些类。
5.3 同一个字段生成了两套setter,代码重复
现象:get前缀的方法出现了两遍,编译时报“方法重复定义”。
原因:注解处理器被配置了多次,或者 IDEA 的注解处理开关和 Maven 编译插件同时跑了处理逻辑。Lombok 默认带幂等保护,但 IDE 和命令行构建工具并行编译,两个编译进程各自跑一遍处理器,最终产物里就可能出现重复。
解决:确认项目里只有一个地方配置注解处理器路径。用了 Maven 或 Gradle 构建的项目,IDE 里把 build 操作委派给对应的构建工具,避免 IDE 自带的 builder 和构建插件各跑各的。还有一个排查技巧:看累计轮次——标准处理器的process方法会在多轮里被调用,若逻辑里没用processingOver()做拦截,很容易重复生成。
5.4 内部类或嵌套类上的注解失效
现象:@Getter放在一个类的内部类上,生成的 getter 方法位置不对,或者压根没生成。
原因:javac 的 AST 里,内部类是独立的JCClassDecl嵌套在外部类节点之下,而 Lombok 的处理器是按编译单元遍历的。有些 handler 只处理了顶层节点,没有递归访问内部类节点。
解决:这类问题的通用做法是不要依赖注解处理器自动发现嵌套类,而是通过显式遍历把所有层级的类节点拿出来再处理。我在自己写处理器时用的办法是:在visitTopLevel里遍历classDecl.defs,对嵌套类重复调用 handler 逻辑。还有一个更稳的路径是直接给字段所在的内部类单独加注解,不依赖外层传播。
5.5 IDEA全量编译正常,但热加载或增量编译后效果消失
现象:改了一个字段保存,IDEA 的自动构建没有生成新的 getter,运行时报错;点“Rebuild Project”又恢复正常,看起来像玄学。
原因:本质是增量编译没有在修改的类上执行注解处理。IDEA 的 Java 编译器和 javac 的增量编译策略不同,它可能只编译变更的文件,而注解处理器只有在编译该文件所在编译单元时才会触发。如果只改了字段名而没改类名,编译器可能判断“类签名没变”跳过处理。
解决:把 IDE 自动构建关掉,改用构建工具的命令行编译来保证一致性。或者保证 IDE 编译器和 Maven/Gradle 使用同一套注解处理器路径——这是大多数人没注意到的细节,IDE 默认自带的编译器配置和构建工具两套独立,删掉 IDE 那套配置只保留构建工具里的,能解决一多半这类问题。从那次以后,我在任何项目里都只保留 build 工具里的annotationProcessorPaths,IDE 里不再重复配置。
6. 二次开发思路:把Lombok的机制搬进自己的代码生成器
读懂 Lombok 的源码后,真正有价值的事情是把这个模式抽出来:一个“在编译期改写 AST 的代码生成器”完全可以泛化成一套通用工具。我在自己的项目里做了一个简化框架,核心改动只有两处。
第一处:把硬编码的 handler 逻辑抽象成规则。不再为每个注解写一个独立的HandleXxx类,而是定义一个生成器接口:
public interface AstGenerator { void generate(JCTree.JCClassDecl classDecl, TreeMaker make); }每个注解对应一个AstGenerator实现,处理器启动后按注解类型查表分发。这样新增一个注解就只需要写一个新的AstGenerator,不需要碰处理器的调度逻辑。
第二处:把“改 AST”和“生成规则”彻底分离。很多场景下你要生成的代码是纯模板性质的,手写make.MethodDef链又长又容易错。我一般会给AstGenerator加一个中间层:先用文本模板描述“要生成什么”,再在运行时用 javac 的JavacParser把模板解析成 AST,然后插入目标类。这样做的好处是代码逻辑更直观,出错的概率也远低于手工构造每个节点。
// 模板方式生成 builder 方法,替代手写 TreeMaker JavacParser parser = JavacParser.instance(context); // 伪代码示意:把字符串模板解析成 CompilationUnit,再截取其中的方法节点这个做法有个前提:模板解析的编译单元里的类名和包名必须和待处理类完全一致,否则插入时类型解析会失败。
验证方法还是那套组合拳:先用javap看字节码,再用 IDE 打开生成目录看中间源码,最后跑一轮真实业务逻辑。“至于怎么测试这个生成器本身,我的习惯是搞一个包含静态字段和泛型字段的测试类,把所有边界情况一次跑全。”
做这段开发最大的教训是:永远不要在编译期生成的代码里引用你自己项目里的类。编译器的“下一轮编译”顺序不受你控制,处理器生成的代码在团队协作里很容易碰到类加载顺序错位的问题。从那以后我要求所有模板生成的代码只依赖 JDK 原生类型和项目里已经存在且编译完成的类,不依赖生成类之间的相互引用。希望这个经验对你也有用。
如果还有问题,或者想聊具体某个注解的细节,欢迎在评论区留言——不过更推荐的方式是把这份源码包拉下来,对着里面的测试用例自己调试一遍,比任何文档都来得快。
本文还有配套的精品资源,点击获取