0. TL;DR
结论先行:当构建突然报出成百上千个 找不到符号: 方法 getXxx(),而代码一行没改时,第一件事是执行 mvn -v 确认它用的是哪个 JDK,而不是去读代码。
读完可获得:
- 一条从"报错表象"反推到"构建环境"的定位路径,以及为什么这类问题的报错离根因特别远;
- JDK 23 注解处理默认策略变更的准确含义与影响面(它比"Lombok 不支持新 JDK"更根本);
- Lombok 与 JDK 的版本对应关系,以及 Lombok 为什么对 JDK 版本如此敏感;
- 一组让这类问题不再复发的工程配置。
本文所有结论均在下列环境实测得出:
| 组件 | 版本 |
|---|---|
| Maven | 3.9.16(Homebrew 安装) |
| 构建 JDK(问题态) | OpenJDK 26.0.1(Homebrew) |
| 构建 JDK(正常态) | Temurin 17.0.19 |
| Lombok | 1.18.24 / 1.18.30 / 1.18.32 |
| maven-compiler-plugin | 2.3.2 |
| 编译目标 | source/target = 1.8 |
| 操作系统 | macOS 15.6 (arm64),zsh |
1. 背景与问题
一个多模块 Maven 工程,git status 干净,代码没有任何改动,mvn install 却直接失败,输出上百行编译错误。挑三条有代表性的(已脱敏):
text
错误: 找不到符号
符号: 方法 getBankMark()
位置: 类 PaymentBankMarkEnum
错误: 无法将类 AmountBO 中的构造器 AmountBO 应用到给定类型;
实际参数列表和形式参数列表长度不同
错误: 方法引用无效
位置: 类 ValuePair
这三类错误表面上毫无关联:一个是枚举缺方法,一个是构造器签名对不上,一个是方法引用失效。分布在几十个不同的类里,看起来像是有人大范围改坏了代码。
但 git log 显示最近一次提交是几天前的合并,且当时 CI 是绿的。代码没变,构建结果变了,那么变的一定是构建环境。
2. 术语与前置约定
只列读懂本文必需的几个概念:
- 注解处理(Annotation Processing):JSR 269 定义的编译期扩展机制。javac 在编译过程中发现并调用注解处理器,处理器可以生成新的源文件。Lombok 是其中最广为人知的使用者,但它的用法超出了规范范围,详见第 5 节。
-proc选项 :javac 控制注解处理行为的开关,三个取值none/only/full,分别表示不处理、只处理不编译、处理并编译。这个选项的默认值在 JDK 23 发生了变更,是本文的核心。- 构建 JDK 与运行 JDK :编译代码用的 JDK 和运行程序用的 JDK 是两个独立的东西,可以不同。本文说的"JDK 版本"若无特别说明,均指构建 JDK。
- 下文
javac特指命令行编译器本身,编译器插件特指 maven-compiler-plugin。
3. 整体视图
先把从"敲下 mvn install"到"产出字节码"的完整链路理清楚,再看每一段发生了什么。这条链上有两个不同的断点,它们的表现差异极大。
这张图要说的是:同样是"Lombok 没生效",断点 1 和断点 2 的可观测性天差地别。 断点 2 会甩一段明确指向 Lombok 的堆栈,五分钟就能定位;断点 1 什么都不说,只留下一地"找不到符号",让人误以为是业务代码坏了。本次遇到的正是断点 1。
后面各节按这条链逐段展开。
4. 定位过程
4.1 从三类报错反推共性
第一步不是打开报错的类去看,而是问:这三类错误有没有共同的解释?
把每类错误对应的源码拉出来看:
java
// 报错 1 对应的源码:枚举上标了 @Getter
@Getter
public enum PaymentBankMarkEnum {
CASH("cash"), WECHAT("wechat");
private final String bankMark; // getBankMark() 由 @Getter 生成
}
// 报错 2 对应的源码:类上标了 @AllArgsConstructor
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AmountBO {
private Amount left;
private Amount right;
public static AmountBO zero() {
return new AmountBO(Amount.newZero(), Amount.newZero()); // 两参构造器由 @AllArgsConstructor 生成
}
}
// 报错 3 对应的源码:方法引用指向 @Data 生成的 getter
list.stream().map(ValuePair::getFirst).collect(Collectors.toList());
共性一目了然:三类错误缺失的符号,全部是 Lombok 应该生成而没有生成的成员。 缺 getter、缺全参构造器、方法引用指向的 getter 不存在------它们是同一个原因的三种表现。
到这里问题就从"几十个类同时坏了"收敛成了"Lombok 没跑"。这一步很关键:报错数量多不等于问题多,先找共性再动手,能省掉大量无效阅读。
4.2 mvn -v 与 java -version 不是一回事
Lombok 没跑,通常只有两种可能:依赖没了,或者编译环境不对。依赖用 mvn dependency:tree 一查还在(org.projectlombok:lombok:jar:1.18.30:compile),那就看环境:
console
$ java -version
openjdk version "17.0.19" 2026-04-21
OpenJDK Runtime Environment Temurin-17.0.19+10
$ mvn -v
Apache Maven 3.9.16
Java version: 26.0.1, vendor: Homebrew, runtime: /opt/homebrew/Cellar/openjdk/26.0.1/...
java -version 是 17,mvn -v 是 26。
这两个值来自不同的解析逻辑:java 走 PATH,而 Maven 走 JAVA_HOME。当 JAVA_HOME 为空时,Homebrew 安装的 Maven 有一个包装脚本会兜底:
bash
$ cat /opt/homebrew/bin/mvn
#!/bin/bash
JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home}" \
exec "/opt/homebrew/Cellar/maven/3.9.16/libexec/bin/mvn" "$@"
Homebrew 的 maven formula 依赖 openjdk,装 Maven 时顺带装了一个当时最新的 JDK,并在 JAVA_HOME 缺省时指向它。没有人主动选择用 JDK 26 构建这个项目,它是包管理器的依赖传递带进来的。
这类"包管理器悄悄换掉你的运行时"的情况不止 Homebrew 有。所以排查构建问题时,mvn -v 的输出比 java -version 更有参考价值。
4.3 最小复现
有了怀疑对象就要做实验验证,而不是直接改配置试。构造一个不依赖任何工程上下文的最小用例:
java
import lombok.Data;
@Data
public class Demo {
private String name;
public static void main(String[] args) {
Demo d = new Demo();
d.setName("hi");
System.out.println(d.getName());
}
}
绕开 Maven,直接用两个 JDK 的 javac 编译,变量只有 JDK 版本这一个:
console
$ /path/to/temurin-17/bin/javac -cp lombok-1.18.30.jar -d out17 Demo.java
(无任何输出,编译成功)
$ /path/to/openjdk-26/bin/javac -cp lombok-1.18.30.jar -d out26 Demo.java
Demo.java:9: 错误: 找不到符号
d.setName("hi");
^
符号: 方法 setName(String)
位置: 类型为Demo的变量 d
Demo.java:10: 错误: 找不到符号
System.out.println(d.getName());
^
符号: 方法 getName()
位置: 类型为Demo的变量 d
2 个错误
复现成立,且与 Maven、与项目代码全都无关。顺带把本地有的几个 Lombok 版本都在 JDK 26 上跑一遍:
| Lombok 版本 | JDK 17 | JDK 26 |
|---|---|---|
| 1.18.24 | 成功 | 找不到符号 |
| 1.18.30 | 成功 | 找不到符号 |
| 1.18.32 | 成功 | 找不到符号 |
4.4 为什么一句告警都没有
到这里,"换 JDK 就好"已经够解决问题了。但有个现象值得追问:Lombok 挂掉是件大事,为什么 javac 一句话都不说?
先排除是不是自己没看见。打开注解处理相关的诊断开关:
console
$ /path/to/openjdk-26/bin/javac -Xlint:processing -cp lombok-1.18.30.jar -d outx Demo.java
Demo.java:9: 错误: 找不到符号
(...输出与不加该选项时完全一致,没有任何关于注解处理器的信息)
-Xlint:processing 本应报告注解处理器的发现与执行情况,结果一个字都没有。这说明不是处理器执行失败,而是 javac 根本没有进入注解处理流程。
原因是一处 JDK 层面的默认行为变更。从 JDK 23 开始,javac 不再默认启用注解处理:
在 JDK 22 及之前,注解处理是默认启用的。从 JDK 23 起,如果没有提供
-processor、--processor-path、--processor-module-path中的任何一个,就必须显式给出-proc:only或-proc:full,否则不会执行注解处理。
官方给出的动机是:让构建产出对"classpath 上无意引入的注解处理器"更健壮。这个特性 2006 年引入时默认开启是合理的,但在依赖动辄数百个 jar 的今天,任何一个 jar 只要在 META-INF/services 里注册了处理器,就能在编译期执行代码并影响产物------默认开启不再是个安全的选择。
JDK 21 和 22 为此做了过渡:当检测到"依赖默认策略的隐式注解处理"时会打印一条提示信息。同时 -proc:full 选项被向后移植到了 JDK 8u、11u、17u,让项目可以提前适配。
于是本次的完整因果链是:
JAVA_HOME为空 → Homebrew 包装脚本兜底用 JDK 26;- maven-compiler-plugin 2.3.2 是 2011 年的插件,只传
-source/-target,不会传--processor-path,也不认识-proc:full; - JDK 26 因此按默认策略不启用注解处理;
- Lombok 从未被调用,也就没有机会报错;
- 编译继续进行,产出缺少全部 Lombok 生成成员的字节码;
- 引用这些成员的地方全部报"找不到符号"。
报错离根因隔了六层,这就是它难查的原因。
4.5 用 -proc:full 逼出真实堆栈
既然默认策略把注解处理关掉了,那显式打开会怎样?这一步的价值在于验证"是否只是开关问题":
console
$ /path/to/openjdk-26/bin/javac -proc:full -cp lombok-1.18.30.jar -d outf Demo.java
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by lombok.permit.Permit
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
批注处理程序抛出未捕获的异常错误。
java.lang.ExceptionInInitializerError
at lombok.javac.apt.LombokProcessor.placePostCompileAndDontMakeForceRoundDummiesHook(LombokProcessor.java:174)
at lombok.javac.apt.LombokProcessor.init(LombokProcessor.java:96)
at lombok.core.AnnotationProcessor.init(AnnotationProcessor.java:213)
at jdk.compiler/com.sun.tools.javac.processing.JavacProcessingEnvironment$ProcessorState.<init>
...
Caused by: java.lang.NoSuchFieldException: com.sun.tools.javac.code.TypeTag :: UNKNOWN
at lombok.permit.Permit.getField(Permit.java:144)
at lombok.javac.JavacTreeMaker$SchroedingerType.getFieldCached(JavacTreeMaker.java:171)
at lombok.javac.Javac.<clinit>(Javac.java:186)
开关打开后,问题从"静默"变成了"响亮",但并没有被解决。 真实原因浮出水面:Lombok 1.18.30 通过反射去取 com.sun.tools.javac.code.TypeTag 的 UNKNOWN 字段,这个字段在 JDK 26 的 javac 里已经不存在了。
也就是说,JDK 26 上有两个独立的问题叠加:
- 注解处理默认关闭(JDK 23 起的策略变更)------这是导致"静默"的原因;
- Lombok 1.18.30 不认识 JDK 26 的 javac 内部结构------这是即便打开开关也跑不起来的原因。
只解决第一个不够。要在 JDK 26 上用 Lombok,两个都得解决。
补充一个实测结论:--processor-path 与 -proc:full 效果相同,都能重新激活注解处理,得到上面同样的堆栈。这一点在第 6 节的配置建议里会用到。
5. 设计思想与权衡
排查到此结束,但更有价值的部分在于:这两个决策各自都是合理的,为什么叠加起来会产生一个如此难查的现象。
5.1 Lombok 的能力来源就是它的脆弱性来源
JSR 269 规定的注解处理器只能做一件事:生成新的源文件。规范明确不支持修改已有的抽象语法树------这是有意的设计约束,为的是保证编译过程可预测、可重入。
但 Lombok 要做的恰恰是修改已有类:往 Demo 类里塞 getName(),而不是生成一个新的 DemoGetter 类。这件事规范内做不到,所以 Lombok 选择了另一条路:注册成一个注解处理器拿到编译期的执行时机,然后用反射直接操作 javac 内部的 AST。
上面那段堆栈就是这条路径的完整暴露:
text
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by lombok.permit.Permit
Caused by: java.lang.NoSuchFieldException: com.sun.tools.javac.code.TypeTag :: UNKNOWN
at lombok.javac.JavacTreeMaker$SchroedingerType.getFieldCached
lombok.permit.Permit 这个类的职责就是绕过 JDK 的访问控制(用 sun.misc.Unsafe 改字段偏移量来关掉 AccessibleObject 的检查)。JavacTreeMaker 则是对 javac 内部 TreeMaker 的反射封装。
这个设计的 trade-off 非常清晰:
- 换来的:能力上限突破了规范限制,实现了规范内无法实现的功能,这是 Lombok 得以存在的全部前提;
- 付出的 :它依赖的是 JDK 的内部实现细节 ,而不是任何公开契约。
com.sun.tools.javac.*从 JDK 9 起就被模块系统封装了,Lombok 靠--add-opens和Unsafe硬闯进去。JDK 内部随时可以重构,而重构一次 Lombok 就要跟一次; - 边界 :Lombok 与 JDK 之间不存在"向前兼容"。 一个 Lombok 版本只保证支持它发布时已存在的 JDK。这不是 Lombok 做得不好,是这条技术路线的必然结果。
理解了这一点,Lombok 的版本发布节奏就变得可预测了:
| JDK | 首个支持它的 Lombok 版本 | 发布日期 |
|---|---|---|
| JDK 21 | 1.18.30 | 2023-09-20 |
| JDK 22 | 1.18.32 | 2024-03-20 |
| JDK 23 | 1.18.36 | 2024-11-15 |
| JDK 24 | 1.18.38 | 2025-03-31 |
| JDK 25 | 1.18.40 | 2025-09-04 |
| JDK 26 | 1.18.46 | 2026-04-22 |
每个 JDK 大版本都对应一次 Lombok 适配。本次事故中项目用的 1.18.30 对应的上限正是 JDK 21,而实际构建 JDK 是 26,隔了五个大版本。
5.2 JDK 关闭默认注解处理:优化了什么,牺牲了什么
站在 JDK 的角度,"classpath 上出现注解处理器就自动执行它"是一个 2006 年做出的默认决策。当时的依赖规模和今天完全不是一个量级。
这个默认值的实际含义是:任何一个进入 classpath 的 jar,只要在 META-INF/services/javax.annotation.processing.Processor 里注册自己,就能在编译期执行任意代码,并影响最终产物。 传递依赖引入的 jar 也算。这既是构建可重现性问题,也是供应链攻击面。
改成默认关闭,优化的是构建的可预测性和安全性,牺牲的是兼容性------所有依赖隐式注解处理的构建都会在升级到 JDK 23 时行为改变,而且改变的方式是"静默地少做一件事"。
JDK 团队显然清楚这个代价,所以做了两年过渡:JDK 21、22 打印提示信息,-proc:full 向后移植到 8u/11u/17u。这套过渡方案对持续跟进 JDK 版本的项目是有效的。它失效的场景恰恰是本文这种:项目长期停在 JDK 17,从未见过 JDK 21/22 的提示,然后被包管理器一步跨到 JDK 26。
5.3 两个正确的决策叠加出一个错误的体验
这才是这次事故真正值得记录的地方。
- Lombok 用反射操作 javac 内部:在它的约束下是合理选择;
- JDK 关闭默认注解处理:在安全性考量下是合理选择;
- 但两者叠加的结果是:Lombok 失效时,连报错的机会都没有。
如果只有 Lombok 的版本不兼容问题(断点 2),报错是 ExceptionInInitializerError 加完整堆栈,五分钟定位。如果只有默认策略变更(断点 1)而用的注解处理器是规范内的代码生成器,那缺失的是新生成的类,报错同样直指要害。
偏偏 Lombok 生成的是已有类的成员,它不生效的表现就是"这些成员从来没存在过"------与"你写错了代码"在编译器看来完全无法区分。
可迁移的判断:当一个组件的失效模式是"少做了一件事"而不是"做错了一件事"时,它的可观测性会急剧下降。 设计任何"默认启用、可被关闭"的机制时,关闭路径都应该留下痕迹。JDK 21/22 的提示信息正是为此而加,但它在跨版本跳跃时会被整段跳过------过渡期告警只对逐版本升级的用户有效,这是这类迁移方案的固有盲区。
6. 实践与避坑
按影响面从大到小排列,每条都说明"不做会怎样"。
6.1 JAVA_HOME 放 .zshenv,不要放 .zshrc
根因是 JAVA_HOME 为空。但仅仅把 export JAVA_HOME=... 写进 ~/.zshrc 是不够的,因为 zsh 的启动文件加载时机不同:
| 文件 | 交互式终端 | 非交互式(脚本 / IDE / CI / 工具子进程) |
|---|---|---|
.zshenv |
加载 | 加载 |
.zprofile |
加载(登录 shell) | 不加载 |
.zshrc |
加载 | 不加载 |
实测对比,配置只写在 .zshrc 时:
console
$ zsh -i -c 'echo $JAVA_HOME'
/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
$ zsh -c 'echo "[$JAVA_HOME]"'
[]
不做会怎样 :手敲 mvn 正常,但脚本里调 mvn、CI 跑构建、IDE 起子进程时又会掉回错误的 JDK,同样的问题周期性复发,且更难联想到根因。
正确做法:
bash
# ~/.zshenv
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
IDE 是完全独立的一套,不读任何 shell 配置(除非从终端启动)。IntelliJ IDEA 需要单独确认 Settings → Build Tools → Maven → Runner → JRE 和 Project Structure → Project → SDK。
6.2 显式声明 annotationProcessorPaths
不要依赖"Lombok 在 compile classpath 上,javac 会自动发现它"这个隐式行为------它在 JDK 23 已经不成立了。
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
配置后插件会显式传 --processor-path。实测该选项可以重新激活注解处理,效果与 -proc:full 相同。
不做会怎样 :构建行为依赖 JDK 版本的默认策略,跨大版本升级时静默改变。较新版本的 maven-compiler-plugin 还提供了 <proc> 参数可显式设为 full,具体从哪个版本引入以你依赖的插件版本文档为准。
这条还有个额外收益:把处理器路径与编译 classpath 分开,Lombok 就不会被当成运行时依赖误传递下去。
6.3 升级过老的 maven-compiler-plugin
本次事故中的插件版本是 2.3.2,发布于 2011 年。构建日志里有一条容易被忽略的告警:
text
[WARNING] Parameter 'parameters' is unknown for plugin 'maven-compiler-plugin:2.3.2:compile'
意思是父 pom 里配置的 <parameters>true</parameters> 根本没有生效 ------这个参数让 javac 保留方法参数名(-parameters),Spring 的构造器注入、不写 value 的 @PathVariable、以及部分 JSON 反序列化都依赖它。
不做会怎样:一个配了但不生效的参数,比没配更危险------你以为有,实际没有,问题会在运行期以"参数名变成 arg0"的形式爆出来。
遇到 Parameter 'X' is unknown for plugin 这类告警要当错误看,它说明你的配置和插件版本对不上。
6.4 用 --release 替代 source / target
顺带一提本次排查中发现的另一个隐患。项目用的是:
xml
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
-target 8 只保证字节码版本 是 52,但编译时链接的仍是当前构建 JDK 的类库。用 JDK 26 编译出 target 8 的字节码,你可以调用到 Java 8 里不存在或签名不同的 API。
经典案例是 ByteBuffer.flip():Java 8 里它继承自 Buffer,返回 Buffer;Java 9 加了协变返回类型,返回 ByteBuffer。用高版本 JDK 编译、低版本 JDK 运行,就会在运行期抛 NoSuchMethodError。
xml
<!-- 推荐 -->
<maven.compiler.release>8</maven.compiler.release>
--release 8 会使用 JDK 内置的 Java 8 API 签名做校验,编译期就能发现这类问题。
不做会怎样 :本地编译全绿,部署到目标运行时报 NoSuchMethodError,且报错位置与真实原因无关。
6.5 CI 与本地锁同一个 JDK
.mvn/jvm.config、.sdkmanrc、CI 配置里的 setup-java,以及 Maven Toolchains,任选一种把构建 JDK 固定下来并纳入版本控制。
不做会怎样:本地能过 CI 挂、或者反过来,排查成本翻倍。本次事故本质上就是"本地 JDK 被包管理器悄悄改了",而这件事在任何一台开发机上都可能独立发生。
6.6 一条经验判断
看到满屏 找不到符号: 方法 getXxx() / 构造器参数长度不同 / 方法引用无效,且这些符号都由注解处理器生成时,先跑 mvn -v 看构建 JDK,不要读代码。
这三类报错的组合是注解处理器整体失效的特征信号。同样的判断也适用于 MapStruct、Dagger、AutoValue 等其他基于注解处理的框架------它们失效时同样是"生成物集体消失"。
7. 总结
三条可迁移的判断:
-
报错数量与问题数量无关。 上百个错误可能只有一个根因。先找报错之间的共性,再决定读哪段代码------本次的共性是"缺失的符号全部由 Lombok 生成",找到这一点后问题就从几十个类收敛到了一个环境变量。
-
java -version和mvn -v是两个独立的值。 前者走PATH,后者走JAVA_HOME,包管理器可能在你不知情时改变后者。排查构建问题时以mvn -v为准。 -
失效模式为"少做一件事"的组件,可观测性天然低下。 Lombok 反射 javac 内部(合理)+ JDK 23 关闭默认注解处理(合理)叠加,产生了一个连报错都没有的失效。设计任何"默认启用、可被关闭"的机制时,关闭路径都应该留下痕迹;而依赖"过渡期告警"的迁移方案,对跨版本跳跃的用户是失效的。
留一个开放问题:Lombok 这种"用反射突破规范限制换取能力"的模式,在 JDK 持续收紧内部访问(sun.misc.Unsafe 已标记为终将移除,从上面的告警里就能看到)的趋势下还能走多久?如果它某天走不下去,你的代码库里有多少 @Data 需要还原成手写代码?
8. 引用
- Quality Outreach Heads-up - JDK 23: Changes Default Annotation Processing Policy --- JDK 23 默认策略变更的官方公告与动机说明
- Quality Outreach Heads-up - JDK 22: Annotation Processing Behavior Change --- JDK 21/22 过渡期提示信息的说明
- JDK-8321319: Reinstate disabling the compiler's default active annotation processing --- 对应的 OpenJDK issue
- Proposal to change default annotation processing policy in JDK 23 --- jdk-dev 邮件列表上的提案与讨论
- JDK 23 Release Notes
- Lombok Changelog --- 各版本 JDK 支持情况的一手来源
- 本文涉及的 Lombok 内部类:
lombok.javac.apt.LombokProcessor、lombok.javac.Javac、lombok.javac.JavacTreeMaker、lombok.permit.Permit