rea-reverse

解决 Unsupported class file major version 和 UnsupportedClassVersionError

发布者 rea-reverse更新于 约 8 分钟读完
本页目录

有两类报错长得很像,里面都带一个数字。一类是 JVM 加载类时报的:

java.lang.UnsupportedClassVersionError: demo/Hello has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0

另一类是读字节码的工具报的,比如 Gradle 里的 Groovy 编译器、Android 的 D8:

java.lang.IllegalArgumentException: Unsupported class file major version 61

这个数字是 class 文件的 major version,表示这个类是哪个 Java 版本编译的。但两条消息的意思不一样:前一条说的是运行时太旧,后一条多半是某个工具太旧,读不了它拿到的 JDK 或类。本文用几个小程序把这两种报错都故意复现一遍,介绍四种查看 class / jar 版本的方法,再分别修好。

适合在构建或启动时碰到这类报错的 Java、Kotlin、Android 开发者。全部操作在 macOS 27.0.1(Apple silicon)上完成,用的是 Homebrew 的 OpenJDK 11.0.32.1、17.0.18、25.0.2,Gradle 6.7.1 和 9.6.1,以及 Android SDK build-tools 30.0.3 和 Command-line Tools 20.0 里各自带的 D8。输出都是实际运行结果,系统语言是中文,所以部分 javac 输出是中文的。

数字怎么换算:major version = Java 版本 + 44

每个 .class 文件的第 4 到 7 字节是 minor 和 major 版本号。从 Java 5 开始,major 就是版本号加 44。常见的对照如下:

Major Java Major Java
52 8(LTS) 62 18
53 9 63 19
54 10 64 20
55 11(LTS) 65 21(LTS)
56 12 66 22
57 13 67 23
58 14 68 24
59 15 69 25(LTS)
60 16 70 26
61 17(LTS) 71 27

更早的:45 是 Java 1.0.2/1.1,46 到 48 是 1.2 到 1.4,49 到 51 是 Java 5 到 7。minor 为 65535 表示用了预览特性,只能在编译它的那个版本上加 --enable-preview 运行。

复现运行时报错

一个一行输出的 Hello 类,用 JDK 25 分别编译成五个目标版本:

for r in 8 11 17 21 25; do
  javac --release $r -d out/r$r src/demo/Hello.java
done

--release 8 那次已经开始警告了:

警告: [options] 源值 8 已过时,将在未来发行版中删除
警告: [options] 目标值 8 已过时,将在未来发行版中删除
警告: [options] 要隐藏有关已过时选项的警告, 请使用 -Xlint:-options。
3 个警告

类还是照样生成了。把 --release 21 的类放到 JDK 17 上跑:

$ /opt/homebrew/opt/openjdk@17/bin/java -cp out/r21 demo.Hello
错误: 加载主类 demo.Hello 时出现 LinkageError
	java.lang.UnsupportedClassVersionError: demo/Hello has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0

注意第一行被本地化成了中文,异常本身的消息还是英文。两个数字都在里面:65 是这个类需要的(Java 21),61 是当前运行时能加载的上限(Java 17)。--release 17 的 jar 放到 JDK 11 上也一样,变成 class file version 61.0 和 up to 55.0。

javac 还有两个相关报错,经常和上面一起被搜。JDK 17 要求编译到 21:

$ javac --release 21 ...
错误: 不支持发行版本 21

JDK 11 编译一段引用了 Java 17 jar 里类的代码:

out/use/demo2/Use.java:2: 错误: 无法访问Hello
  错误的类文件: out/hello-17.jar(/demo/Hello.class)
    类文件具有错误的版本 61.0, 应为 55.0

三种情况的修法方向一致:要么用不低于该类版本的 JDK 来运行或编译,要么把类重新编译到更低的版本。

四种查看版本的方法

file,macOS 自带(这里是 file-5.41):

out/r11/demo/Hello.class: compiled Java class data, version 55.0 (Java SE 11)
out/r17/demo/Hello.class: compiled Java class data, version 61.0
out/r21/demo/Hello.class: compiled Java class data, version 65.0
out/r25/demo/Hello.class: compiled Java class data, version 69.0
out/r8/demo/Hello.class:  compiled Java class data, version 52.0 (Java 1.8)

数字都打出来了,但只有 52 和 55 后面带了版本名。这是这个 file 版本的 magic 库只认识这两个,和类本身没关系。另外它只能看散的 .class,看不了 jar 里的类。

直接看字节。对 release 21 的类执行 xxd -l 8,输出 cafe babe 0000 0041:CAFEBABE 是魔数,0000 是 minor,0x41 就是 65。

javap -v,单个文件和 jar 里的类都能看:

