恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Lombok原理:如何在javac编译期修改AST生成代码
首页
资讯中心
/
Lombok原理:如何在javac编译期修改AST生成代码
Lombok原理:如何在javac编译期修改AST生成代码
发布时间:2026/10/20 10:21:23
简介围绕 Lombok 注解处理器与编译期字节码操作的代码资源面向具备 Java 基础的后端开发者旨在讲清 Lombok 如何借助编译阶段的注解处理能力自动生成 getter、setter、构造器、equals、hashCode 等样板代码并提示使用边界与常见兼容性问题。压缩包共 40 个文件大小约 88KB以 Java 源文件、class 字节码、XML 配置和 JAR 依赖为主另有 bpmn、dmn 等流程与规则示例文件覆盖从注解定义、处理器实现到 IDE 插件协作的完整实验路径工程采用多模块结构便于按功能拆分阅读。已有 665 人学习下载适合希望掌握 Lombok 内部机制或自行扩展自定义注解处理器的开发者。通过实际运行代码可直观对照源码与生成后的字节码理解 ASM 库在编译期间插入方法的具体时机也能看到自定义注解处理器的扩展写法为排查第三方库兼容性问题提供实践参考。1. Lombok一个在编译期「改写源码」的黑客式注解处理器很多开发者第一次接触 Lombok 时会以为Getter是 IDE 补全出来的或是运行时的反射魔法。真相恰好相反你的.java源文件里从未存在过getName()但.class文件里确实有它——它是 javac 编译到一半时被一个跑在注解处理器队列里的程序直接往编译器内部语法树上画上去的。无论把 lombok 软件/插件装成 IDE 组件还是构建依赖它的执行位置都只有一个javac 的注解处理引擎。弄懂它怎么进编译器、为什么能改树你在 JDK 升级翻车、构建找不到 getter 这类问题上就能一眼定位根源而不是靠换版本碰运气。2. 注解处理的边界为什么标准 API 只能「新增」而不能「修改」2.1 JSR 269 的轮次模型编译被拆成了多轮问答javac 的注解处理机制来自 JSR 269Java Annotation Processing它本质上是在编译流程中间插入了一个「多轮问答」循环。每一轮javac 把当前可见的注解交给对应处理器处理器可以读注解所在元素的信息也可以通过Filer生成新的.java或.class文件。只要这一轮有新的文件产生javac 就再开一轮直到某一轮没有任何新文件产出进入最后一轮last round处理引擎退出编译继续走类型检查和字节码生成。轮次javac 做什么处理器能做第 1 轮解析源码收集注解读取符号、生成新文件第 N 轮编译新文件再次收集注解继续读取、继续生成最后一轮没有新文件了收尾清理随后退出关键点在于处理器手里的输入Elements只读符号模型和RoundEnvironment当轮环境。你可以问「这个类有哪些字段、字段类型是什么」但没有一条 API 能回答「给这个类加一个方法」。这不是 API 设计疏漏而是刻意划定的边界编译单元的所有权归编译器处理器只是旁路观察者只能往旁边「新增」不能回头「修改」已有的树。// 一个符合 JSR 269 的处理器只能生成新文件不能改已有类 public final class GenProcessor extends AbstractProcessor { Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { for (TypeElement annotation : annotations) { for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { // element 是编译单元里的类/字段/方法 // 能读它的修饰符、名字、类型但无法往它的方法列表里塞东西 // 想产出代码只能走 Filer // Filer filer processingEnv.getFiler(); // filer.createSourceFile(com.example.Generated); } } return true; // 声明本轮注解已被自己消费 } }这段代码里roundEnv.getElementsAnnotatedWith(annotation)返回的元素对读取完全开放但任何「写入现有类型」的操作都没有对应 API。Filer创建的新文件还会受「类型重复」约束——如果你生成一个与已有类同名的类会直接编译报错。所以标准注解处理的世界观是「增量」的你可以基于现有代码生成新类型但永远无法回到现有类型内部去改造它。2.2 为什么「生成新类」或「运行时增强」都救不了 Lombok顺着这个边界往下想Lombok 似乎有三条路可以绕。第一条是「生成一个平行的类」比如FooGetter把 getter 都放在里面——这毫无意义调用方写的是foo.getName()没人会去实例化一个平行的工具类。第二条是「改继承结构」让Foo继承一个带 getter 的基类——这会破坏原有类型语义Java 单继承也经不起这么折腾。第三条是运行时字节码增强ASM 或动态代理但那只对通过代理创建的实例生效直接new Foo()走的是原始字节码方法表里没有就是没有反射也救不回来。所以摆在 Lombok 面前的路只剩一条修改 javac 自己持有的那棵 AST。JSR 269 确实给了处理器一个看见 AST 的窗口——Trees.instance(processingEnv)之后可以调用getTree(element)——但com.sun.source.tree是只读接口写操作没有承诺。真正的修改能力藏在 javac 的实现类里com.sun.tools.javac.tree.JCTree的节点类暴露了公开字段比如JCClassDecl.defs就是类的成员声明列表谁拿到这个字段谁就能改树。Lombok 选择的就是这条路放弃稳定 API直接操作编译器内部实现。这也是它此后一切兼容性问题的总根源。提示判断一个注解处理框架是「标准派」还是「Lombok 派」只看它是否依赖com.sun.tools.javac就够。标准派只碰javax.annotation.processing.*和javax.lang.model.*而 Lombok 的核心逻辑几乎全在lombok.javac包底下。3. 侵入式改造Lombok 怎么混进 javac 并改写 AST3.1 入口伪装AnnotationProcessorHider 与自研类加载器先从入口看。Lombok 的 jar 里META-INF/services/javax.annotation.processing.Processor指向的不是大家以为的lombok.core.AnnotationProcessor而是一个叫lombok.launch.AnnotationProcessorHider$AnnotationProcessor的壳类。为什么套壳因为 javac 的注解处理引擎用标准ServiceLoader发现处理器壳类必须能被系统类加载器加载但 Lombok 真正要访问的com.sun.tools.javac.*内部类在 JDK 9 引入模块系统后默认不对外导出。如果让真实处理器直接挂在系统类加载器下一碰JCTree就会抛IllegalAccessError。壳类的破解办法是「自己加载自己」它内部实现了一个ShadowClassLoader把lombok.core底下的真实处理器重新加载一遍。这个加载器的关键动作是绕过模块边界的访问检查——在较新的 JDK 上常见做法是借道sun.misc.Unsafe打开jdk.compiler模块内部包的访问权限再在内存里直接定义lombok.javac.*的类。这样真实处理器所在的类加载域与 javac 内部类一致私有字段随便碰。这也是为什么每次 JDK 大版本更新Lombok 都要跟着发新版——内部模块的访问规则一变撬锁姿势就得跟着换。// 壳类的高度简化示意真实实现比这复杂得多 public final class AnnotationProcessorHider { public static class AnnotationProcessor extends AbstractProcessor { private final Processor realProcessor; public AnnotationProcessor() { // 用自研加载器绕过模块边界加载 lombok.core 里的真正实现 ClassLoader shadow ShadowClassLoader.create(getClass().getClassLoader()); this.realProcessor newInstance(shadow, lombok.core.AnnotationProcessor); } Override public void init(ProcessingEnvironment env) { realProcessor.init(env); // 把 javac 给的编译环境透传进去 } Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { return realProcessor.process(annotations, roundEnv); // 真正干活 } } }注意init(ProcessingEnvironment env)透传的这个env在 javac 里它的实现类是JavacProcessingEnvironment。真实处理器拿到它之后会从里面挖出Context——javac 的全局上下文对象类似一个内部 IOC 容器——再从Context提出TreeMaker、Names、Symtab这些核心服务。整个入侵的起点就是这个Context拿到它等于拿到了编译器全部内部依赖的钥匙串。3.2 JavacAST 与 TreeMaker给编译器准备的语法树手术台拿到Context之后Lombok 的工作可以概括成三句话把编译单元包成自己的树结构、定位要改的节点、用TreeMaker造出新节点并塞进父节点的成员列表。javac 的 AST 长这样一个编译单元对应JCCompilationUnit下面挂JCClassDecl类声明类声明里有个公开字段defs里面装着JCVariableDecl字段、JCMethodDecl方法等成员节点。所谓「加一个 getter」本质就是往JCClassDecl.defs这个ListJCTree末尾append一个新拼出来的JCMethodDecl。拼装不是靠写字符串再 parse而是用TreeMaker这个语法树工厂一个个参数构造// 用 javac 内部 TreeMaker 组装 getter 的简化示意 TreeMaker make TreeMaker.instance(context); // javac 的语法树工厂 Names names Names.instance(context); // 符号名表 JCMethodDecl buildGetter(JCVariableDecl field) { return make.MethodDef( make.Modifiers(Flags.PUBLIC), // public names.fromString(get capitalize(field.name)), // getName make.Type(field.type), // 返回类型 字段类型 List.nil(), // 泛型参数 List.nil(), // 形参列表 List.nil(), // 抛出异常列表 make.Block(0, List.of( make.Return( make.Select(make.Ident(names.fromString(this)), field.name)))), // return this.name; null // 默认值 ); }方法体的组装最值得看return this.name;在 AST 里是一个JCReturn里面包着一个JCFieldAccess它由「this标识符」和「字段名」组合而成。Lombok 不是写代码字符串它在用 javac 自己的积木搭一颗和手写源码等价的语法树。搭完后defs.append(...)塞回类声明对 javac 来说这颗树和 parse 出来的树没有任何区别后续类型检查、重载决议、字节码生成照常执行——这就是整个编译期改写能成立的根基。3.3 为什么是改 AST而不是改字节码同样在编译期动手脚还有一条路是等.class生成后用 ASM 改字节码。Lombok 没选它原因有三。第一类型检查的时机生成的方法必须参与编译器的重载决议和泛型推导改字节码发生在类型检查之后错误永远晚一步暴露改 AST 是「先改树再编译」语义最接近手写源码。第二IDE 的增量编译器也是一套 javac同样接受 AST 修改所以 IDE 里的补全和报错都不会失真字节码方案会绕过 IDE 静态分析体验直接崩坏。第三注解联动Getter生成的方法上有时要挂Deprecated等注解AST 阶段可以一并组装字节码阶段再补救就太迟了。所以「改 AST」不是激进是让生成物具备完整一等公民语义的唯一正解。4. 处理器分发与实现从 Getter 到字节码的完整链路4.1 HandlerLibrary注解到处理器的映射表Lombok 的 jar 里塞了几十个处理器各管各的注解。加载它们的容器叫HandlerLibrary通过 SPI 扫描lombok.handlers.*包下所有处理器实现每个实现类声明自己关心哪些注解。这份「注解 → 处理器 → 生成物」的映射决定了你写一个注解时背后发生什么注解处理器生成物Getter/SetterHandleGetter / HandleSetter类里的方法节点ToStringHandleToString重写的toString()EqualsAndHashCodeHandleEqualsAndHashCode一对方法BuilderHandleBuilder内部 builder 类 静态builder()Slf4jHandleLog一个log字段声明process进入后Lombok 先做「解析」Resolution。这不只是看注解字面量还包括把Getter(lazy true)这类属性读出来、把字符串名字映射成符号表里的Name。解析必须「懒」——被注解引用的类型可能还没生成要留到最后一刻才真正落到 AST 上。// 处理链骨架真实类名与分工简化示意 public final class LombokProcessor extends AbstractProcessor { private HandlerLibrary handlers; Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { Resolution r JavacResolution.create(processingEnv, roundEnv); for (TypeElement annotation : annotations) { for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { // 1. 找到对应 Handler比如 Getter - HandleGetter Handler? handler handlers.get(annotation.getQualifiedName()); // 2. 解析注解属性成符号/类型懒解析类可能还没生成 r.resolve(element); // 3. 从只读 Element 反查到 AST 节点包装成 JavacNode JCTree tree (JCTree) trees.getTree(element); JavacNode node new JavacNode(tree, ...); handlers.handle(annotation, node); } } return false; // 不认领注解允许别的处理器继续观察 } }最后一行return false是个细节标准处理器返回true表示「这注解我认领了」Lombok 故意返回false因为它不想阻止其他框架同时观察同一个注解。这侧面说明它把自己定位成「并行改造者」而不是注解的所有者。4.2 postRound 延迟为什么改树要拖到最后一刻最容易被忽略也最关键的是时机。Lombok 不在process()里立刻改树而是把所有要生成的节点装进队列等一轮处理彻底结束javac 的postRound回调时统一画到 AST 上。原因有两个。第一完整性。Getter所在类的父类可能是另一个处理器在上一轮生成的字段类型可能来自还没编译的新文件。第一轮就急着生成 getter类型检查时那些符号还不存在直接编译失败拖到收尾所有符号都沉淀完了出错概率最小。第二去重。如果边解析边改树javac 可能因为新文件又开一轮同一批注解再次被看到getter 就生成两遍。集中到postRound只跑一次天然避免重复。// 延迟执行结构lombok.javac 包里的真实机制简化示意 class JavacAST { private final ListRunnable postRoundTasks new ArrayList(); void deferUntilPostRound(Runnable task) { postRoundTasks.add(task); // 先记账不立即执行 } void postRound() { for (Runnable task : postRoundTasks) task.run(); // 统一改树 postRoundTasks.clear(); } }这个「先收集、后执行」的模型是 Lombok 与其他注解生成器最大的分野。你看到的所有Data生成的一大坨方法其实都是在同一个时间点、基于同一批解析结果批量写入 AST 的所以它们之间不会出现「部分方法生成、部分没生成」的中间态。4.3 链路落地一段 getter 的完整生平把前三节串起来一个Getter的生命周期是这样的源码里class Foo { Getter private String name; }被 javac 解析成JCCompilationUnit注解处理引擎命中AnnotationProcessorHider壳类加载真实处理器透传JavacProcessingEnvironmentLombokProcessor.process找到HandleGetter把name字段的 AST 节点包装成JavacNode解析注解属性确认没有lazy、没有AccessLevel.NONE然后deferUntilPostRound排队一轮结束后HandleGetter用TreeMaker拼出public String getName(){ return this.name; }对应的JCMethodDeclappend进JCClassDecl.defsjavac 继续走类型检查和代码生成getName进入方法表。验证这条链路不需要读源码编译后直接反编译# 编译后检查字节码方法表 javap -p target/classes/Foo.classclass Foo { private java.lang.String name; public Foo(); public java.lang.String getName(); # 源码里不存在但字节码里有了 }javap -p列出所有方法含私有和编译器生成的。getName()出现在这里就证明整条链路是「编译期改树 正常代码生成」的产物而不是运行时任何魔法。这也顺带解释了一个常见困惑为什么 Lombok 生成的代码在调试时看不到源码——因为它在.java里真的不存在你看到的class文件本身就是唯一真相。5. 避坑Lombok 与 JDK 迭代里的经典翻车现场Lombok 这类侵入式框架翻车现场几乎都集中在 JDK 版本、处理器发现机制、注解联动三个维度。挑五条最有代表性的记录下来每条都按「现象 → 原因 → 解决」讲透。5.1 换了 JDK 版本就编译失败现象项目原本好好的某天把本机 JDK 升到新大版本构建工具一跑就抛java.lang.IllegalAccessError或NoSuchMethodError报错位置通常在lombok.javac相关类里。原因Lombok 直接访问com.sun.tools.javac.*而 javac 的内部类结构和模块访问规则每个 JDK 版本都可能变。某个TreeMaker方法签名调整了某个内部包不再允许反射老版本 Lombok 当场崩。这是侵入式方案的必然代价不是偶发 bug。解决升级 Lombok 到与当前 JDK 匹配的新版本并在构建配置里锁死 JDK 版本保证 CI 和本地不出现大版本分叉暂时升不了级的宁可把 JDK 钉在旧版本也别让两侧各用各的。5.2 构建工具里找不到 getterIDE 却好好的现象IDE 里编译、跳转、补全全部正常但命令行构建一跑就报「找不到符号getName()」。原因IDE 的增量编译器内置了一套 javac默认扫描 classpath 上的注解处理器而新版 JDK从 23 开始的命令行javac默认不再隐式发现 classpath 上的处理器。没有显式声明处理器路径时Lombok 压根没被加载IDE 与命令行走的是两套不同的发现逻辑。解决在构建配置里显式声明 Lombok 的处理器依赖并开启完整处理模式用裸javac验证时显式传入-proc:full -processor lombok.launch.AnnotationProcessorHider$AnnotationProcessor先确认「处理器到底跑没跑」再谈代码问题。5.3 自定义处理器读不到 Lombok 生成的方法现象自己写了一个注解处理器想读Data类里的 getter 做二次生成结果Element里翻遍只有源码里写过的字段和方法。原因Lombok 改的是 javac 的 AST没有生成新文件也不会触发新的处理轮次。按 JSR 269 标准语义你的处理器只能看到「源码和已生成文件里真实存在」的符号AST 被改过不会同步进其他处理器的符号视图。这不是缺陷是工作方式决定的。解决需要消费 Lombok 生成结果的下游处理不能放在同一编译流程里。先delombok展开成真实源码再把自己的处理器挂到第二次编译上两个阶段解耦。5.4Data与Builder联用把无参构造搞没了现象一个类同时标了Data和Builder之前所有new Foo()的调用点全部编译失败。原因Builder会生成一个全参构造器供 builder 灌值而Data期望类里有无参构造兜底主要是序列化框架要用。两个注解各自为政叠加之后全参有了、无参没了。解决显式补上NoArgsConstructor和AllArgsConstructor把构造器策略完全握在自己手里类里已有手写构造器时同理。处理这类问题的一个通用思路是把隐式行为显式化不确定性立刻消失。5.5 delombok 辅助排查的通用姿势现象怀疑某个生成规则不对比如Getter(lazy true)生成的字段命名和预期不符盯着 class 文件反编译也绕晕。原因lazy的处理会引入额外字段和判空逻辑AST 生成物经过字节码编译后已经变形直接看产物很难反推生成规则。解决用 Lombok 自带的delombok工具把注解展开成普通 Java 文件java -jar lombok.jar delombok src -d delomboked-out。执行后Getter变成真实的getX()Builder展开成完整的内部类。拿「期望写法」和「展开结果」对拍一眼就能看出是处理器没跑、版本不匹配还是生成逻辑本身的问题。这套姿势同样适用于前四条先展开再定位永远比对着报错信息猜快。6. 设计取舍用编译器私有 API 换开发体验这笔账怎么算Lombok 的设计取舍本质上是一道选择题要「源码极简」的开发体验就得绑定 javac 私有 API要「跨版本稳如老狗」的兼容性就得放弃Data这类注解。二者不可能兼得因为 JSR 269 的稳定公共 API 天生不支持修改已有类型。Lombok 选择押注前者把兼容性风险转嫁给发布节奏每次 JDK 新版本预览出来作者团队就要适配内部类变动。这也是社区里一直有人呼吁「把 Lombok 功能收进 JDK」却始终没成型的核心原因——收进去意味着编译器团队要维护一套稳定的 AST 修改契约成本远高于单点适配。作为使用方这笔账怎么算才不亏我给自己定了三条硬规矩。第一把 Lombok 版本与 JDK 版本写成显式约束放进构建配置禁止「CI 一套版本、本机一套版本」。第二对生成代码有强校验的场景用javap -p查验方法表必要时把delombok展开后的源码提交进仓库让魔法变成可 review 的普通代码。第三自己写注解处理器时默认走标准 JSR 269 生成新文件只有生成物必须嵌进原有类型时才去碰com.sun.tools.javac而且要清醒知道这条路没有兼容性承诺。如果手上拿到的是这份资源的源码包建议先在本地把示例工程编译一遍用javap和delombok两条命令把生成代码摊开再回去对照lombok.javac包里的处理器实现逐行读效果远好于只看结论。从那以后我每次遇到「编译期魔法」类的诡异报错第一件事永远是先javap -p看 class 文件的方法表——确认代码到底生成没有再谈是版本问题、处理器没跑还是生成逻辑不对。这个习惯帮我筛掉了至少一半的无效排查希望帮到你。本文还有配套的精品资源点击获取