Java 内存马(十):JDK 8、11、17、21、25 兼容性实战

同一段运行时代码,JDK 8 可以执行,换到 JDK 17 抛出 InaccessibleObjectException,到了 JDK 21 又出现动态 Agent 告警。看起来都是“JDK 不兼容”,实际可能分别属于字节码、Servlet 接口、依赖、模块权限和 Attach 策略。

开始排查前,先固定四项:

1
2
3
4
中间件产品与完整版本
Servlet API:javax 还是 jakarta
JDK 发行版、完整更新号与运行时镜像
JVM 启动参数、模块开放和 Agent

必须先纠正一个常见误区:javax.servlet.* 与 jakarta.servlet.* 不是 JDK 版本差异。

1
2
JDK 17 + Tomcat 9   → javax.servlet
JDK 17 + Tomcat 10 → jakarta.servlet

JDK 决定 JVM 能加载什么、允许访问什么;中间件版本决定请求组件实现哪套 Servlet 接口。两者必须分别判断。

一、看到异常,先定位失败层级

不同异常对应不同技术层,不能靠反复更换 JDK 碰运气。

现象 失败层级 第一检查项
UnsupportedClassVersionError Class 文件 字节码主版本
ClassCastException JVM 类型身份 javax/jakarta、ClassLoader
ClassNotFoundException 类查找 依赖、模块、目标 ClassLoader
NoClassDefFoundError 链接或初始化 缺失依赖、静态初始化失败
NoSuchMethodError 编译与运行依赖不一致 实际加载的 JAR 版本
InaccessibleObjectException 模块强封装 add-opens、目标模块和包
IllegalAccessException Java 访问控制 Lookup、成员可见性、模块开放
Attach 失败或 Agent 告警 JVM 动态能力 Attach 开关、Agent 加载方式

JDK 8—25 的 Class 主版本:

JDK Class 主版本 关键兼容边界
8 52 无平台模块系统,深度反射相对宽松
11 55 不再内置部分 Java EE 与 CORBA 模块
17 61 JDK 内部包强封装全面生效
21 65 动态加载 Agent 产生明确告警
25 69 Security Manager 已无法启用

大版本只能确定方向,更新号同样重要。同为 JDK 8,不同更新对 JNDI、TLS 和反序列化过滤已有差异;记录中不能只写“Java 8”。

正确排错顺序是:

1
2
3
4
5
字节码能否加载
→ 对象是否实现容器需要的接口
→ 依赖类和方法是否存在
→ 模块是否允许访问
→ Agent 与 Attach 是否开放

前一层没有通过,不要跳到后一层添加 JVM 参数。

二、五分钟完成一次兼容性定位

2.1 先从目标进程采集事实

不要使用当前终端的 java -version 代替生产进程。jcmd 必须在目标 JVM 所在主机执行,并通常需要使用相同的操作系统用户;优先使用目标 JDK 自带的工具。

1
2
3
4
jcmd 2481 VM.version
jcmd 2481 VM.command_line
jcmd 2481 VM.flags
jcmd 2481 VM.system_properties

不同 HotSpot 版本提供的诊断命令可能不同,先确认实际支持范围:

1
jcmd 2481 help

这组输出能够确定 Java Home、JVM 发行版、完整启动参数、模块开放参数、Agent 和关键系统属性。

2.2 用 javap 确认字节码版本

无需先写解析程序,直接查看 Class 文件:

1
javap -verbose Probe.class

在输出中找到:

1
major version: 61

主版本 61 表示该类至少需要 JDK 17。使用较新 JDK 编译旧目标时应使用 --release:

1
2
3
javac --release 8  -d out8  src/lab/Probe.java
javac --release 11 -d out11 src/lab/Probe.java
javac --release 17 -d out17 src/lab/Probe.java

--release 从 JDK 9 开始提供,每个 javac 支持的目标范围不同,应先运行 javac --help。它同时限制 Class 版本和可调用的标准 API;只硬改主版本号,仍会在链接阶段失败。

2.3 打印完整类型身份

判断一个对象能否注册为 Filter,不能只看简单类名:

1
2
3
4
5
6
7
8
9
10
11
12
13
Class<?> type = candidate.getClass();

System.out.println("class=" + type.getName());
System.out.println("loader=" + type.getClassLoader());
System.out.println("module=" + type.getModule());
System.out.println("source="
+ type.getProtectionDomain().getCodeSource());

for (Class<?> iface : type.getInterfaces()) {
System.out.println("interface=" + iface.getName()
+ ", loader=" + iface.getClassLoader()
+ ", module=" + iface.getModule());
}

JVM 类型身份由“全限定名 + 定义它的 ClassLoader”共同决定。即使两个接口都叫 javax.servlet.Filter,由不同 ClassLoader 定义时也不能相互强制转换。

Class#getModule() 适用于 JDK 9 及以上;在 JDK 8 环境去掉 Module 输出即可。

2.4 用 jdeps 查依赖,不靠猜

JDK 11 移除了过去由 JDK 提供的部分 Java EE 与 CORBA 模块。先分析目标 JAR 的静态依赖:

1
jdeps --recursive runtime-component.jar

模块化 JDK 还可以列出运行时镜像:

