类加载器与委派边界:同名类为何不再是同一种类型
插件平台热更新后,日志里出现了一条看似荒谬的异常:com.example.PaymentPlugin cannot be cast to com.example.PaymentPlugin。重启后恢复,连续更新几轮,Metaspace 又开始上涨。依赖树里明明只有一个类名,运行时却像存在两个世界。
问题不在字符串形式的类名,而在类由谁定义。JVM 判断运行时类型时使用的是二元身份:
运行时类身份 = 二进制类名 + 定义类加载器
同一份 class 字节被两个互不共享定义结果的加载器加载,会得到两个不同的 Class 对象。插件实例、反射对象、线程上下文和外部注册表又可能把旧加载器留在 GC Roots 路径上,使整批类无法卸载。下面从一个稳定失败实验开始,把类型分裂、委派、访问异常和卸载串成同一条证据链。
三层内置加载器只负责建立默认可见性
在 JDK 9 及以后,常见内置层次是 Bootstrap、Platform 和 Application ClassLoader。Bootstrap 由虚拟机实现,在 Java 对象层查询时通常显示为 null;Platform 负责平台模块;Application 负责应用 class path 或 module path 上的类型。
JDK 8 的历史模型常写成 Bootstrap、Extension、Application;JDK 9 模块系统引入后,PlatformClassLoader 取代了旧 Extension 层的叙述方式。插件代码若需要同时运行在两类 JDK 上,不能通过比较加载器实现类名来判断能力,应以目标 JDK、模块/类路径配置和实际可见性验证为准。
| 关注点 | JDK 8 | JDK 9+ |
|---|---|---|
| 平台层称呼 | Extension ClassLoader | PlatformClassLoader |
| 模块访问控制 | 无 JPMS readability/exports/opens | 与类加载边界叠加生效 |
| 类加载日志 | 常见旧参数或统一日志能力有限 | -Xlog:class+load,class+unload |
| 诊断命令 | 依具体更新版本确认 | VM.classloaders、VM.metaspace 仍需先查 jcmd help |
这张层次图只描述默认可见性,不表示 JVM 强制所有自定义加载器都必须 parent-first。真正决定类型身份的是哪一个加载器最终调用或促成了 defineClass,它是定义加载器;最初发起 loadClass 的对象则是初始加载器,两者可能不是同一个。
先亲手制造一次“同名类不能转换为自己”
仓库已经提供完整实验,入口在 examples/backend-development/jvm/classloader-boundaries/LoaderIdentityDemo.java。它创建两个父加载器都为 null 的 URLClassLoader,分别加载同一个 LinkageProbe.class:
try (var leftLoader = new URLClassLoader(new URL[]{classes}, null);
var rightLoader = new URLClassLoader(new URL[]{classes}, null)) {
Class<?> left = leftLoader.loadClass("example.jvm.classfile.LinkageProbe");
Class<?> right = rightLoader.loadClass("example.jvm.classfile.LinkageProbe");
Object rightInstance = right.getDeclaredConstructor().newInstance();
System.out.println("same-name=" + left.getName().equals(right.getName()));
System.out.println("same-class=" + (left == right));
left.cast(rightInstance);
}在仓库根目录编译并运行:
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -cp out/jvm example.jvm.loader.LoaderIdentityDemo out/jvm类初始化日志可能先打印一行 <clinit>:runtimeValue=11,加载器对象的哈希值每次也可能不同;与类型身份有关的稳定输出是:
same-name=true
same-class=false
left-loader=java.net.URLClassLoader@...
cross-loader-cast=ClassCastExceptionsame-name=true 只证明二进制名相同;same-class=false 证明两个定义结果不同。left.cast(rightInstance) 使用左侧 Class 检查右侧实例,失败不是依赖版本比较的结果,即使两边字节完全相同也会发生。线上打印类型时,至少把名称、定义加载器、模块和代码来源放在同一条日志中:
Class<?> type = value.getClass();
System.out.printf("type=%s loader=%s module=%s source=%s%n",
type.getName(),
type.getClassLoader(),
type.getModule(),
type.getProtectionDomain().getCodeSource());loadClass 的父委派是一套可替换策略
JDK ClassLoader.loadClass 的典型流程是:先用 findLoadedClass 防止重复定义,再请求父层,父层找不到时调用当前加载器的 findClass。父优先让 JDK 核心类、共享 API、日志门面和跨插件 DTO 由稳定层统一定义,避免边界两侧各带一份。
插件隔离有时必须 child-first,例如两个插件需要不兼容版本的解析库。可治理的实现不是“所有包都反转”,而是先建立父优先白名单,再让插件私有包本地优先。仓库中的 ChildFirstClassLoader.java 给出了可编译骨架,关键分支如下:
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
loaded = parentFirst(name)
? super.loadClass(name, false)
: findLocalThenParent(name);
}
if (resolve) resolveClass(loaded);
return loaded;
}
}白名单至少要覆盖 java.*、javax.*、jdk.* 等平台命名空间,以及平台真正拥有的 API/SPI、日志门面和 DTO 包。java.* 还有 JVM 的受保护包约束,不能把“本地优先失败后回退”当成允许插件伪造核心类的通道。白名单必须由平台维护,插件不能自行缩小。
资源查找是另一条策略。只覆盖 loadClass 不会自动让 getResource/getResources 也 child-first;SPI 描述文件、配置和同名资源可能仍按另一顺序出现。平台必须明确类和资源是否采用相同可见性,否则会发生“类来自插件、服务描述来自父层”的组合故障。
parallel capable 只缩小类加载锁,不包办线程安全
自定义加载器若要按类名并行加载,需要在类初始化期间调用 ClassLoader.registerAsParallelCapable(),并用 getClassLoadingLock(name) 协调同名类的定义。它只改变类加载锁的粒度:不同类名可以使用不同锁;它不会让自定义缓存、JAR 索引、资源读取、保护域计算和关闭流程自动线程安全。
注册还有继承链条件。父类没有被登记为 parallel capable 时,子类不能假定一次调用就获得完整并行能力。实现中若存在 A 类加载期间递归请求 B、另一个线程反向请求 A,错误的锁顺序仍可能死锁。验证并行加载器时要同时做重复定义、递归依赖、关闭竞态和资源缓存并发测试,不能只测两个不同类能否同时返回。
URLClassLoader.close() 负责关闭它打开的 JAR/文件资源,Windows 上这常常决定旧 JAR 能否被替换;它不清除已经定义的 Class,不切断外部强引用,也不触发类卸载。关闭文件句柄与加载器可回收是两个独立验收项。
四类异常要先分型,再选择证据
类加载事故常被统称为“类冲突”,但几种异常的第一现场不同:
| 异常形态 | 被破坏的边界 | 第一证据 |
|---|---|---|
ClassCastException,两边类名相同 | 两个定义加载器产生不同运行时类型 | 打印两侧 Class、loader、module、code source |
LinkageError / loader constraint violation | 方法签名中的同名类型被要求解析为同一运行时类型 | 列出 API、实现、参数/返回 DTO 的定义加载器 |
IllegalAccessError | 二进制访问不满足运行时包或模块导出 | 核对包名、定义加载器、模块 readability/exports |
InaccessibleObjectException | 深反射没有获得模块 opens | 核对反射目标模块与 opens,不要误改委派顺序 |
loader constraint 常在链接阶段才暴露。两个加载器各自能加载 com.example.Dto,不代表它们能通过同一个父层接口交换这个类型;当 JVM 解析跨边界方法签名时,会要求相关加载上下文对签名类型形成兼容解析。若插件把 DTO 也私有加载,就可能在接口调用或方法覆盖时得到 LinkageError,而不是创建实例时立即失败。
package-private 访问也不只看包名。运行时包由“包名 + 定义加载器”共同决定;同名包位于不同加载器时不属于同一个运行时包。JDK 9+ 还叠加 JPMS:类可见、成员可访问、模块可读、包被导出、包向反射开放是不同判断。先按异常类型分叉,才能避免用 --add-opens 掩盖类型分裂,或用 child-first 修一个本来属于模块访问的问题。
线程上下文类加载器必须有词法生命周期
父层框架代码天然看不到子层插件。ServiceLoader、JDBC 等机制需要一个反向可见性入口,线程上下文类加载器(TCCL)让调用方把“本次应该从哪个加载边界找实现”带入父层框架。JDK 9+ 又增加按 ModuleLayer 等入口加载服务的方式,因此不能把所有服务发现都简化成“永远读取 TCCL”;要核对实际调用的 ServiceLoader.load 重载和框架封装。
线程池会复用 worker。任务若只设置新 TCCL 而不恢复,下一任务会继承错误边界,worker 还会长期强引用插件加载器。仓库中的 ContextClassLoaderScope.java 把安装与恢复绑定到同一线程和 try-with-resources:
try (ContextClassLoaderScope ignored = ContextClassLoaderScope.open(pluginLoader)) {
ServiceLoader.load(Plugin.class).forEach(Plugin::start);
}scope 保存旧值,在 close 中恢复,并拒绝跨线程关闭。异步任务不能直接捕获整个插件对象图;平台应只捕获稳定 API 值和加载器引用,在任务开始时安装,在 finally 恢复。插件停止时必须先阻止新任务提交,再等待这些 scope 全部退出,否则加载器关闭后仍可能有任务继续使用旧类型。
插件卸载要切断每一条父层强引用
JVM 没有“卸载某一个 Class”的公共 API。类卸载以定义加载器及其定义的整批类为边界:加载器、Class、实例和关联元数据不再从 GC Roots 可达后,GC 才有机会回收它们。System.gc() 也只是请求,不是卸载承诺。
最常见的引用链来自插件边界外部:
平台静态 Map 缓存插件 Class、Method、lambda、动态代理或实例。插件注册 JDBC Driver、MBean、日志 appender、监控回调、监听器后没有撤销。插件创建平台线程、定时任务、执行器或 ThreadLocal,线程仍存活或 value 没清理。
异步 Future、队列任务或 Reactor 回调捕获插件对象。JNI 全局引用或原生库保存回调对象;这类引用无法只靠普通堆对象列表判断。
插件管理器应把生命周期写成状态机,而不是一串约定回调:ACTIVE → QUIESCING → STOPPING → CLOSED → COLLECTABLE。进入 QUIESCING 后拒绝新任务并记录在途引用数;引用归零或截止时间到达后进入 STOPPING,依次取消任务、撤销 Driver/MBean/监听器/缓存、恢复 TCCL、关闭加载器;最后清除管理器自身强引用。若截止时间到达仍有在途任务,发布新版本必须失败或进入明确隔离状态,不能同时把两个版本都标成当前版本。
用 WeakReference 与 ReferenceQueue 验证生命周期
LoaderLifecycleDemo.java 连续创建八个加载器。release 模式只保留 WeakReference,leak 模式则故意把插件 Class 放入父层静态缓存:
java -Xlog:class+unload=info -cp out/jvm example.jvm.loader.LoaderLifecycleDemo out/jvm
java -Xlog:class+unload=info -cp out/jvm example.jvm.loader.LoaderLifecycleDemo out/jvm leak常见输出如下:
mode=release, enqueued=8, alive=0, parentCache=0
mode=leak, enqueued=0, alive=8, parentCache=8release 行不是可以写入 CI 的绝对 GC 时限承诺:类卸载和引用入队由实际收集器、内存压力与运行时决定。这个实验真正建立了两项证据:leak 模式下父层强引用能稳定阻止回收;release 模式在给定测试窗口内应最终出现入队与 class unload 事件。生产回归应采用趋势不变量,而不是照抄“八轮、1.5 秒”作为万能阈值。
建议把热更新验收固定成一段受控循环,例如预热后连续执行 20 轮更新,再观察至少两个完整收集周期:
| 信号 | 通过判据 | 常见误判 |
|---|---|---|
| 存活旧加载器数 | 回落到稳定基线,不随轮次累计 | 只看加载器总创建数 |
| class loaded/unloaded delta | 旧版本类有对应卸载趋势 | 要求每轮立刻一一相等 |
| Metaspace used | 预热后围绕基线波动,不近似单调上涨 | committed 不下降就判泄漏 |
| Metaspace committed | 可保留供后续复用,结合 used 判断 | 把保留提交块当成活对象 |
| 在途插件任务/引用计数 | 停止截止时间前归零 | 只看插件 stop 回调返回 |
从异常到 GC Root 的一页排查路径
线上先确认当前 JDK 支持哪些诊断命令:
jcmd "$PID" help VM.classloaders
jcmd "$PID" VM.classloaders
jcmd "$PID" help VM.metaspace
jcmd "$PID" VM.metaspace show-loaders scale=MB
jcmd "$PID" JFR.start name=loader settings=profile duration=120s filename=loader.jfr启动时可使用统一日志记录类加载与卸载:
java -Xlog:class+load=info,class+unload=info:file=classload.log:time,level,tags ...JDK 8 常见的 -XX:+TraceClassLoading、-XX:+TraceClassUnloading 属于旧入口;不要把它们与 JDK 9+ -Xlog 参数混写进同一启动基线。Arthas 的 classloader 命令适合在线快速查看加载器树、类来源和资源,但它是额外注入的诊断工具,使用前要经过生产授权、版本兼容与开销评估;最终泄漏结论仍要回到 GC Roots、卸载事件和趋势指标。
什么时候值得承担自定义加载器成本
| 需求 | 首选方案 | 不要过早引入的复杂度 |
|---|---|---|
| 单体应用只有依赖版本冲突 | 构建期依赖收敛、shade 或升级 | 自定义加载器与运行时隔离 |
| 插件需要独立升级和卸载 | 父层稳定 API + 子层隔离实现 + 生命周期管理器 | 让插件私带 API/SPI |
| 多个不兼容依赖必须同进程共存 | 精确 child-first 包域与父优先白名单 | 对全部包无条件 child-first |
| 需要强模块封装但不卸载 | 优先评估 JPMS 模块边界 | 把模块访问问题伪装成类加载问题 |
| 容器已经提供成熟隔离模型 | 遵循容器的加载器与生命周期契约 | 再套一层未知所有权的加载器 |
Tomcat WebappClassLoader、OSGi、Spring Boot 的启动加载器都解决特定包装、隔离或生命周期问题,但它们的委派规则、资源模型和卸载责任不同。评审自研方案时可以用它们做对照,不能只借一个“child-first”标签就认为行为等价。
回到插件热更新事故,修复顺序应当是:把共享 API/SPI/DTO 提到稳定父层;用白名单约束隔离范围;让每个插件登记线程、任务与外部注册;停止时先静默流量并等待在途引用,再撤销注册、恢复上下文、关闭资源和清除强引用;最后用循环更新、ReferenceQueue、卸载日志与 Metaspace used 趋势证明旧加载器离场。若只能证明 close() 已调用,不能证明旧加载器不可达,热更新仍没有完成。
进一步核对规范和实现时,可查阅 Java SE 25 ClassLoader、Java SE 25 URLClassLoader、Java SE 25 ServiceLoader 与 JVMS 5.3:Creation and Loading。这些入口分别约束默认加载协议、资源关闭、服务发现和运行时类身份;具体容器的 child-first 与卸载行为还要回到对应实现文档验证。
