2497 字
约 8 分钟
13
S04-A20 · 类加载器与双亲委派:为什么"替换 jar 就生效"不可靠

一个"插件式"功能想通过"动态替换 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. 自测

  1. 双亲委派的三个步骤是什么?它的意图是什么?
  2. 为什么"不同加载器 + 同名"会产生两个不同的类?它导致的典型错误是什么?
  3. 为什么"写了自定义加载器但替换 jar 仍不生效"?
  4. 打破委派的最小实现是什么?它带来的代价是什么?
  5. 热部署"替换 jar"的四个前提里,哪一个最容易被忽略?
  6. 判断"是否需要操心类加载"的那个问题是什么?
S04-A20 · 类加载器与双亲委派:为什么"替换 jar 就生效"不可靠
https://www.clxhxhhr.top/posts/6576/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。