1
java --list-modules

JDK 8 没有平台模块系统,应核对启动 ClassPath、扩展目录和应用服务器提供的依赖。jlink 精简镜像还可能移除 jdk.compiler,可直接探测:

1
2
System.out.println(javax.tools.ToolProvider.getSystemJavaCompiler()
== null ? "compiler unavailable" : "compiler available");

最后确认运行时实际加载的类,而不是 Maven 理论依赖:

1
2
3
4
5
6
7
Class<?> type = Class.forName(
"target.Type",
false,
Thread.currentThread().getContextClassLoader());

System.out.println(type.getClassLoader());
System.out.println(type.getProtectionDomain().getCodeSource());

三、JDK 17 之后真正变化了什么

3.1 setAccessible 不再是万能钥匙

旧代码经常反射调用 ClassLoader#defineClass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java.lang.reflect.Method method =
ClassLoader.class.getDeclaredMethod(
"defineClass",
String.class,
byte[].class,
int.class,
int.class);

try {
method.setAccessible(true);
System.out.println("deep reflection allowed");
} catch (RuntimeException error) {
System.out.println(error.getClass().getName());
System.out.println(error.getMessage());
}

JDK 17 的强封装环境中通常得到 InaccessibleObjectException。针对性开放示例:

1
--add-opens java.base/java.lang=ALL-UNNAMED

它不是代码修复,而是扩大未命名模块访问 java.lang 私有成员的能力。生产环境出现异常 --add-opens、--add-exports 或 --patch-module 时,应当进入启动参数审计。

反射 Tomcat 应用类路径中的私有字段,不一定直接被 JDK 模块系统阻止;它更可能因 Tomcat 版本更换字段名而失败。不要把所有反射异常都归因于 JDK。

3.2 动态类定义有多条路径

路径 主要约束 检测重点
反射 ClassLoader#defineClass 深度反射、目标 ClassLoader 普通命名类和加载器
Lookup#defineClass Lookup 权限、字节码包名 Lookup 来源与模块开放
Lookup#defineHiddenClass Lookup、包关系、Hidden 选项 类加载事件和堆引用
Proxy、Lambda、字节码框架 接口、缓存、框架 ClassLoader 生成类数量与调用来源
Instrumentation Agent 能力、目标类可修改性 运行时字节码与 Transformer

合法 Lookup 定义普通类:

1
2
3
4
5
6
java.lang.invoke.MethodHandles.Lookup targetLookup =
java.lang.invoke.MethodHandles.privateLookupIn(
anchorClass,
java.lang.invoke.MethodHandles.lookup());

Class<?> defined = targetLookup.defineClass(classBytes);

目标模块必须向调用模块开放对应包,字节码包名也必须与 Lookup Class 一致。privateLookupIn() 不能绕过模块边界。

Hidden Class 无法通过 Class.forName() 或 ClassLoader.loadClass() 查找,也不能被重定义或重转换;但 Instrumentation.getAllLoadedClasses() 会包含 Hidden Class,getInitiatedClasses() 则不会。排查时不能只遍历 ClassLoader 的普通名称缓存,也不能把框架合法生成的 Hidden Class 全部判为恶意。

四、Agent、Attach 与升级验收

JDK 21 开始,动态加载 Java Agent 或 JVM TI Agent 会输出明确告警。调查时同时检查:

1
2
3
4
5
6
-javaagent
-agentlib
-XX:+EnableDynamicAgentLoading
-XX:-EnableDynamicAgentLoading
-XX:+DisableAttachMechanism
-Djdk.instrument.traceUsage

这些参数的含义不同:

参数 作用
-javaagent JVM 启动时加载 Java Agent
-agentlib JVM 启动时加载原生 Agent
+EnableDynamicAgentLoading 明确允许动态 Agent,并抑制相应告警
-EnableDynamicAgentLoading 拒绝动态加载 Agent
+DisableAttachMechanism 禁用 HotSpot Attach 机制
jdk.instrument.traceUsage 输出 Instrumentation API 调用与栈信息

APM、RASP、诊断工具和测试框架也会合法使用 Agent。判断依据应是 JAR 来源、批准记录、加载时间、调用栈和被修改的类,而不是“发现 Agent 就判恶意”。

JDK 24 起,命令行启用 Security Manager 会导致 JVM 启动失败,运行时也不能再安装自定义 Security Manager;JDK 25 延续这一行为。旧系统依赖的策略文件不再构成有效沙箱,升级后必须改用操作系统权限、容器隔离、网络策略和应用最小权限。

最终验收不要只看应用能否启动。每个真实组合都应记录:

1
2
3
4
5
6
7
8
JDK 发行版、完整版本和 Java Home
中间件版本、补丁与 Servlet API
Class 主版本和编译参数
ClassLoader、Module 与 CodeSource
JVM flags、模块开放与 Agent
成功结果或完整异常链
运行时组件能否枚举和卸载
重启后是否出现复活

兼容性测试的目标不是让旧代码“想办法跑起来”,而是明确它依赖了哪些 JVM 权限、类型和动态能力。只有这些依赖可解释、可审计、可回滚,升级才算真正完成。

上一篇
下一篇