同一段运行时代码,JDK 8 可以执行,换到 JDK 17 抛出 InaccessibleObjectException,到了 JDK 21 又出现动态 Agent 告警。看起来都是“JDK 不兼容”,实际可能分别属于字节码、Servlet 接口、依赖、模块权限和 Attach 策略。
开始排查前,先固定四项:
1 | 中间件产品与完整版本 |
必须先纠正一个常见误区:javax.servlet.* 与 jakarta.servlet.* 不是 JDK 版本差异。
1 | JDK 17 + Tomcat 9 → javax.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 | 字节码能否加载 |
前一层没有通过,不要跳到后一层添加 JVM 参数。
二、五分钟完成一次兼容性定位
2.1 先从目标进程采集事实
不要使用当前终端的 java -version 代替生产进程。jcmd 必须在目标 JVM 所在主机执行,并通常需要使用相同的操作系统用户;优先使用目标 JDK 自带的工具。
1 | jcmd 2481 VM.version |
不同 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 | javac --release 8 -d out8 src/lab/Probe.java |
--release 从 JDK 9 开始提供,每个 javac 支持的目标范围不同,应先运行 javac --help。它同时限制 Class 版本和可调用的标准 API;只硬改主版本号,仍会在链接阶段失败。
2.3 打印完整类型身份
判断一个对象能否注册为 Filter,不能只看简单类名:
1 | Class<?> type = candidate.getClass(); |
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 | System.out.println(javax.tools.ToolProvider.getSystemJavaCompiler() |
最后确认运行时实际加载的类,而不是 Maven 理论依赖:
1 | Class<?> type = Class.forName( |
三、JDK 17 之后真正变化了什么
3.1 setAccessible 不再是万能钥匙
旧代码经常反射调用 ClassLoader#defineClass:
1 | java.lang.reflect.Method method = |
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 | java.lang.invoke.MethodHandles.Lookup targetLookup = |
目标模块必须向调用模块开放对应包,字节码包名也必须与 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 | -javaagent |
这些参数的含义不同:
| 参数 | 作用 |
|---|---|
-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 | JDK 发行版、完整版本和 Java Home |
兼容性测试的目标不是让旧代码“想办法跑起来”,而是明确它依赖了哪些 JVM 权限、类型和动态能力。只有这些依赖可解释、可审计、可回滚,升级才算真正完成。