从 JDK 8 升到 21:类加载器先查哪三个旧假设
升级到 JDK 21 时,先检查“系统加载器一定是 URLClassLoader”“null 父级能看到全部平台类”“核心类都在 rt.jar”这三个假设。默认的先父后子委派仍在,但父级可见范围和运行库布局已经改变。
父级决定插件能复用哪些已加载类型,插件自己的 URL 决定委派未命中后去哪里找。平台父级能减少对宿主应用依赖的可见性,应用父级便于共享宿主接口;应先确定接口由谁定义,再决定隔离范围。
常见故障是老插件扫描器启动就出现 ClassCastException,改完强转又找不到 JDBC 接口,最后读核心类资源时把 jrt: 当成本地文件路径。它们发生在不同层,逐项验证比盲目添加 JVM 参数更有效。
1. 把版本边界写进排查记录
| 项目 | JDK 8 常见 Oracle/OpenJDK 实现 | JDK 9+,包括 21 |
|---|---|---|
| 中间层 | 扩展类加载器,读取扩展目录 | 平台类加载器,扩展目录机制移除 |
| 内建系统加载器 | 常见实现继承 URLClassLoader | 不再继承 URLClassLoader |
| 核心运行库 | rt.jar 等 JAR | 模块化运行时镜像,资源可能为 jrt: |
| 启动加载器在 API 中的表示 | null | 仍为 null,但定义的平台类范围变小 |
这是官方 JDK 的迁移对照,不是所有 JVM 和容器的承诺。平台类能经平台加载器访问,不表示全部由它定义;也不能把内部类名当稳定 API。JDK 21 迁移指南 说明了这些变化。
在默认的 URLClassLoader.loadClass 路径中,先查已加载类,再委派父级,最后才查自己的 URL。父级为 null 时改由启动加载器查找,不是跳过委派,也不是自动委派给平台加载器。空 URL 列表又没有本地后备来源,所以本例无法找到由平台加载器定义的 JDBC 接口。以下是本例的父级取舍,不代表容器自定义加载策略:
| 插件父级 | 空 URL 时的可见性与取舍 |
|---|---|
null | 可见启动加载器提供的类;本例能找到 Object,找不到 java.sql.Connection |
getPlatformClassLoader() | 可访问平台类;不经默认父级链引入宿主 classpath,插件仍需自行提供业务依赖 |
| 宿主接口的定义加载器 | 默认委派可复用该宿主接口,避免接口重复定义;也会扩大插件对宿主依赖的可见性 |
这里区分的是“能通过某加载器访问”和“由它定义”,两者不同。委派顺序依据 ClassLoader.loadClass,父级为 null 的含义也在该 API 中说明。
2. 先运行探针,再改业务加载逻辑
下载完整的 ClassLoaderProbe.java。它检查当前系统加载器、核心资源,并比较两个空 URL 加载器的父级。关键操作如下,完整文件还包含输出与异常处理:
ClassLoader app = ClassLoader.getSystemClassLoader();
ClassLoader platform = ClassLoader.getPlatformClassLoader();
System.out.println(app instanceof URLClassLoader);
try (URLClassLoader plugin = new URLClassLoader(new URL[0], platform)) {
Class<?> sql = plugin.loadClass("java.sql.Connection");
System.out.println(sql.getModule().getName());
}在下载目录编译完整文件,不要把上面的方法体片段单独当作源文件:
javac -encoding UTF-8 --release 21 ClassLoaderProbe.java
java -cp . ClassLoaderProbe本机 JDK 21.0.12 实际 stdout:
Object.loader=null
app.isURLClassLoader=false
app.parentIsPlatform=true
Object.resource=jrt:/java.base/java/lang/Object.class
legacyCast=ClassCastException
nullParent.sql=ClassNotFoundException
platformParent.sql.module=java.sqlObject.loader=null 表示启动加载器定义了该类,不能解释为没加载成功。在本次完整 JDK 上,空 URL 加载器用 null 父级找不到 java.sql.Connection,改为平台父级则能找到;这证明旧假设失效,并不要求其他平台类都归平台加载器定义。
3. 对应修复,不把一个症状扩成大改造
- 系统加载器强转失败。 删除对内建实现的强转。业务要加载插件 JAR 时,自己维护明确的插件 URL 列表,创建独立
URLClassLoader,在插件停止使用后关闭;不要反射调用系统加载器的内部addURL。关闭会释放它打开的文件,阻止从它自己的路径加载新类或资源,但已加载类及父级提供的类仍可访问,因此close()不能作为热卸载成功的证明;也不应在插件仍可能延迟加载依赖时提前关闭。见 URLClassLoader.close。 - 平台接口找不到。 先记录父加载器。需要平台 API、又要隔离应用依赖时,考虑平台加载器作为父级;插件还需要宿主业务接口时,则可能需要应用加载器作为父级。父级选择由依赖可见性决定,不能一律替换。
- 资源读取失败。 用
getResourceAsStream读取所需资源;只有明确要枚举运行时镜像时才使用相应文件系统接口。不要拼接rt.jar路径,也不要把任意资源 URL 直接转成磁盘File。流式读取只避免依赖 URL 的存储形式,不会绕过模块封装:ClassLoader.getResourceAsStream查找命名模块包内的非.class资源时,要求该包无条件开放,否则可能返回null。因此Object.class资源可读不能证明模块内配置文件也可读,应检查资源名、所属模块与开放规则。见 getResourceAsStream 的封装约束。
扩展目录里的旧 JAR 应改为显式 classpath 或按模块化要求处理;平台加载器不是换了名字的“用户 JAR 投放目录”。ClassLoader API 是查父级、加载及资源查找规则的主入口。
4. 留一份可比较的升级记录
在同一启动方式下保存 JDK 完整版本、classpath、模块启动参数和异常首个业务栈帧;再打印故障类型的 getClassLoader() 与 getModule()。开发工具直接运行、可执行 JAR、容器启动的可见性可能不同,只在 IDE 里成功不能替代实际入口复查。探针源码本身调用了 Java 9 才提供的平台加载器与模块 API,所以不能直接拿它在 JDK 8 编译。
如果诊断落在模块导出或深反射访问限制,先升级依赖、改用公开 API,再研究所需的精确开放参数。给加载失败随意加 --add-opens 通常不会补出缺失的 JAR;改变父级也不会自动解决两个插件依赖互不兼容的问题。每项修复都应对应一条具体异常和复现入口。
5. 哪些错误不能靠换父级解决
同名类型由不同定义加载器加载,仍是不同类型,可能出现看似“自己不能转成自己”的异常。应同时记录二进制类名、定义加载器及模块,再查接口 JAR 是否重复。ClassNotFoundException、链接时依赖缺失与模块访问拒绝也应分别记录,不能统统归为委派失败。
本探针只在普通 classpath、完整 JDK 21 运行,未加载真实插件、未验证热卸载或自定义模块层;JDK 8、裁剪镜像、应用服务器需各自复跑。完整 实测说明与 stdout 保留本次边界,更多问答见 JVM 旧文,主资料入口为 Java 面试资料。
