类加载器与委派边界
类加载器决定类的字节从哪里取得,以及由谁定义运行时类型。应用类、平台 API 和插件实现可以由不同加载器管理;它们之间传递对象时,共享接口必须指向同一份运行时定义。
Application loader
├─ Greeting 接口:宿主与插件共享
├─ 插件管理器
├─ Plugin loader A → GreetingPlugin A + 私有依赖 A
└─ Plugin loader B → GreetingPlugin B + 私有依赖 BA 与 B 的实现都可以作为父层的 Greeting 使用,但不能把 A 定义的实现直接转换成 B 定义的同名实现。类型身份由二进制名与定义加载器共同组成。
内置加载器与运行时类型
每个类只有一个 defining loader,即真正定义该类的加载器。initiating loader 是 JVM 所记录的发起加载者。JVM 请求加载的加载器与最终定义者都可能成为 initiating loader;委派链中仅转发请求的中间加载器不因此自动成为 initiating loader。最终类型身份使用 defining loader。JVMS 25 §5.3给出的标识也是二进制名与 defining loader 的二元组。
JDK 9 及以后常见的内置关系是 Bootstrap、Platform、Application。Bootstrap 在 Java 对象层通常显示为 null;Platform 与 Application 分别承担平台模块和应用路径的主要加载工作。Platform 取代了 JDK 8 的 Extension loader;现代 Application loader 也不应再被强制转换为 URLClassLoader。模块加载还涉及包到模块的映射,不能仅凭父关系推导所有可见性。插件 loader 一般以 Application loader 为父,让稳定的 API 由父层定义,再自行定义插件实现。
假设两个 URLClassLoader 都读取同一个 GreetingPlugin.class,所得类型分别是:
(example.plugin.impl.GreetingPlugin, Loader A)
(example.plugin.impl.GreetingPlugin, Loader B)二进制名和 SHA-256 相同并不能消掉第二项。leftType.cast(rightInstance) 会抛 ClassCastException;但只要插件 JAR 没有私带共享 API,两边实例都能转换为父层唯一的 Greeting 接口。这正是插件边界的基本设计:共享契约只有一份,隔离实现可以有多份。
运行时包也使用“包名 + defining loader”判断。两个类即使声明在同名 package,只要由不同 loader 定义,就不共享 package-private 访问权。JDK 9+ 还会叠加模块 readability、exports 与 opens;访问错误不能靠改委派顺序或随意追加 --add-opens 混着修。
类查找与模块访问
加载器可以从 classpath 目录、JAR、运行时模块映像或动态字节读取类型。普通 classpath 的根目录对应二进制包路径;模块路径先参与模块解析,再由相应加载器加载模块中的类。exports 控制常规访问,opens 允许指定范围的深反射,它们都不会合并两份已经由不同加载器定义的类型。
findLoadedClass 查询当前加载器已被记录为 initiating loader 的类,findClass 由具体加载器实现字节查找,defineClass 请求 JVM 从字节定义类型。自定义文件查找通常重写 findClass 就够了;修改 loadClass 则是在改变委派策略。
父优先与局部 child-first
ClassLoader.loadClass的默认实现先查 findLoadedClass,再请求父层;父层找不到时才调用当前 loader 的 findClass。父优先使共享类型优先使用父层定义:JDK 类型、平台 API/SPI/DTO 和跨插件交换对象应由稳定父层统一定义。
插件确实需要不兼容的私有依赖时,可以只对平台明确交给插件所有的包局部反转。公开实验中的实现把 example.plugin.impl.* 与 example.plugin.privateimpl.* 设为 local-first,其他类型仍走父层:
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
loaded = localFirst(name)
? findLocalThenParent(name)
: super.loadClass(name, false);
}
if (resolve) resolveClass(loaded);
return loaded;
}不要复制一份“万能白名单”。java.* 有受保护包约束,插件不能定义;javax.*、日志门面、Servlet API 或业务 DTO 是否父优先,则取决于当前平台究竟由谁提供。更稳妥的规则是默认父优先,只列出插件确实拥有且允许隔离的 local-first 包域,并在构建时反断言插件制品不含共享 API。
自定义 loader 若调用 registerAsParallelCapable(),还要检查返回值,并用 getClassLoadingLock(name) 保护同名定义。parallel capable 只把锁粒度缩小到类名;它不会自动让 JAR 索引、缓存、资源读取与关闭逻辑线程安全。实验分别检查注册状态,并让一个全新 loader 接受八个并发的同名首次加载,确认只产生一个 Class。后一个断言验证的是同名定义协调,不证明两个不同类名一定并行进入 findClass。
类和资源是两条策略。覆盖 loadClass 不会自动改变 getResource/getResources;同名 class 来自插件而服务描述或配置来自父层,完全可能发生。实验故意让私有 Version.class 走 child-first,却让同路径 config.txt 仍按未重写的父优先资源策略返回,避免把两条通道误认为一体。
如果共享接口的方法签名中出现一个被插件私有复制的 DTO,问题可能在链接跨边界调用时才成为 loader constraint 或 LinkageError。上一章的 owner/name/descriptor 解释“调用方期待什么”,本章的 defining loader 则解释“同名 owner 最终是不是同一个运行时类型”。
服务发现与线程上下文加载器
父层框架天然看不到只存在于子 loader 的 provider。ServiceLoader 的 load(Greeting.class) 使用当前线程的 context class loader 查找 provider;显式的 ServiceLoader.load(service, loader) 则把选择写在调用点。前一种方式适合已有框架协议,但必须把 TCCL 当成临时能力,而不是线程的永久配置。
try (ContextLoaderScope scope = ContextLoaderScope.open(pluginLoader)) {
Greeting greeting = ServiceLoader.load(Greeting.class)
.findFirst()
.orElseThrow();
greeting.greet();
}scope 保存 previous loader,只允许创建它的线程关闭,并在 close() 恢复。try-with-resources 即使遇到异常也会执行恢复。线程池会复用 worker;如果任务只设置新 TCCL 而不恢复,下一个任务会继承错误可见性,存活线程也会继续强引用旧插件 loader。
异步边界不能把一个已打开的 scope 交给另一线程关闭。应让每个 worker 在任务开始时安装、在同一任务的 finally 中恢复;提交方只传稳定父层契约所需的数据。插件停止时先拒绝新任务,再等待这些作用域退出,才有可能切断线程到旧 loader 的引用。
TCCL 不是所有服务发现机制的统一答案。不同 ServiceLoader 重载、ModuleLayer 或框架封装可能选择不同 loader;排查时应先确认调用的实际入口,而不是看到 SPI 就盲目修改线程上下文。
插件停止与类卸载
URLClassLoader.close() 会关闭它打开的 JAR/文件资源;它不会删除已经定义的 Class,不会清除外部注册,也不会命令 JVM 卸载。一个类只有在 defining loader 可由 GC 回收时才可能卸载,JLS 17 §12.7没有承诺固定时限。
父层静态 Map 中的插件实例或 Class、存活线程的 TCCL/ThreadLocal、未结束的任务与定时器、未撤销的 Driver/MBean/监听器,都能形成 GC Root → 父层对象 → 插件实例或 Class → defining loader。这条链上任何一根强引用存在,close() 都只完成了资源关闭。
停止插件需要先处理仍在使用它的对象和任务。先让插件停止接收新工作并耗尽在途调用,再撤销父层注册、取消任务、清理 ThreadLocal、恢复 TCCL;随后关闭 loader 的文件资源,最后清除插件管理器自己的强引用。新版本可启用不代表旧版本已离场,这两个状态必须分别观察。
WeakReference<ClassLoader> 和 ReferenceQueue 可以记录测试窗口内是否观察到 loader 变为弱可达,-Xlog:class+unload=info 可以记录 HotSpot 的类卸载事件;两者都没有固定截止时间。没有入队不能单独证明泄漏,入队也不能替代生产注册表与任务计数的清理断言。Metaspace 的 used/committed、GC Roots 与 heap dump 分析分别由运行时数据区与对象布局和JVM 诊断继续展开。
编译并运行两份插件实例
下载完整实验包,解压后进入 classloader-boundaries。Linux Bash 中以普通用户操作,完整 JDK 的准备可查Java 版本基线。这里使用 Temurin 17.0.20+8;JDK 25 上的标准类身份规则相同,运行输出仍应按实际构建核对。
export JDK17_HOME=/opt/jdk-17
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JDK17_HOME/bin/java" -version
export LAB_OUT="$(mktemp -d /tmp/loader-learning.XXXXXX)"目录分为 platform、plugin、host 和 resources。先把共享接口与平台的 Version 编译到父层,再只针对这些 API 编译插件:
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT/platform" platform/example/plugin/api/Greeting.java \
platform/example/plugin/privateimpl/Version.java
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-cp "$LAB_OUT/platform" -d "$LAB_OUT/plugin" \
plugin/example/plugin/privateimpl/Version.java \
plugin/example/plugin/impl/GreetingPlugin.java
cp -r resources/platform/. "$LAB_OUT/platform/"
cp -r resources/plugin/. "$LAB_OUT/plugin/"
"$JDK17_HOME/bin/jar" --create --file "$LAB_OUT/plugin.jar" -C "$LAB_OUT/plugin" .
"$JDK17_HOME/bin/jar" tf "$LAB_OUT/plugin.jar"JAR 应有 GreetingPlugin、插件的 Version、config.txt 和 META-INF/services 描述,不能夹带 Greeting.class。共享 API 若被打入插件并由子层定义,跨层交换的接口就会分裂。服务描述文件名是接口的二进制名,内容是 provider 的二进制名。
编译宿主的加载器、上下文作用域和测试入口。运行 classpath 只放父层和宿主,不放 plugin.jar;插件路径作为参数交给自定义加载器:
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-cp "$LAB_OUT/platform" -d "$LAB_OUT/host" host/example/host/*.java
export LAB_CP="$LAB_OUT/platform:$LAB_OUT/host"
"$JDK17_HOME/bin/java" -cp "$LAB_CP" example.host.PluginBoundaryDemo \
identity "$LAB_OUT/plugin.jar"same-byte-hash=true
same-binary-name=true
same-runtime-class=false
shared-contract=true
cross-cast=ClassCastException两个加载器读取同一 JAR,创建两个不同的 Class。它们的实例都实现父层唯一的 Greeting,因此共享契约调用成立;用左侧实现类型 cast 右侧对象时精确抛出 ClassCastException。该输出把文件相同、名称相同和类型不同分开呈现。
对照类与资源的查找结果
"$JDK17_HOME/bin/java" -cp "$LAB_CP" example.host.PluginBoundaryDemo \
delegation "$LAB_OUT/plugin.jar"普通 URLClassLoader 的父优先查询得到 platform-copy,局部 child-first 得到 plugin-private。共享 Greeting 仍由 Application loader 定义。资源返回 platform-resource:示例只改变类查找,未重写资源查找,两套结果并不矛盾。
同一个模式还以八个线程同时首次加载同名实现,要求结果是同一个 Class,并检查 parallel-capable 注册成功。这验证了同名定义协调;不同类名实际并行的性能需要另测。
观察 TCCL 恢复和外部强引用
"$JDK17_HOME/bin/java" -cp "$LAB_CP" example.host.PluginBoundaryDemo \
tccl "$LAB_OUT/plugin.jar"
"$JDK17_HOME/bin/java" -Xint -Xmx64m -XX:+UseSerialGC \
-cp "$LAB_CP" example.host.PluginBoundaryDemo lifecycle "$LAB_OUT/plugin.jar"TCCL 模式先得到 default-provider-count=0,再在 scope 内发现 plugin-private;故意抛错后 tccl-restored-after-failure=true。另一个线程关闭 scope 会被拒绝,最终由创建线程恢复上下文。这个 scope 只适合词法嵌套且按逆序关闭的作用域,不能把多个 scope 乱序管理。
生命周期模式的前三项必须成立:
loader-closed=true
parent-cache-retains-loader=true
parent-cache-cleared=true
weak-clear-observed=true最后一项也允许 false,因为 JVM 不承诺在短观察窗口里回收。前三项分别对应关闭 JAR、父缓存仍持有插件实例、移除该强引用;不能通过强行要求最后一项立即为 true 来替代生命周期检查。
使用回归脚本可重复检查全部模式。没有本机 JDK 时,在已获 Docker 权限的 Linux 普通用户下,从解压目录运行:
docker run --rm --network none --read-only \
--user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$(pwd),dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=256m,mode=1777 \
--env JDK17_HOME=/opt/java/openjdk \
eclipse-temurin:17.0.20_8-jdk bash /lab/run.sh镜像需要提前从可信来源取得;离线环境可通过校验过的 docker save/load 制品导入。脚本的输出写入临时目录并自动清理,宿主源码只读。手动实验结束后,只删除本轮临时目录,保留源码包:
case "$LAB_OUT" in
/tmp/loader-learning.*) rm -rf -- "$LAB_OUT" ;;
*) printf '拒绝清理未知路径\n' >&2 ;;
esac区分转换、链接、访问与卸载故障
| 问题轴 | 先取什么证据 | 修复控制点 |
|---|---|---|
| 身份 | 两侧 Class 的二进制名、defining loader、模块与 code source | 共享 API/SPI/DTO 上移到唯一稳定父层,插件 JAR 删除复制品 |
| 可见性 | initiating loader、实际委派分支、class 与 resource 来源、当前 TCCL | 收窄 local-first 包域;为资源另定策略;TCCL 同线程安装与恢复 |
| 生命周期 | 父层注册、在途任务、线程/TCCL/ThreadLocal、loader 是否只完成 close | 先静默与撤销,再恢复上下文、关闭句柄、清管理器强引用 |
异常首先决定检查位置。ClassCastException 比较两端 Class;loader constraint 的 LinkageError 比较跨调用 descriptor 中的类型由谁定义;IllegalAccessError 检查字节码访问与运行时包,反射的 IllegalAccessException 或 InaccessibleObjectException 则检查反射入口及模块开放。添加 --add-opens 只改变指定反射访问,不能修复类型身份或缺失方法。
将下面代码放在已经取得目标 value 的诊断位置,同时打印类型、模块、加载器和来源;Bootstrap 或动态类的 code source 可能为 null:
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());加载来源或离场趋势需要进一步确认时,可在受控环境增加 -Xlog:class+load=info,class+unload=info;日志说明发生了什么,不说明父层注册是否清理正确。只有依赖版本冲突时,优先在构建期收敛、升级或 shade;只有插件确实需要同进程隔离、独立升级并且平台愿意承担生命周期所有权时,自定义 loader 才值得进入设计。
权威资料与规范地址
按正文中的概念与命令查阅原始规范。版本化文档对应标注的 JDK,升级后应查目标版本的同名章节。
