一个"插件式"功能想通过"动态替换 jar 包"来更新逻辑——把新 jar 放进目录,运行时加载。结果旧类仍然生效:因为旧类已经被"父加载器"加载过,而双亲委派让"先问父、父有就用父的",于是新 jar 里的同名类永远不会被用到。
类加载是 Java 里少数"看代码看不出来"的机制:类的身份由"加载器 + 全限定名"共同决定,同一个名字在不同加载器里是两个不同的类。这个"身份"定义与"语言层的感觉"冲突:代码里 X 就是 X,而运行时"哪个 X"取决于加载器。所以这类问题的表现总是"看起来不可能",于是排查从"怀疑代码"开始,方向就错了。
而这条规则解释了很多"诡异"的现象(类型转换失败、静态变量有两份)。
1. 双亲委派做了什么
| 步骤 | 行为 |
|---|---|
| 1 | 收到加载请求,先委派给父加载器 |
| 2 | 父加载器再往上传,直到启动类加载器 |
| 3 | 父加载不到才自己加载 |
这个顺序的意图是"核心类不被替换":你自己写一个 java.lang.String 也不会生效第一项(先问父)的副作用是"自定义加载器经常"什么都没做"":它写了,但所有类型都由父加载——于是"隔离"与"热替换"都不生效,而代码看起来完全正确。
,因为父加载器(启动类)先找到它。
// 三段加载器(JDK 9+ 仍是这三层,只是实现变了)
Bootstrap(启动类加载器):加载 java.base 等核心模块
Platform(平台类加载器):加载 java.sql 等平台模块
App(应用类加载器):加载 classpath / 模块路径上的应用类
// 自定义加载器通常挂在 App 之下(或并列)
2. "同一个类"为什么会不相等
| 条件 | 结果 |
|---|---|
| 同一加载器 + 同名 | 同一个类 |
| 不同加载器 + 同名 | 两个不同的类(Class 对象不同) |
| 父子加载器 + 同名 | 子不加载(委派给父),同一个类 |
第二项的"最迷惑"之处在于错误信息:它把同一个名字打印两遍,于是读起来像"这不是同一个类型吗"。而解决方式与"类型"无关,只与"加载器"有关。
第二项导致的典型错误是 ClassCastException: X cannot be cast to X——两个 X 来自不同加载器,于是"看起来是同一种类型但不兼容"。
第三项在"自定义加载器没有打破委派"时成立:你写了自定义加载器,但没覆写 loadClass,于是它仍然先问父第三项的"排查"很直接:在 loadClass 里打一行日志,看它是否被调用。若"替换 jar 后没有新日志",说明请求根本没到你的加载器。
——所以"替换 jar"不起作用。
3. 三种"打破委派"的方式
| 方式 | 用途 |
|---|---|
覆写 loadClass(先去自己找) |
热部署、插件隔离 |
| 用"子优先"的加载器(如 Tomcat 的 Webapp) | 应用隔离 |
| OSGi 式的网状委派 | 模块化(复杂) |
第一项是"热部署"的技术基础:先在自己的 URL 里找,找不到再问父。于是"新 jar 里的同名类会覆盖旧的"。
第二项的"边界"是"用接口隔离":跨加载器只传"由父加载器加载的接口"(如 java.util.List 或你自己放在共享层的接口),于是类型身份统一——这是插件框架的标准做法。
代价是"同一个类可能出现两份":一份来自父、一份来自子,于是"跨加载器的对象传递"会 ClassCastException。
// 打破委派的最小实现(先自己找,再问父)
class ChildFirstLoader extends URLClassLoader {
@Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try { c = findClass(name); } // 先自己找
catch (ClassNotFoundException e) { c = super.loadClass(name, resolve); } // 再问父
}
if (resolve) resolveClass(c);
return c;
}
}
}
4. 热部署"替换 jar"的四个前提
| 前提 | 说明 |
|---|---|
| 旧类无引用 | 否则回收不了,且旧逻辑仍在 |
| 旧加载器可被回收 | 需断开所有引用(线程/静态/监听器) |
| 不跨加载器传类型 | 否则 ClassCastException |
| 框架支持隔离 | 否则容器里的对象仍指向旧类 |
第一项最容易被忽略:"我已经不用旧类了"与"JVM 还能通过引用找到它"是两件事。第一项的"引用来源"通常是四类:静态字段、运行中的线程、已注册的监听器、缓存/连接池。而它们各自都"看起来与类加载无关"。
静态字段、线程、监听器、缓存都会持有引用。
"两份类"可以用一个"身份检查"在关键边界上主动发现,而不是等异常:
// 边界检查:跨加载器传对象前先断言"加载器一致"
static void assertSameLoader(Object a, Object b) {
var la = a.getClass().getClassLoader();
var lb = b.getClass().getClassLoader();
if (la != lb) {
throw new IllegalStateException("不同加载器: " + la + " vs " + lb
+ " class=" + a.getClass().getName());
}
}
// 用法:在"插件 → 宿主"的入口处调用一次,问题立刻定位而不是"偶发 ClassCastException"
5. 五种"类加载相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
ClassCastException: X to X |
两份同名类 | 查加载器 |
| 静态变量有两份 | 同上 | 同上 |
| 替换 jar 不生效 | 未打破委派 | 覆写 loadClass |
| 热部署后内存涨 | 旧加载器未回收 | 断引用 + dump 检查 |
NoSuchMethodError |
版本冲突(同名不同版本) | 见依赖冲突 |
第二项(静态变量有两份)在"计数器/单例/配置缓存"上最刺眼:"我明明改了配置,为什么还是旧值"——而"两个加载器各有一份"完全不在直觉里。
第一项与第二项是"同一个根因的两种表现":两份同名类,于是"类型"与"静态状态"都分裂。
// 排查"两份类"的三行(一眼看出加载器)
Class<?> c = obj.getClass();
log.info("class={} loader={}", c.getName(), c.getClassLoader());
// 若两个"同类对象"的 loader 不同 → 就是这个问题
"旧加载器未回收"可以用"堆直方图 + 类加载器计数"确认,而不是猜:
// 三个观测点
jcmd <pid> GC.class_histogram | grep -i "ClassLoader" # 加载器实例数是否持续增
jcmd <pid> VM.classloader_stats # 每个加载器的类数
// 若某加载器实例数随时间线性增长 → 旧加载器没被回收
// 再用 MAT/jhat 看"谁持有该加载器的引用"(GC root 路径)
6. 排错:五种"静态状态不同步"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 单例变成多个 | 加载器分裂 | 查加载器 |
| 计数器不共享 | 同上 | 同上 |
| 配置读到旧值 | 旧类仍被引用 | 断引用 |
| 只在插件里出错 | 隔离边界 | 用接口传值 |
| 重启后恢复 | 内存状态 | 同上 |
第三项的排查是"看静态字段属于哪个加载器":两个加载器各有一份静态字段,于是"改了 A 的,B 看不到"。
最后一个"架构"建议:插件边界用"接口 + 数据"而不是"对象"。也就是"插件实现一个接口,宿主只通过接口调用,参数与返回值用基本类型/DTO(由共享层加载)"——于是"类型身份"统一,ClassCastException 不会发生。
// 两种边界的差别
差:插件返回"它自己的领域对象" → 宿主按自己的版本解释 → 类型不兼容
好:插件返回"共享层定义的 DTO" → 两侧同一个类 → 兼容
// 前提:共享层由"父加载器"加载,插件与宿主都委派到它
7. 边界:什么情况不必操心类加载
| 场景 | 为什么 | 做法 |
|---|---|---|
| 普通 Web 应用(不热部署) | 单加载器 | 不操心 |
| 不替换 jar | 无"两份类" | 不操心 |
| 不用自定义加载器 | 委派默认 | 不操心 |
| 已在容器里(Tomcat/Spring Boot) | 容器管 | 跟随容器 |
最后一条与"排障"有关的边界:"两份类"的问题不能靠"看代码"发现,只能靠"看运行时"。所以排查的第一动作应当是"打印 getClass().getClassLoader()",而不是"读实现"。
判断口径一句话:"同一个全限定名的类"会不会被两个加载器分别加载。会(插件、热部署、应用隔离),就必须处理"类型身份"与"静态状态";不会(单应用单加载器),委派是透明的。
8. 自测
- 双亲委派的三个步骤是什么?它的意图是什么?
- 为什么"不同加载器 + 同名"会产生两个不同的类?它导致的典型错误是什么?
- 为什么"写了自定义加载器但替换 jar 仍不生效"?
- 打破委派的最小实现是什么?它带来的代价是什么?
- 热部署"替换 jar"的四个前提里,哪一个最容易被忽略?
- 判断"是否需要操心类加载"的那个问题是什么?