Lombok源码拆解:编译期AST改写与注解处理器实战 简介围绕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/setterLombok 这个 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操作ASTTreeMaker与JCTree的配合Lombok 处理注解的“原子操作”是拿到一个JCTree.JCCompilationUnit编译单元的语法树然后对树上的节点做增删改。javac 的语法树节点都是只读语义想安全修改必须通过TreeMaker这个工厂类来创建新节点再用TreeScanner或TreeTranslator去遍历和替换。// 简化版的 HandleGetter 处理逻辑仅示意核心链路 public class HandleGetter { public void handle(AnnotationValuesGetter 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 ShadowClassLoaderLombok的类加载隔离设计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.ListJCTree.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:jdwptransportdt_socket,servery,suspendy,address8000 \ -cp lombok.jar -processorpath lombok.jar \ src/main/java/cn/demo/User.java参数里的-J表示把后面的参数传给启动 javac 的 JVMsuspendy表示 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 内部 APIJDK 9 开始模块化后com.sun.tools.javac.*默认不开放给外部模块。JDK 11 之前没有强制的模块边界所以还能用JDK 17 之后强封装不显式开放直接拒绝加载。Lombok 主版本跟进有延迟经常出现“IDE 里能用但命令行编译失败”的错位情况。解决如果你是普通用户升级 Lombok 到支持当前 JDK 的版本如果你是二次开发者在 Maven 编译插件里加compilerArgs arg--add-exportsjdk.compiler/com.sun.tools.javac.apiALL-UNNAMED/arg arg--add-exportsjdk.compiler/com.sun.tools.javac.treeALL-UNNAMED/arg arg--add-exportsjdk.compiler/com.sun.tools.javac.utilALL-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 工具里的annotationProcessorPathsIDE 里不再重复配置。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 原生类型和项目里已经存在且编译完成的类不依赖生成类之间的相互引用。希望这个经验对你也有用。如果还有问题或者想聊具体某个注解的细节欢迎在评论区留言——不过更推荐的方式是把这份源码包拉下来对着里面的测试用例自己调试一遍比任何文档都来得快。本文还有配套的精品资源点击获取