$ javap -v out/r21/demo/Hello.class | grep version
  minor version: 0
  major version: 65
$ javap -v -cp out/hello-17.jar demo.Hello | grep version
  minor version: 0
  major version: 61

grep version 在我们这个类里还额外匹配到了三行(常量池里的 "java.version" 字符串和加载它的那条指令),代码里有类似字符串时,直接 grep major version 更干净。

本站的 Java class 版本检查工具,在浏览器里读 jar、WAR、EAR、AAR 里的每一个类,嵌套的 jar 也会打开。之所以需要它,是因为一个 jar 里可能混着好几个版本。我们打了一个 mixed.jar:里面是 release 11 的 Hello、release 21 的 Last,再把 release 17 的 jar 嵌在 lib/ 下。然后在 Node.js 下直接跑工具的代码(站点仓库里的 src/tools/java/analyze.ts):

== mixed.jar kind=jar archives=2 classes=3
  archive "" manifest=[["Created-By","25.0.2 (Homebrew)"],["Main-Class","demo.Hello"]]
  archive "lib/hello-17.jar" manifest=[["Created-By","25.0.2 (Homebrew)"]]
  [(upload)] demo/Hello.class 55.0
  [(upload)] demo/Last.class 65.0
  [lib/hello-17.jar] demo/Hello.class 61.0

这段输出里有两点要多看一眼。第一,Created-By: 25.0.2 (Homebrew) 记录的是运行 jar 命令的那个 JDK,跟编译目标毫无关系:同一个 JDK 25 编出了 55、61、65 三种版本的类。想知道 “这个 jar 需要哪个 Java”,别看 Created-By 或 Build-Jdk。第二,JVM 是按类、在加载时才检查版本的。在 JDK 17 上执行 java -cp mixed.jar demo.Hello,正常打印 hello from 17.0.18,退出码 0;只有加载 demo.Last 时才报 class file version 65.0。也就是说一个 jar 可以正常跑好几周,直到某条代码路径第一次碰到那个新版本的类才炸。对整个 jar 做一次版本扫描,可以赶在用户之前把这个类找出来。

工具里还有一个报错解读功能。我们把上面抓到的报错都贴了进去:运行时报错能识别(类 demo/Last、major 65、运行时上限 61),Gradle 和 D8 的 “Unsupported class file major version” 能识别,javac 英文版的 “wrong version 61.0, should be 55.0” 和 “release version 21 not supported” 也能识别。最初的版本认不出中文的 javac 输出,错误: 不支持发行版本 21 和 类文件具有错误的版本 61.0, 应为 55.0 贴进去都没有结果。后来我们用 -J-Duser.language=zh -J-Duser.country=CN 分别跑了 javac 11、17、25,照着它们实际打印的原文补上了这几种:类文件具有错误的版本 X, 应为 Y(class 版本太新)、不支持发行版本 N(--release 超出编译器支持范围)、无效的目标发行版:N 和 无效的源发行版:N(-target / -source 太新)。现在把上面那两段中文报错贴进去,结果和英文版一样。日文等其他语言环境仍然认不出,遇到时给 javac 加 -J-Duser.language=en 再复制。JVM 自己的 UnsupportedClassVersionError 消息在中文环境下也是英文,只有启动器那一行 错误: 加载主类 … 时出现 LinkageError 是中文,直接贴就行。

复现工具报错:旧 Gradle 跑在新 JDK 上

Gradle 6.7.1 跑在 JDK 17 上,构建脚本只有一句 plugins { id 'java' }:

* What went wrong:
Could not compile settings file '.../gr6/settings.gradle'.
> startup failed:
  General error during semantic analysis: Unsupported class file major version 61

  java.lang.IllegalArgumentException: Unsupported class file major version 61
  	at groovyjarjarasm.asm.ClassReader.<init>(ClassReader.java:196)

这里的 61 不是我们自己的类,settings.gradle 总共就一行。出问题的是 JDK 17 自带的类:Gradle 编译构建脚本时,内置的 ASM 去读它们,而这个 ASM 比 Java 17 老。项目本身没有任何要改的地方,是 Gradle 版本和运行它的 JDK 不匹配。同一个项目换到 JDK 11 上一次就过,编出来的类是 version 55.0 (Java SE 11)。

所以 Gradle 报这条错时,先看是哪个 JDK 在跑 Gradle(./gradlew --version 会打印),再去怀疑依赖。

在 Android 构建里复现:D8

D8 负责把 class 转成 DEX,它也有自己的版本上限。build-tools 30.0.3 里带的是 D8 2.1.7-r1,我们把几个 jar 都喂给它:

