一个组件要交付给用 JDK 8 的客户。为了兼容,在 pom.xml 里加了 -source 8 -target 8,本地跑测试全绿,打出来的 jar 也是 52 版字节码(Java 8 的版本号)。客户环境一启动就报 NoSuchMethodError: java.util.List.of。
原因只有一句:-source 和 -target 管的是"语法"和"字节码版本",不管"你调用的 API 是否存在"。编译时 classpath 上放的是 JDK 17 的类库,List.of 就在那儿,编译器当然放行;运行在 JDK 8 上,那个方法根本不存在。
这不是"配置少了一项",是编译器能验的边界与运行时会遇到的边界不是同一条。修法在 JDK 9 之后有了标准答案:--release。
1. 三个开关管三件事
| 开关 | 管语法 | 管字节码版本 | 管 API 可见性 |
|---|---|---|---|
-source 8 |
是(不能写 var) |
否 | 否 |
-target 8 |
否 | 是(52) | 否 |
-bootclasspath(JDK 8 的 rt.jar) |
是 | 是 | 是(但 JDK 9+ 已移除) |
--release 8 |
是 | 是 | 是(用 ct.sym 校验) |
第二行是那个陷阱的核心:-target 8 只是告诉编译器"输出 52 版 class",它不会检查你调用的方法在 Java 8 里存不存在。编译器忠实地按 JDK 17 的类库编译,然后标注成"Java 8 可运行"。
// 验证:同一个源码,两种配置,产物一样但可用性不同
$ javac -source 8 -target 8 -d out A.java
warning: [options] bootstrap class path not set in conjunction with -source 8
$ javap -v out/A.class | grep "major version"
major version: 52 // 确实是 Java 8 字节码
$ javap -c out/A.class | grep "List.of"
invokeinterface java/util/List.of:()Ljava/util/List; // 调用的是 JDK 9+ 才有的方法
// 结论:字节码版本对了,API 依赖错了
2. --release 是唯一能编译期拦住的做法
JDK 9 引入 --release N,它做三件事:限定语法、限定字节码版本、用 ct.sym(历史 API 签名库)校验你只调用了 N 版本里存在的 API。
| 配置 | 编译期能发现 List.of 不兼容吗 |
产物可运行在 JDK 8 |
|---|---|---|
-source 8 -target 8 |
不能 | 否(运行时 NoSuchMethodError) |
--release 8 |
能(编译直接失败) | 是 |
| 在 JDK 8 上编译 | 能(该 API 不存在) | 是 |
第三行是"最土但最可靠"的做法,问题是要在 CI 里维护第二个 JDK。所以 --release 是标准答案:一个 JDK 编译,编译期就把兼容性钉死。
<!-- Maven:一个属性替换掉 source/target 两项 -->
<properties>
<maven.compiler.release>8</maven.compiler.release>
</properties>
<!-- 注意:release 与 source/target 互斥,同时写会以 release 为准并告警 -->
// Gradle:targetCompatibility 不够,要显式用 release
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) } // 编译用 17
}
tasks.withType<JavaCompile> {
options.release.set(8) // 产物按 8 的 API 校验 ← 关键一行
}
3. 三种"编译通过、运行报错"的形状
不是只有 NoSuchMethodError 一种。三种形状的错误信息完全不同,认错了会查错方向。
| 错误 | 触发条件 | 含义 |
|---|---|---|
NoSuchMethodError |
调用 JDK 9+ 新增的方法 | API 不存在于目标版本 |
NoClassDefFoundError |
引用 JDK 9+ 新增的类 | 类不存在于目标版本 |
UnsupportedClassVersionError |
class 文件版本 > 运行时版本 | 字节码版本不对 |
InaccessibleObjectException |
用了 --add-opens 才允许的反射 |
模块化后的封装 |
AbstractMethodError |
接口新增默认方法被旧实现覆盖 | 版本混用 |
第一、二行是 -target 管不了的那类;第三行才是 -target 能管的。所以"字节码版本对了"完全不能推出"能跑",这两件事经常被当成一件。
// 一个最容易被忽略的组合:编译用 17,target 8
List<String> a = List.of("x", "y"); // JDK 9+ API → NoSuchMethodError
var m = new HashMap<String, Integer>(); // var 是语法,-source 8 会拦住
String s = "a".repeat(3); // JDK 11+ API → NoSuchMethodError
// 只有中间那行会在编译期失败,上下两行都会"成功编译、运行时炸"
4. 依赖的字节码目标:只能往上兼容
多模块项目里,这条规则比 --release 更容易被违反:被依赖方的 target 必须 ≤ 依赖方的 target。
| 依赖方向 | 合法? | 后果 |
|---|---|---|
| app(17) → lib(8) | 合法 | 运行时 JDK 17 能加载 52 版 class |
| app(8) → lib(17) | 不合法 | 运行时 UnsupportedClassVersionError |
| app(17) → lib(17) | 合法 | 但 app 就不能交付给 JDK 8 客户 |
| app(8) → lib(8) | 合法 | 交付目标一致 |
第二行是"本地用 JDK 17 跑得好,客户 JDK 8 一启动就崩"的另一半原因——问题不在你的代码,在你依赖的库。所以交付物要有一个显式检查:javap 扫一遍所有 class 的 major version。
// 交付前的自动检查:所有 class 都不高于 52(Java 8)
$ find target/classes -name '*.class' -exec sh -c \
'v=$(javap -v "$1" | awk "/major version/{print \$3}"); [ "$v" -gt 52 ] && echo "$1 $v"' _ {} \;
// 依赖 jar 用同样的办法抽检,尤其注意传递依赖里被"顺手"升级的那些
5. 父 POM 与插件管理的三处覆盖
配置写对了但没生效,几乎都在这三处。
| 位置 | 效果 | 常见错配 |
|---|---|---|
父 POM properties |
子模块默认继承 | 子模块自己声明了就覆盖 |
maven-compiler-plugin 配置 |
覆盖 properties | 只改 properties 但插件里写死了 |
| 打包插件(shade/assembly) | 不改字节码 | 有人以为它会影响版本 |
第二行最隐蔽:父 POM 里 maven.compiler.release=8,某个子模块的 pom.xml 里又写了 <source>17</source><target>17</target>,于是那个模块悄悄变成 61 版。而它不报错,只在交付时才暴露。
// 核对每个模块的"实际生效值",而不是"我以为设的值"
$ mvn -q help:evaluate -Dexpression=maven.compiler.release -DforceStdout
8
$ for m in module-a module-b; do (cd $m && mvn -q help:evaluate \
-Dexpression=maven.compiler.release -DforceStdout); done
// 若某个模块输出 17 或空,就是它覆盖了父配置
6. 排错:五种"版本对了却跑不起来"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
NoSuchMethodError 指向 JDK 方法 |
-target 不校验 API |
换 --release |
UnsupportedClassVersionError |
依赖的 target 更高 | 降依赖版本或降 target |
AbstractMethodError |
接口默认方法与旧实现混用 | 统一依赖版本 |
InaccessibleObjectException |
模块化封装 + 反射 | 加 --add-opens 或换 API |
| 编译成功但包体积异常大 | shade 把多版本 class 一起打进去 | 排除 META-INF/versions |
最后一行值得单独说:多版本 jar(MR-JAR)在 META-INF/versions/ 下放了高版本 class,shade 打包时如果没有排除,会出现"同一类两个版本",运行时按类加载顺序随机命中——这是最像"玄学"的一类版本问题。
7. 边界:什么情况不该降 target
| 场景 | 为什么不该降 | 该做什么 |
|---|---|---|
| 需要 record / sealed / 虚拟线程 | 语法与运行时都要求高版本 | 升客户的 JDK,或做双产物 |
| 依赖库已不支持 8 | 降 target 也降不了依赖 | 换库或锁旧版本(有安全债) |
| 只为了"看起来兼容" | 兼容是承诺,不是配置 | 明确交付矩阵,别猜 |
| 使用新 GC / 新诊断工具 | 运行时特性无法通过编译目标获得 | 分环境部署 |
判断口径一句话:如果这个产物要跑在 N 版本上,就必须用 --release N 且所有依赖的 target 都 ≤ N;做不到其中一条,"兼容 N"就只是一个没被验证的说法。而"降 target"从来不是兼容手段——它只是把不兼容推迟到运行时。
8. 自测
-source、-target、--release各管什么?哪一个能在编译期发现List.of不可用?- 为什么"字节码版本是 52"不能推出"能在 JDK 8 上跑"?
- 多模块里 target 的合法依赖方向是什么?反过来的典型报错是哪个?
- 父 POM 设了
release=8,什么情况下子模块仍会编出 61 版? AbstractMethodError与UnsupportedClassVersionError分别指向什么问题?- 什么情况下"降 target"根本不是解决办法?
9. 一句话总结
-source/-target 只约束语法与字节码版本,不约束 API 是否存在——所以"降到 Java 8 编译"的产物在 JDK 8 上会 NoSuchMethodError。唯一能在编译期钉死兼容性的是 --release N(用 ct.sym 校验 API),配套还要保证所有依赖的 target ≤ N。判据:产物要跑在哪,就用 --release 编译到哪;做不到就不是兼容,只是把崩溃推迟到运行时。