资讯动态

深入解析Lombok源码:javac编译期如何通过注解处理器修改AST生成代码

发布时间:2026/10/20 7:11:42 来源:尧图企业网站定制
简介面向Java开发者的Lombok实现原理源码资源包围绕注解处理器、字节码操作与IDE集成展开适合希望弄清楚Lombok为何能在编译期自动生成getter/setter、equals/hashCode、构造器等样板代码的读者。资源共40个文件约88KB包含9个Java源码、10个class字节码以及jar文件、工程配置XML和IntelliJ模块文件可对照源码与编译产物理解注解处理链路dmn/bpmn等流程模型文件可用于了解与Camunda等框架结合时的兼容性。已有665人学习。读者可通过具体示例模块跟踪Getter、Data等注解从编译器扫描到ASM字节码插桩的过程借助processor相关辅助文件理解自定义注解处理器的扩展方式也能在配置中提前留意Lombok在final字段、第三方库兼容性方面的使用限制。整包小而精适合在IDE中边读边调试快速建立对编译期代码生成机制的完整认知。1. Lombok 到底是什么编译期在语法树上做手术的注解处理器第一次拆 Lombok 源码时我心里是带着气的一个 Data 写下去IDE 里凭空多出一堆 getter、setter、equals可 .java 文件里一个字都没多。顺着调用栈追到 javac 语法树层才看清本质——Lombok 不是运行时反射也不是编译后改字节码它是在 javac 编译到一半时直接对内存里的语法树开了一刀。改动发生在 .class 生成之前所以编译产物干净利落连覆盖率工具都找不到它动过手的痕迹。这篇解析适合两类人天天用 Lombok 插件但没搞懂注解处理器原理的 Java 开发者以及打算自己写编译期注解处理器、想找个成熟项目当模板的进阶玩家。接下来沿着入口、AST 操作、编译时序和二次开发把源码拆完。2. 技术选型与整体架构为什么 javac 编译期是最佳手术窗口2.1 三种方案走了一遍只剩编译期改语法树这条路先说运行时反射。Java 类的行为在类加载之后基本定型想动态塞一个 getter 进去只能靠 Instrumentation 的 redefineClasses对类加载器、调试器都有副作用而且 IDE 自动补全永远看不到这些方法。Lombok 的目标是写给开发者看的代码生成运行时魔法这种黑匣子会让问题排查变得极痛苦它不会选这条路。再说字节码后处理。用 ASM 在 .class 文件里追加方法技术在底层是可行的很多框架也这么干但代价是构建流程必须多挂一道 transform。这意味着每个模块编译完都要跑一遍额外任务还要处理测试类、依赖 jar、增量编译的各种边角更致命的是 IDE 里依然看不到生成的方法跳转和重构直接失效。单凭开发者体验这一条这条路就走不通。于是只剩第三个窗口javac 把 .java 解析成语法树之后、生成字节码之前会留出注解处理阶段。这时候树上的节点都还活着塞一个 JCMethodDecl 进去后面的语义分析、数据流分析、字节码生成都会把它当手写方法同等对待。你可以把这一步想象成水泥还没干的时候改墙里的管线改完抹平谁也看不出原来是什么样。还有一条更正统的 JSR 269 路线在注解处理阶段通过 Filer 生成新的 .java 文件Spring 的注解处理器就是这么做的。Lombok 为什么不走因为生成 .java 文件需要重新解析、重新编译注解里携带的泛型、类型引用都可能丢失直接改现有语法树类型信息就是原来那颗节点一行代码都不用重写。代价是它必须碰 javac 的内部实现这也是 Lombok 每次遇到 JDK 大版本升级就容易崩的根源。2.2 javac 留给注解处理器的入口链路javac 通过两种方式发现注解处理器命令行显式指定 -processor或者靠 ServiceLoader 从 META-INF/services/javax.annotation.processing.Processor 文件里读取。Lombok 用的是后者它的 jar 里这个文件的内容指向一个内部处理器类。实际编译时javac 会先创建 JavacProcessingEnvironment然后反复调用处理器的 process 方法。每个轮次开始前javac 已经完成了 parse 和 enter也就是源码已经变成了完整的语法树处理完注解之后如果没有生成新文件轮次结束编译器进入 attribute、flow、desugar、generate 这些阶段。Lombok 的入口拿到 JavacProcessingEnvironment 之后会从它内部取 Context再从 Context 里取 TreeMaker、Names、JavacTrees。这三个类分别是造语法树节点造名字定位编译单元的工具后面所有注解的展开逻辑都建立在它们之上。看懂这一层Lombok 在你眼里就不再是黑匣子而是一套围绕 javac 内部 API 的语法树编辑器。2.3 源码地图拆源码时先认准这几个包包路径职责阅读建议lombok.core注解处理器入口、SPI 装配从这里跟进看处理器如何被调起lombok.javacjavac 侧 AST 包装JavacAST、JavacNode理解节点如何包装语法树lombok.javac.handlers每个注解一个 Handler如 HandleGetter核心业务逻辑重点读lombok.delombokDelombok 命令实现需要把注解展开成源码时再看lombok.eclipse另一套编译器生态的实现思路与 javac 侧对应细节可跳过一个值得注意的细节同一个注解在 javac 和另一套编译器生态下面各有一整套 Handler注解语义要同步维护两份。拆源码时只看 lombok.javac.handlers 就够了不要被同名类带偏。读 handler 时有个顺序技巧先读 HandleData因为它会同时调 HandleGetter、HandleSetter、HandleEqualsAndHashCode、HandleToString一张调用图就能看清组合式注解是怎么拼出来的。3. 核心模块拆解一个 Getter 从注解到方法树的完整链路3.1 入口AnnotationProcessor 如何在 process 阶段接管编译Lombok 给不同编译器各写了一个入口壳壳之上是统一的 AnnotationProcessor。看下面这个关键片段重点在它拿到 JavacTrees 之后做了什么// lombok.core.AnnotationProcessor关键片段已省略异常处理与平台分支 public final class AnnotationProcessor implements AnnotationProcessorI { Override public void process(AnnotationProcessorFramework javac, AnnotationValue... annotations) { // 标准 API 的实现类被强转成 javac 内部类型这是整个设计的钥匙 JavacProcessingEnvironment env (JavacProcessingEnvironment) javac; JavacTrees trees JavacTrees.instance(env); JavacAST ast new JavacAST(env, trees, env.getContext()); ast.parse(); ast.traverse(new AnnotationVisitor(ast)); } }那行强转很关键。JSR 269 的公开 API 只允许你读程序结构不允许直接改语法树Lombok 把标准 API 的实现对象强转成 JavacProcessingEnvironment再顺藤摸瓜拿到 JavacTrees本质上是绕过标准规范用私有实现干活。JavacTrees 是 javac 内部 RTT 工具类它能把一个 Element 映射回对应的 JCTree 节点所有找到树并修改树的操作都从它开始。拿到 JavacTrees 后Lombok 自己包一层 JavacAST然后开始遍历整棵语法树。遍历器 AnnotationVisitor 在命中不同注解时会把节点分发给对应的 Handler。这里要注意整个编译可能有多个轮次process 每次被调用都会重新构建 AST 并遍历所以 Handler 内部必须自带已处理判断防止同一个注解被展开两次这也是后面总结坑位时第一条会遇到的隐性前提。3.2 Handler 分发注解类型是怎么对应到处理逻辑的打开 lombok.javac.handlers 包每个注解基本都有一个 HandleXxx 类它们继承自同一个抽象父类。拿 HandleGetter 的签名来说// lombok.javac.handlers.HandleGetter精简 public class HandleGetter extends JavacAnnotationHandlerGetter { Override public void handle(AnnotationValuesGetter annotation, JavacAnnotation annotationNode, JavacNode typeNode) { // annotationNode 是 Getter 注解所在的语法树节点 // typeNode 是注解所在的上级节点对字段上的 Getter 就是类节点 } }三个参数各有用处。annotation 把注解属性包了一层getter 要读的 value、lazy 都从它里面取注解写错类型或格式时也是它负责报编译错annotationNode 提供注解本身的位置信息写编译期错误时要把源码坐标指向它否则 IDE 会把错误标在不该标的地方typeNode 是注解所在节点的父级Getter 写在字段上时它就是类定义节点后面所有插入方法的动作都发生在 typeNode 对应的 JCClassDecl 上。分发逻辑本身不复杂核心价值在每个注解一个处理器类的组织方式。新增一个注解的成本变得很低写一个 HandleXxx在遍历器的回调分支里加一行分发。读源码时先找 JavacASTVisitor 的 visitAnnotationOnType 方法它里面有一长串 if 判断是理解 Lombok 支持哪些注解以及注解间组合关系最快的入口。3.3 生成方法树TreeMaker 把 getter 拼出来的现场假设源码里有一个字段 private String name;。Lombok 的目标是在类定义的语法树上追加一个方法节点等价于手写 public String getName() { return this.name; }。核心代码长这样// lombok.javac.handlers.HandleGetter生成方法的核心片段已简化 JavacTreeMaker maker typeNode.getTreeMaker(); Name methodName names.fromString(get capitalize(fieldName)); JCMethodDecl methodDef maker.MethodDef( maker.Modifiers(Flags.PUBLIC), // 可见性修饰符 methodName, // 方法名get 字段名首字母大写 maker.Type(fieldType), // 返回类型直接复用字段的类型节点 List.nil(), // 泛型类型参数 List.nil(), // 形参列表 List.nil(), // throws 异常列表 maker.Block(0, List.of( // 方法体 maker.Return(maker.Select( maker.Ident(names.fromString(this)), fieldName)))), null); // 默认值无 classDef.defs classDef.defs.prepend(methodDef);逐个参数说。maker.Modifiers(Flags.PUBLIC) 生成修饰符节点Flags.PUBLIC 是 javac 内部常量和源码里写 public 是同一个语义。maker.Type(fieldType) 有意思它没有重新解析类型而是直接把字段已有的类型节点拿过来复用所以泛型、注解、通配符全都原样保留。maker.Block 第一个 0 是块标志位第二个参数是语句列表这里只有一条 return。方法体里那两行是很多人第一眼绕不过去的maker.Ident(names.fromString(this)) 造出一个代表 this 的标识符节点maker.Select 再把这个标识符和字段名 name 拼成 this.name 的访问路径。Ident 和 Select 的区别要记牢Ident 是单个名字引用Select 是带路径的字段访问字段是 static 时这里会直接换成 Ident 引用字段名不再走 Select。最后的 prepend 把新方法插到类定义节点的 defs 链表头部头插法让生成的方法在遍历顺序上稳定靠前不会被其它处理器同类逻辑打乱。3.4 字段分支处理final、static、lazy 的岔路生成 getter 只是最简单的一条路。HandleGetter 里真正体现工程经验的是对字段修饰符的逐个分支大致逻辑可以压缩成boolean isStatic (fieldFlags Flags.STATIC) ! 0; boolean isFinal (fieldFlags Flags.FINAL) ! 0; if (isFinal generateSetter) return; // final 字段不生成 setter if (isStatic) { // 方法体里不出现 this直接 return 字段名 }final 字段不生成 setter是刻意为之语义上 final 字段不允许重新赋值生成了也是编译错不如直接跳过。static 字段生成的方法是静态方法方法体里不出现 thisLombok 会把这个判断折进方法体构造分支里。这些分支看似不起眼恰恰是生成代码能和手写代码完全对齐的原因。更复杂的 Getter(lazy true) 走的是另一条路。字段名会被自动改成 $namegetter 方法体变成一个双重检查锁初始化的惰性表达式被包进 AtomicReference字段类型也会被改写。这条分支要操作字段声明本身而不仅是新加方法读源码时能学到怎么在 AST 上做既有节点的类型替换这是仿写编译期注解处理器时最有价值的一页。4. 编译全流程与验证AST 改动如何变成 class 文件4.1 javac 处理管线上Lombok 的改动发生在哪一步javac 编译有大致固定的管线parse 把 .java 解析成 JCTreeenter 把符号装载进去接着进入注解处理轮次Lombok 的 process 就运行在这个阶段。它改的是内存里的语法树节点不会回写 .java 文件。轮次结束后attribute 做语义分析刚插入的方法参与重载决议和类型检查flow 做数据流分析return 语句有没有覆盖所有路径在这里被检查desugar 去掉语法糖最后 generate 输出字节码。理解这条管线能解释很多诡异现象。比如为什么 Lombok 生成的代码在 IDE 和命令行 javac 下表现基本一致因为两边的语法树最终都被同样的语义分析渡过再比如为什么某些 IDE 的即时编译偶尔和 Maven 编译结果不一致因为 IDE 有自己的编译实现不一定完整执行了注解处理轮次。一个细节process 方法返回 boolean 表示是否认领了这些注解。Lombok 返回什么影响不大因为它不生成新 .java 文件不会触发新的轮次它改现有树的操作反映到编译时间上几乎是常量。所以排查加了 Lombok 编译变慢时第一反应该去数注解处理轮次而不是怀疑语法树操作本身成本高。4.2 用 Delombok 把魔法还原成可读源码Lombok 自带一个叫 delombok 的子命令能把注解展开写成普通 Java 代码。这是拆源码时最趁手的验证工具java -jar lombok.jar delombok src/main/java -d delomboked-src --classpath target/classes参数含义第一个路径是源文件目录-d 指定输出目录--classpath 让它在解析类型时能找到依赖的 class。跑完之后delomboked-src 里会多出完整的 getter、setter、equals、hashCode 源码一行魔法都不剩。// 原始代码 Getter private String name; // delombok 输出 private String name; public String getName() { return this.name; }我一般用 delombok 验证两件事。一是看生成方法的可见性和名字是否符合预期比如 AccessLevel.PROTECTED 到底有没有生效二是排查生成方法和我手写方法重复的冲突展开后的文件会直接标出重复方法比看编译日志直观得多。有些团队还会把 delombok 产物提交进仓库给旧构建链当备胎这也是它在生产环境里的常见用法。提示delombok 只展开本次源码里的注解不会处理依赖 jar 里的类对第三方类不要有预期。4.3 调试技巧断点打在语法树被修改的现场想在 IDE 里跟踪 Lombok 的整个运行过程核心是让断点能命中 javac 内部类。推荐的做法是在 HandleGetter 的 handle 方法第一行打断点以 Debug 方式跑一个最简单的 main 方法。启动调试后观察三个变量typeNode 指向的类节点、classDef.defs 链表的前后长度、新插入的 JCMethodDecl 的 pos 字段。pos 代表源码坐标Lombok 生成的节点会用注解所在位置的坐标所以在 IDE 源码视图里点 Getter 那一行能看到这个断点命中时坐标是如何被继承的。不想开 IDE 时可以用 javac 直接观察处理轮次javac -XprintRounds -XprintProcessorInfo -cp lombok.jar your/App.java-XprintRounds 会打出每一轮输入了哪些文件、输出了哪些文件-XprintProcessorInfo 会列出处理器实例化信息。这两个参数对排查处理器根本没执行非常有用如果输出里压根没有 Lombok 的 Processor说明 SPI 文件没被加载问题在 classpath不在代码逻辑。5. 避坑清单注解处理这条路上的常见翻车点以下问题不是从文档里抄来的是拆源码和实际构建中反复遇到的。每条按现象、原因、解决三步给适合在你自己的项目翻车后按图索骥。5.1 现象依赖加对了IDE 里却一直找不到 getter现象是 pom 里已经引入 Lombok代码里的 Getter 不报错但 IDE 代码提示里就是没有 getName编译直接报 cannot find symbol。绝大多数情况是 IDE 的注解处理开关默认没开或者项目构建配置里误加了 -proc:none把整个注解处理阶段关掉了。Lombok 插件的本质只是帮你把开关打开、处理好 javac 内部 API 的访问权限代码生成依然靠编译器的注解处理轮次。解决方法是先查 IDE 编译器设置里的 Annotation Processing 有没有勾选再查构建插件有没有关处理器最后做一次干净 rebuild。5.2 现象命令行编译报程序包 lombok 不存在现象是 IDE 里跑得好好的换成命令行编译立刻报错。原因通常有两个方向依赖冲突项目里同时存在多个 Lombok 版本或者某个依赖的传递过程里把 Lombok 排除了还有的是 scope 配错处理器不在编译 classpath 上。解决时先跑依赖树命令看 Lombok 在依赖树里的位置和版本确认只有一个再确认 scopeLombok 编译期用 provided 是对的最后检查有没有 dependency 的 exclusions 把它排掉。这个坑的麻烦之处在于 IDE 会自己拼 classpath掩盖掉很多命令行下的问题。5.3 现象升级 JDK 后编译直接崩抛 NoSuchFieldError现象是切到新 JDK 后编译抛 NoSuchFieldError 或 InaccessibleObjectException堆栈指向 com.sun.tools.javac。原因是 Lombok 依赖 javac 内部 API而 JDK 每次大版本都可能重构内部类老版本 Lombok 抓着一个私有字段反射进去读字段改了名字或挪了位置自然崩。这几年 JDK 不断封装内部 API这类问题越来越频繁。解决时第一件事是升级 Lombok 到支持当前 JDK 的版本按官方兼容矩阵选不要留在过老版本如果项目锁版本临时方案是加 JVM 参数开放模块访问但只能治标。这条属于典型的版本玄学遇到先查版本对应关系别去改业务代码。5.4 现象模块化项目里 Lombok 完全不生效现象是项目引入 module-info.java 后注解处理器没执行生成的代码全部缺失。原因是模块路径下 ServiceLoader 的行为和 classpath 不同META-INF/services 里的 Processor 需要模块描述文件里显式声明 provides否则不会被发现。很多迁移模块化的项目都在这一步翻车。解决时如果不想做完整模块化改造把编译期的注解处理放回 classpath用 -processorpath 明确指定 lombok.jar如果坚持模块化必须在 module-info 里补齐 provides 声明。我的建议是注解处理器密集的项目迁移模块化要格外慎重收益未必抵得过构建复杂度。5.5 现象继承场景下生成的 setter 把父类方法覆盖了现象是父类有同名同参的 setter子类用 Data 且恰好有同名字段编译通过但运行行为混乱。原因是 Lombok 生成方法时只检查当前类有没有同名方法不感知父类已有的方法。语法树上属于子类的方法和父类同名同参Java 的覆盖规则就生效了语义跟你想要的不一样。解决方法是改字段名避开父类或者不用 Data 改用显式 Getter 加手写 setter把方法命名控制权拿回来。这是我实际踩过的坑从那以后凡是有继承关系的类引入 Lombok 前都会先看一眼父类的字段和方法清单。6. 二次开发实战写一个 30 行的迷你 MyGetter6.1 处理器本体想真正掌握 Lombok 的套路最有效的方法是照它的样子写一个极简处理器。下面这个 MyGetterProcessor 会在编译期给标注了 MyGetter 的字段生成 getterpackage demo.annot; import com.sun.tools.javac.api.JavacTrees; import com.sun.tools.javac.code.Flags; import com.sun.tools.javac.processing.JavacProcessingEnvironment; import com.sun.tools.javac.tree.JCTree.JCClassDecl; import com.sun.tools.javac.tree.TreeMaker; import com.sun.tools.javac.util.List; import com.sun.tools.javac.util.Name; import com.sun.tools.javac.util.Names; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.*; SupportedAnnotationTypes(demo.annot.MyGetter) SupportedSourceVersion(SourceVersion.RELEASE_17) public class MyGetterProcessor extends AbstractProcessor { Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { JavacProcessingEnvironment env (JavacProcessingEnvironment) processingEnv; TreeMaker maker TreeMaker.instance(env.getContext()); Names names Names.instance(env.getContext()); for (Element field : roundEnv.getElementsAnnotatedWith(MyGetter.class)) { if (field.getKind() ! ElementKind.FIELD) continue; JCClassDecl cls (JCClassDecl) JavacTrees.instance(env).getTree(field.getEnclosingElement()); Name getterName names.fromString(get cap(field.getSimpleName().toString())); cls.defs cls.defs.prepend(maker.MethodDef( maker.Modifiers(Flags.PUBLIC), getterName, maker.Type(field.asType()), List.nil(), List.nil(), List.nil(), maker.Block(0, List.of(maker.Return( maker.Select(maker.Ident(names.fromString(this)), field.getSimpleName())))), null)); } return true; } static String cap(String s) { return Character.toUpperCase(s.charAt(0)) s.substring(1); } }这段代码和 Lombok 的 HandleGetter 同构从注解元素拿到字段向上定位类节点用 TreeMaker 拼方法再 prepend 到类节点。区别是省略了所有修饰符判断和配置读取方便看清最小骨架。6.2 注册与验证在 src/main/resources/META-INF/services 下建一个名为 javax.annotation.processing.Processor 的文件内容写一行demo.annot.MyGetterProcessor编译时注意 JDK 9 以后访问 javac 内部包需要放开模块限制在构建插件的 compilerArgs 里加上 --add-exportsjdk.compiler/com.sun.tools.javac.treeALL-UNNAMED以及 util、code、processing 三个包。验证方法很简单写个类用 MyGetter 标注字段编译后在 IDE 的 structure 视图里看是否有 getter 出现或者用 javap -p 看字节码的方法表。从那以后我每次接触新注解处理器第一反应都是问它改的是语法树上的哪个节点、插到哪个链表、用什么坐标比背源码更不容易丢。再遇到某个框架的注解不生效之类的怪问题先开 -XprintRounds 确认处理器有没有被加载这招屡试不爽。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