Lombok实现原理:从注解处理器到AST语法树,手写迷你版@Getter

📅 发布时间:2026/9/8 12:32:25
Lombok实现原理:从注解处理器到AST语法树,手写迷你版@Getter 简介这是一份围绕 Lombok 实现原理的 Java 工程代码包面向希望从编译机制层面理解 Lombok、或者在项目中排查样板代码生成问题的开发者。整套材料以 Java 注解处理器为主线清晰展示了 Data、Getter、Setter 等注解在 javac 编译阶段如何被解析以及如何借助 ASM 字节码库在生成结果中插入 getter、setter、equals、hashCode、toString、构造器等方法使代码在运行时无需额外依赖。压缩包共 40 个文件包含可阅读的 Java 源码、编译后的 class 字节码、封装好的 jar 包以及 xml、iml 工程配置文件另有 bpmn、dmn 流程定义内容可作拓展参考整体仅 88KB。已有 665 人学习内容紧凑适合用于阅读编译产物、跟踪处理流程以及在此基础上设计自定义注解处理器。整套材料保留从注解入口到字节码修改的完整实现线索并附带工程配置与流程定义便于开发者对照源码理解 Lombok 的设计边界与常见使用限制从而在工程中更安全地运用这类编译期增强技术。 用了这么多年Java你在实体类上敲下一个Data然后getter、setter、toString、equals就全都“凭空出现”了。但真要问一句lombok的实现原理是什么十个人里九个答不上来剩下一个可能会说“编译期注解处理”。这答案对但太笼统完全没碰到代码层面。我最早研究lombok实现原理时也以为它是靠运行时的反射或者动态代理后来翻到它的源码才发现这个库做的事比我想象的“野”得多它直接在javac生成字节码之前把语法树给改了。这篇文章我会从编译器的角度把它讲透最后带大家手写一个迷你版注解处理器让你亲眼看到getter是怎么被编译进.class文件的。适合已经被lombok惯坏、但又想搞明白背后机制的同学也适合想自己写编译期注解处理器的人。1. 编译期“魔法”的入口javac的注解处理机制1.1 javac的编译流水线注解处理发生在哪一步先说结论lombok的所有操作都发生在javac编译过程的“注解处理”阶段。要理解这个阶段就得先看一段Java源码从文本变成.class文件要经过哪些步骤。大体上javac的流程是词法分析把字符流拆成Token语法分析把Token按Java语法组装成一颗抽象语法树也就是AST然后进入“输入”阶段解析符号、建立符号表接着就是注解处理阶段之后再做语义分析、常量折叠、数据流分析这些检查最后才生成字节码。关键点在于注解处理阶段是在语法分析之后、语义分析之前执行的。也就是说这时候AST已经建好了但还没有开始做类型检查也没有生成最终的字节码。lombok恰恰就在这个时间窗口里动手。它通过注解处理器拿到AST往里面插入方法、字段、注解等它处理完javac会以为这些代码本来就是程序员写的继续走后面的语义分析和字节码生成。这个时机选择非常聪明。如果太早AST还没成型无从下手如果太晚语义分析和代码生成已经做完你改AST它也未必认账。只有卡在这个中间位置才是“偷梁换柱”的最佳时机。1.2 JSR 269为什么注解处理器能“看到”你的源代码很多人只知道注解处理器能处理注解但不清楚它为什么能拿到完整的类结构。这要追溯到Java 6引入的JSR 269规范它定义了Pluggable Annotation Processing API核心接口就是javax.annotation.processing.Processor。一个Processor要被javac发现需要做成SPI服务具体就是在jar包的META-INF/services/javax.annotation.processing.Processor文件里写上实现类的全限定名。lombok的jar包里就有这个文件里面写着比如lombok.launch.AnnotationProcessorHider$AnnotationProcessor。所以只要你的项目把lombok放进classpathjavac启动时就会自动扫描到这个文件加载对应的处理器。这个机制是lombok能“隐形”的基础。你不需要写任何额外的maven插件也不需要手动声明注解处理器只要依赖在classpath里javac就会按SPI约定主动找到它。看到这里你可能会问注解处理器的入口process方法给出的参数是Set? extends TypeElement annotations和RoundEnvironment roundEnvTypeElement代表一个类或接口的静态结构roundEnv则能拿到被注解修饰的元素。那“元素”和“语法树”之间是什么关系这就是下一层的核心了。单纯处理注解的话你用Element就够了但lombok要的不是“读”注解而是“改代码”。所以它不能停在Element这一层必须下探到javac内部真正保存代码结构的那一层JCTree。1.3 为什么lombok不走“生成新文件”的老路注解处理器有两种流派。一种是常规的代码生成器比如MapStruct、Dagger这类框架。它们的做法是读取你的接口或者抽象方法然后生成一个新的.java文件让编译器继续编译这个新文件。这种思路安全、兼容性好因为生成新文件不会动到原有的代码结构。另一种就是lombok这种“改造派”直接在原始类的AST上插入代码。传统APT生成新文件对使用方来说是“多了一个类”而lombok让使用方觉得“一个类里凭空长出了方法”体验完全是两回事。代价也很明显直接修改javac内部语法树属于使用编译器内部API而内部API从来不在兼容性承诺里。这就是为什么JDK版本一升级lombok偶尔会出现不兼容。它攀住的是javac的“体内结构”不是在体外调用公开接口。明白了这一点lombok为什么有专门的lombok.javac包、为什么社区把它叫“编译期黑魔法”就不难理解了。2. lombok的“手术台”AST语法树与JCTree2.1 从Element到JCTree找到那颗树在注解处理器里你看到的Element是JSR 269定义的抽象视图。它虽然能描述“这个类有哪些字段、哪些方法”但它改不了任何东西。要改必须拿到javac内部真正用来表示源代码的那棵树。javac内部把这棵树实现为com.sun.tools.javac.tree.JCTree。JCTree是一个抽象基类往下有几百个子类分别对应源代码里的不同语法成分。比如JCCompilationUnit整个.java文件JCClassDecl一个类或接口的定义JCVariableDecl一个字段或局部变量JCMethodDecl一个方法JCReturnreturn语句JCExpression各种各样的表达式注解处理器拿到的Element实际上内部都关联着对应的JCTree节点。lombok要做的事就是通过Trees.instance(processingEnv).getTree(element)从Element反向取到它对应的JCTree节点然后向下转型成JCTree.JCClassDecl这就算是把整个类定义握在手里了。这里有个细节值得注意Trees这一类不属于JSR 269标准API它是com.sun.source.tree包下的扩展。lombok在init阶段会保存processingEnv并利用JavacTrees.instance(processingEnv)拿到树工具类。整个链路虽然绕但每一步都有明确目的。2.2 TreeMaker把方法“拼”进语法树拿到JCClassDecl之后接下来的问题是怎么“造”出一个方法节点再塞进类的成员列表里。lombok用的是com.sun.tools.javac.tree.TreeMaker这个类是javac内部的“零件工厂”。你可以用maker.Modifiers(Flags.PUBLIC)造修饰符用maker.MethodDef(...)造方法定义用maker.Return(...)造return语句用maker.Select(maker.Ident(...), fieldName)造类似this.name的字段访问表达式。造好一个JCMethodDecl之后把它加进JCClassDecl的defs列表这个列表就是类的成员列表。javac后续遍历成员时会把这个新方法当作类里本来就存在的方法处理于是生成的.class文件里就真的多了一个方法。整个流程用一句话概括lombok不是“生成”了一个类而是“篡改”了类。这个区别让人觉得它太神奇也让它背负了“非标准实现”的原罪。2.3 每一类注解对应一个Handlerlombok内部代码非常工程化。打开源码你会发现包名结构是lombok.javac.handlers里面几乎每一个常用注解都有对应的handler类HandleGetter和HandleSetter处理字段的读写方法HandleEqualsAndHashCode处理equals/hashCodeHandleBuilder处理builder模式HandleSlf4j处理日志字段HandleVal处理局部变量类型推断这些handler都实现了JavacAnnotationHandler接口lombok的主处理器在process方法里拿到注解后根据注解类型分发到对应handler。每个handler的核心工作都一样定位到当前的JCClassDecl按自己的业务规则在语法树上做调整。一个有趣的例子是Slf4j。你只在类上写了一个注解它就会往类里塞一个private static final Logger log LoggerFactory.getLogger(类名.class);字段。能看到这个例子就说明lombok能改的不只是方法字段、导入语句、类上的注解它都能改。只要有对应的树节点它就能操作。3. 从零写一个迷你版MyGetter理论聊再多不如写代码。我带大家写一个mini版的lombok目标是在你标记了MyGetter的类上自动生成所有字段的getter方法。虽然功能简陋但它完整覆盖了lombok实现原理最核心的那条链路注解触发、拿到类节点、拼方法、塞回成员列表。3.1 工程准备为什么我建议用JDK8跑这个demo先说环境。我建议你在JDK8环境下跑这个demo原因很现实com.sun.tools.javac.tree.TreeMaker、com.sun.tools.javac.util.Names这些是javac内部API在JDK8里它们还在tools.jar里编译器默认能访问。从JDK9开始模块化JDK内部API被封装不加--add-exports之类参数直接访问会报package com.sun.tools.javac.tree is not visible这种错误。JDK16开始默认强封装就更麻烦了。所以我们用JDK8做演示把注意力放在原理本身而不是折腾编译参数。工程结构非常简单一个Maven项目就够lombok-mini/ ├── pom.xml ├── src/main/java/mini/ │ ├── MyGetter.java │ └── MyGetterProcessor.java └── src/main/resources/META-INF/services/ └── javax.annotation.processing.Processorpom.xml里只需要注意一点把maven.compiler.source和maven.compiler.target都设为1.8因为我们要用JDK8的编译环境。如果你机器上装的是高版本JDK可以用release8/release或者通过IDE设置里指定JDK 8为主环境。3.2 注解与Processor骨架先定义一个注解非常简单package mini; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.TYPE) Retention(RetentionPolicy.SOURCE) public interface MyGetter { }RetentionPolicy.SOURCE很重要它标明这个注解只存在于源码阶段不会进入class文件也不会在运行时被反射读取。lombok自己的注解大多是这种级别这是它和普通运行时注解的本质区别。接着写处理器骨架package mini; import com.sun.tools.javac.api.JavacTrees; import com.sun.tools.javac.processing.JavacProcessingEnvironment; import com.sun.tools.javac.tree.JCTree; import com.sun.tools.javac.tree.TreeMaker; import com.sun.tools.javac.util.Names; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.TypeElement; import java.util.Set; import javax.lang.model.util.Elements; SupportedAnnotationTypes(mini.MyGetter) SupportedSourceVersion(SourceVersion.RELEASE_8) public class MyGetterProcessor extends AbstractProcessor { private JavacTrees trees; private TreeMaker maker; private Names names; Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); this.trees JavacTrees.instance(processingEnv); Context context ((JavacProcessingEnvironment) processingEnv).getContext(); this.maker TreeMaker.instance(context); this.names Names.instance(context); } Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith(MyGetter.class)) { if (element.getKind() ! ElementKind.CLASS) { continue; } JCTree.JCClassDecl classDecl (JCTree.JCClassDecl) trees.getTree(element); for (JCTree member : classDecl.defs) { if (member instanceof JCTree.JCVariableDecl) { JCTree.JCVariableDecl field (JCTree.JCVariableDecl) member; classDecl.defs classDecl.defs.prepend(createGetterMethod(field)); } } } return true; } }init方法里做的三件事都很关键JavacTrees.instance(processingEnv)拿到树工具TreeMaker.instance(context)拿到造节点的工厂Names.instance(context)拿到标识符工具。注意JavacProcessingEnvironment是javac内部实现类它能从processingEnv里取出编译器的Context而TreeMaker和Names都依赖这个Context。process方法里我先用roundEnv.getElementsAnnotatedWith(MyGetter.class)找到所有被注解标记的元素再把它们当成JCClassDecl处理。classDecl.defs就是类成员列表遍历它遇到字段节点就生成对应的getter并加进去。别遗漏META-INF/services/javax.annotation.processing.Processor文件里面就一行mini.MyGetterProcessor没有这个文件javac扫描不到处理器整个demo跑不起来。3.3 核心逻辑往class里插入getter方法createGetterMethod这个方法才是真正的“手术”private JCTree.JCMethodDecl createGetterMethod(JCTree.JCVariableDecl field) { String fieldName field.name.toString(); String getterName get Character.toUpperCase(fieldName.charAt(0)) fieldName.substring(1); JCTree.JCExpression returnType field.vartype; JCTree.JCModifiers modifiers maker.Modifiers(Flags.PUBLIC); JCTree.JCReturn returnStatement maker.Return( maker.Select(maker.Ident(names.fromString(this)), field.name) ); JCTree.JCBlock body maker.Block(0, List.of(returnStatement)); return maker.MethodDef( modifiers, names.fromString(getterName), returnType, List.nil(), List.nil(), List.nil(), body, null ); }逐行解释一下。field.vartype是字段的类型节点所以getter的返回类型直接复用字段的语法树节点免去类型映射。maker.Modifiers(Flags.PUBLIC)生成public修饰符。maker.Select(maker.Ident(names.fromString(this)), field.name)生成的表达式可以理解为this.字段名。maker.Return把它包成return语句maker.Block包成方法体。最后的maker.MethodDef参数很多依次是修饰符、方法名、返回类型、泛型参数列表、入参列表、异常列表、方法体、默认值。对于无参getter来说泛型参数、入参、异常都是空列表所以传List.nil()方法体就是前面拼好的block默认值传null。方法体拼接完成后用classDecl.defs.prepend(...)把新方法加到成员列表最前面。为什么用prepend而不是append没特殊原因习惯上让生成的方法排在类前面看着更一致当然放后面也行。这段代码看着不长但它确实动了javac的语法树。字段有多少个就能生成多少个getter方法而且这些方法在字节码层面和手写出来的没有任何区别。不过我得坦白说这个demo为了可读性做了很多简化。生产级lombok需要处理的问题多得多字段是static的怎么办、字段名首字母是is时如何规范命名、类里已经有同名getter怎么办、字段是否有访问权限、泛型字段的类型如何映射、继承体系下父类字段要不要生成方法。这些边界你要真想写成生产代码每个都需要单独处理。3.4 编译验证用javap看class文件代码写完后先编译这个mini工程生成jarmvn clean package然后在另一个目录里放一个测试类import mini.MyGetter; MyGetter public class User { private String name; private Integer age; }再把mini工程的jar和javac一起使用手动触发处理器编译这个类javac -cp lombok-mini.jar -processor mini.MyGetterProcessor User.java编译成功后用javap命令看生成的class内容javap -p User.class你会看到类似这样的输出public class User { private java.lang.String name; private java.lang.Integer age; public User(); public java.lang.String getName(); public java.lang.Integer getAge(); }getter方法确实被编译进去了。这一刻你就成功复刻了lombok最核心的实现思路。如果你平时用Maven编译自己的项目也可以把mini工程作为注解处理器的依赖配置类似annotationProcessorPaths的机制来触发。JDK8上还有一个偷懒的办法直接把两个源文件一起编译让javac自动扫描SPI服务。4. IDE插件在做什么为什么IDEA不认识lombok生成的方法4.1 IDE的“代码模型”和javac并不是一回事很多人有个疑问既然lombok在编译期能生成方法为什么IDEA里我写了Data还是报错必须额外装插件才行原因是IDE在你敲代码的时候根本不会真正调用javac去编译整个项目。IDEA维护了自己的一套代码分析模型如果你用IntelliJ的底子它内部叫PSI也就是Program Structure Interface。你在编辑器里看到的方法列表、自动补全、错误高亮都是基于PSI树的而不是javac的JCTree。lombok修改的是javac的JCTreeIDEA的PSI树完全不知情。如果lombok只是在你构建项目时才介入那开发阶段IDE就永远不知道有getter/setter自然就会报“找不到方法getName()”。这就是为什么lombok开发团队必须给IDE写插件。所以lombok的完整工作链其实是双轨的构建时靠注解处理器在javac里动刀开发时靠IDE插件在PSI里模拟同样的效果。两条线缺一条体验都会崩。4.2 IDEA/Eclipse插件是怎么把方法“显示”出来的IDEA的lombok插件本质上做的事情是把这个注解翻译成“这个类有这些方法”的数据结构注入到IDEA内部模型里。虽然现在IDEA 2020.3之后官方要求lombok插件使用新的API老插件也迭代了几个大版本但核心思路没变当你在代码里看到Data时插件会扫描类字段然后让IDEA“以为”这个类里有对应的getter、setter、toString等方法。这些方法不会真的写进文件但在导航、提示、补全这些场景里表现得很真实。Eclipse那边更暴力。Eclipse插件安装时会把lombok.jar作为agent加载接入Eclipse自己的编译器流程里让IDE编译和代码模型都经过lombok的处理。这也是为什么Eclipse用户装lombok需要改eclipse.ini配置的原因。有一点值得注意IDE插件是“为IDE服务的补丁”它不需要真的重复lombok生成字节码的逻辑只需要把“类里多了哪些成员”这个信息同步给IDE模型即可。真正编译时生效的仍然是annotation processor那套。4.3 排查IDEA 2024 lombok失效的完整链路这几年大家反馈比较多的一个问题就是新版本IDEA里lombok突然“失效”明明依赖都在代码里也标红了甚至编译报错。IDEA 2024.1前后这个问题尤其常见。它背后的原因通常不是lombok坏了而是IDEA自带的Java编译器版本已经切换到了较新的JDK而项目里用的lombok还是老版本老版本的编译期处理器和应用模块还依赖被新JDK封装的内部API所以编译直接失败。如果你遇到报错信息里带着“java: You arent using a compiler supported by lombok, so lombok will not work”这就是lombok在做编译器版本检测它发现当前javac的版本超出了它能处理的已知范围为了安全主动拒绝运行防止产生错误字节码。排查链路我建议这样走能省不少时间第一步先区分是IDE标红还是编译失败。如果只是IDE标红但Maven构建通过问题优先级反而低重点看IDE插件。第二步编译失败就去看具体编译器版本。打开java -version再看lombok版本报错信息里通常会写明它支持到哪个编译器版本或者明确说当前环境不支持。第三步升级lombok版本最好升到最新稳定版。lombok每年的新版本都会适配当年发布的JDK1.18.30之前对JDK 21的支持是有问题的所以别迷信老版本稳定这个问题上老牛拉破车没用。第四步确认IDE里开启了“Enable annotation processing”。这个开关在Settings → Build, Execution, Deployment → Compiler → Annotation Processors。关闭状态下IDE不会帮你跑注解处理器。按这个顺序排查十有八九能解决。千万别一上来就重装IDE反而浪费时间。5. 边界、兼容性与工程选择lombok不完美的地方5.1 它不是运行时魔法生成的是真实字节码讲lombok实现原理时必须澄清一个最容易混淆的点lombok生成的代码是编译期真实存在的字节码不是运行时反射也不是AOP代理。我前面demo里用javap看到getName方法它就在.class文件里是实实在在的方法。这意味着lombok不影响运行时性能也没有反射带来的动态开销。从结果来看lombok生成的方法和你自己手写的几乎一样。也正因为这个特点lombok可以用于很多对性能敏感的场景不需要像反射那样缓存MethodHandle之类的优化。代价就是编译期那一下“篡改”必须足够可靠一旦语法树被改坏javac后续检查发现不了就可能产生畸形class。实际上lombok对大部分流程还是很小心的尽量生成非常标准的方法结构。5.2 新JDK兼容问题从“You arent using a compiler supported by lombok”说起lombok最大的麻烦就是它过度依赖javac内部实现。JDK每次更新只要内部API变了lombok就得跟着适配。这也能解释为什么lombok更新频率不算低因为它不更新高版本JDK的编译器就对它不友好。JDK 9模块化之后com.sun.tools.javac.*这些类被明确标记为内部APInormally你还不能直接访问。JDK 16开始JEP 396强制封装内部API之前还能靠反射绕过现在得加--add-opens。lombok为了跨过这些障碍自己搞了一套launcher机制用sun.misc.Unsafe或高级反射拿到编译器内部类的访问权限然后才继续修改AST。它在启动阶段做了很多防御性编码所以一旦遇见未知编译器版本最稳妥的办法就是直接报错就是那句“You arent using a compiler supported by lombok”。所以如果你升级JDK后看到这句先别喷lombok升级lombok版本多半就好了。我自己吃过亏项目从JDK 17升到21lombok还是1.18.28编译直接挂了。换到1.18.30就干净利落。5.3 哪些场景我建议谨慎用lombok虽然我没有立场劝退任何人不再用lombok但从工程实践来看有几个使用场景确实容易埋坑。第一个是核心领域模型里的equals和hashCode。lombok生成的equals是基于字段值的对于简单值对象很好用但一旦涉及继承关系并且父类也有业务上的相等性判断lombok默认不会调用父类的equals。除非你显式写EqualsAndHashCode(callSuper true)否则子类比较时直接忽略父类字段这个问题在业务代码里很难调试因为它不报错只是行为不对。第二个是SneakyThrows。它把受检异常直接包装成运行时异常看着很爽但团队成员里只要有人不熟悉这个机制会把异常处理搞得很被动。我倾向于只在极少数“不想被模板化try-catch污染”的场景用它比如测试代码或框架代码里。第三个是构造器上做手脚的场景。类似RequiredArgsConstructor生成带final字段的构造器对依赖注入框架很方便但如果类层次复杂IDE显示出来的构造器签名和自己脑补的不一致排查也要多花一点时间。说实话这些都是使用习惯问题不是lombok本身的性能或正确性缺陷。我很认同它在简化样板代码上带来的效率提升尤其DTO、VO、查询参数这些频繁变动的对象手写getter/setter太浪费生命。只是在选型时我会把lombok定位成“编译期工具”而不是“业务框架”这样后续升级JDK、排查兼容问题心态会平和很多。我个人的体会是lombok是“编译期语法糖里最甜的一种”但正因为甜得快更要知道它发生作用的时机。你一旦搞懂了它是在AST阶段动手术就不会再用运行时反射的思路去解释它遇到“方法不存在”、IDEA不识别、新JDK不兼容这类问题时头脑里也会快速浮现出那条完整链路注解触发、SPI加载、处理器拿到语法树、TreeMaker造节点、塞回成员列表。有了这幅画面排查问题就是按图索骥的事。本文还有配套的精品资源点击获取