2257 字
约 7 分钟
13
S06-A27 · 编译目标:为什么"降到 Java 8 编译"上线就报 NoSuchMethodError

一个组件要交付给用 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. 自测

  1. -source、-target、--release 各管什么?哪一个能在编译期发现 List.of 不可用?
  2. 为什么"字节码版本是 52"不能推出"能在 JDK 8 上跑"?
  3. 多模块里 target 的合法依赖方向是什么?反过来的典型报错是哪个?
  4. 父 POM 设了 release=8,什么情况下子模块仍会编出 61 版?
  5. AbstractMethodError 与 UnsupportedClassVersionError 分别指向什么问题?
  6. 什么情况下"降 target"根本不是解决办法?

9. 一句话总结

-source/-target 只约束语法与字节码版本,不约束 API 是否存在——所以"降到 Java 8 编译"的产物在 JDK 8 上会 NoSuchMethodError。唯一能在编译期钉死兼容性的是 --release N(用 ct.sym 校验 API),配套还要保证所有依赖的 target ≤ N。判据:产物要跑在哪,就用 --release 编译到哪;做不到就不是兼容,只是把崩溃推迟到运行时。

S06-A27 · 编译目标:为什么"降到 Java 8 编译"上线就报 NoSuchMethodError
https://www.clxhxhhr.top/posts/6730/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。