Jar D8 2.1.7-r1(build-tools 30.0.3) D8 9.0.32(cmdline-tools 20.0)
--release 11(55) 生成 classes.dex 未测
--release 17(61) Unsupported class file major version 61 未测
--release 21(65) Unsupported class file major version 65 未测
--release 25(69) Unsupported class file major version 69 生成 classes.dex

老 D8 先打印 Compilation failed with an internal error.,后面跟着那条 IllegalArgumentException。Gradle 构建里 D8 的版本由 Android Gradle 插件决定,所以修法是升级 AGP,或者把那个库降到为更低 Java 版本编译的版本。日志里点名的 AAR 或 JAR 拖进版本检查工具,就能看出是哪一个。

修法一:用 --release,别用 -source/-target

最常见的翻车方式是在新 JDK 上构建、部署到旧 JDK 上。--release N 会让 javac 输出版本 N 的 class,并且按 Java N 的 API 来编译;-source N -target N 只做前一半。JDK 25 会对此发出警告(这次我们用 -J-Duser.language=en 跑的):

warning: [options] location of system modules is not set in conjunction with -source 17
  not setting the location of system modules may lead to class files that cannot run on JDK 17
    --release 17 is recommended instead of -source 17 -target 17 because it sets the location of system modules automatically

这条警告到底意味着什么,我们用一个调用了 List.getLast()(Java 21 才有的方法)的类试了一下:

$ javac -source 17 -target 17 -d out/t17 src2/demo/Last.java     # 退出码 0,1 个警告
$ file out/t17/demo/Last.class
out/t17/demo/Last.class: compiled Java class data, version 61.0
$ /opt/homebrew/opt/openjdk@17/bin/java -cp out/t17 demo.Last
Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.Object java.util.List.getLast()'
	at demo.Last.main(Last.java:9)

类的版本写的是 61,任何版本检查都能通过,可在 Java 17 上照样崩。换成 --release 17,同一个文件在编译期就报错了:

src2/demo/Last.java:9: error: cannot find symbol
    System.out.println(xs.getLast());
  symbol:   method getLast()

-target 这种情况任何版本检查工具都发现不了,因为版本号本身是对的。只有 --release(或者干脆用旧 JDK 编译)能拦住。

修法二:Gradle toolchain

Gradle 的 toolchain 决定用哪个 JDK 编译,和跑 Gradle 的 JDK 互不影响:

java {
  toolchain {
    languageVersion = JavaLanguageVersion.of(11)
  }
}

我们在 JDK 25 上用 Gradle 9.6.1 跑这段配置,关掉自动下载,结果先碰上环境问题:

Cannot find a Java installation on your machine (Mac OS X 27.0.1 aarch64) matching: {languageVersion=11, vendor=any vendor, implementation=vendor-specific, nativeImageCapable=false}. Toolchain auto-provisioning is not enabled.

JDK 11 明明装了,但 Homebrew 的 openjdk@11 是 keg-only,不会注册到 /usr/libexec/java_home(这台机器上 java_home -V 只列出了 Temurin 17),Gradle 自然找不到。手动告诉 Gradle 路径就好了:

gradle -Porg.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@11 jarForCheck

之后 gradle javaToolchains 里出现了 Homebrew JDK 11 (11.0.32.1+0),来源写的是 Detected by: Gradle property 'org.gradle.java.installations.paths',编出来的类是 version 55.0 (Java SE 11)。长期使用的话,把这个属性写进 gradle.properties。Maven 里对应的是 maven.compiler.release 属性,这次没有测 Maven。

修法三:换更新的 JVM

如果出问题的类来自第三方依赖、没法重新编译,那运行时版本就必须不低于它。报错里已经写了下限:class file version 65.0 就是 Java 21 及以上。Android 应用不适用这一条,设备上跑的是 DEX 不是 class,限制在 AGP 里的 D8,上面已经讲过。

REA 在这里的位置

REA(morluto 的开源项目)不检查 class 文件版本。[email protected] 的 Android 分析通过无界面 JADX 完成,包里的 README 写明需要机器上有 “a full JDK”。如果 REA 的 Android 工具因为 JDK 出错,判断思路和上面一样:先看跑 JADX 的是哪个 JDK,再去看 APK。JADX 的配置见 用 REA 分析 Android APK。

局限

  • 只测了手上有的 JDK:11、17、25。Java 8 运行时那种老格式的报错(“Unsupported major.minor version 52.0”)这次没有复现。
  • Gradle 的结论只针对 6.7.1。哪个 Gradle 版本开始支持在 Java 17 上运行,我们没有逐个测,请查 Gradle 官方的兼容性表。
  • 版本号查不出 API 层面的问题,-target 那个例子就是证明。

下一步