简介:面向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 JavacAnnotationHandler<Getter> { @Override public void handle(AnnotationValues<Getter> 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 字段生成的方法是静态方法,方法体里不出现 this,Lombok 会把这个判断折进方法体构造分支里。这些分支看似不起眼,恰恰是生成代码能和手写代码完全对齐的原因。更复杂的 @Getter(lazy = true) 走的是另一条路。字段名会被自动改成 $name,getter 方法体变成一个双重检查锁,初始化的惰性表达式被包进 AtomicReference,字段类型也会被改写。这条分支要操作字段声明本身而不仅是新加方法,读源码时能学到怎么在 AST 上做既有节点的类型替换,这是仿写编译期注解处理器时最有价值的一页。4. 编译全流程与验证:AST 改动如何变成 class 文件4.1 javac 处理管线上,Lombok 的改动发生在哪一步javac 编译有大致固定的管线:parse 把 .java 解析成 JCTree,enter 把符号装载进去,接着进入注解处理轮次,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 在依赖树里的位置和版本,确认只有一个;再确认 scope,Lombok 编译期用 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 的字段生成 getter:package 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-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED,以及 util、code、processing 三个包。验证方法很简单:写个类用 @MyGetter 标注字段,编译后在 IDE 的 structure 视图里看是否有 getter 出现,或者用 javap -p 看字节码的方法表。从那以后,我每次接触新注解处理器,第一反应都是问它改的是语法树上的哪个节点、插到哪个链表、用什么坐标,比背源码更不容易丢。再遇到"某个框架的注解不生效"之类的怪问题,先开 -XprintRounds 确认处理器有没有被加载,这招屡试不爽。希望帮到你。本文还有配套的精品资源,点击获取
简介:面向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 JavacAnnotationHandler<Getter> { @Override public void handle(AnnotationValues<Getter> 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 字段生成的方法是静态方法,方法体里不出现 this,Lombok 会把这个判断折进方法体构造分支里。这些分支看似不起眼,恰恰是生成代码能和手写代码完全对齐的原因。更复杂的 @Getter(lazy = true) 走的是另一条路。字段名会被自动改成 $name,getter 方法体变成一个双重检查锁,初始化的惰性表达式被包进 AtomicReference,字段类型也会被改写。这条分支要操作字段声明本身而不仅是新加方法,读源码时能学到怎么在 AST 上做既有节点的类型替换,这是仿写编译期注解处理器时最有价值的一页。4. 编译全流程与验证:AST 改动如何变成 class 文件4.1 javac 处理管线上,Lombok 的改动发生在哪一步javac 编译有大致固定的管线:parse 把 .java 解析成 JCTree,enter 把符号装载进去,接着进入注解处理轮次,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 在依赖树里的位置和版本,确认只有一个;再确认 scope,Lombok 编译期用 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 的字段生成 getter:package 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-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED,以及 util、code、processing 三个包。验证方法很简单:写个类用 @MyGetter 标注字段,编译后在 IDE 的 structure 视图里看是否有 getter 出现,或者用 javap -p 看字节码的方法表。从那以后,我每次接触新注解处理器,第一反应都是问它改的是语法树上的哪个节点、插到哪个链表、用什么坐标,比背源码更不容易丢。再遇到"某个框架的注解不生效"之类的怪问题,先开 -XprintRounds 确认处理器有没有被加载,这招屡试不爽。希望帮到你。本文还有配套的精品资源,点击获取