2441 字
约 8 分钟
22
S05-X05 · 类加载器隔离:为什么核心类库不会被同名类覆盖
2024-12-31
2026-11-08

把一份自己写的 java.lang.String 扔进 classpath,程序会不会用你的版本?不会。这个"不会"由三层机制叠起来保证:双亲委派的查找顺序、defineClass 的包名保护、引导类加载器根本不走 classpath。搞清这三层,才看得懂 Tomcat 为什么故意违反双亲委派,也才明白插件化系统里那些 Metaspace OOM 从哪来。

这篇按"身份 → 委派 → 保护 → 故意破坏 → 泄漏"的顺序拆。


1. 类的身份是二元组:加载器 + 全限定名

同一个 .class 被两个加载器各加载一次,JVM 里就有两个互不相识的类;相等判断、instanceof、强转全按这个二元组走。

ClassLoader a = new URLClassLoader(new URL[]{url}, null);
ClassLoader b = new URLClassLoader(new URL[]{url}, null);
Class<?> ca = a.loadClass("demo.Pojo");
Class<?> cb = b.loadClass("demo.Pojo");
System.out.println(ca == cb);                  // false
System.out.println(ca.getClassLoader() == b);  // false
判断 依据 常见误判
ca == cb 是否同一个加载器加载 同名同包就以为是同一个类
x instanceof T T 的加载器必须与 x 的类加载器一致(或为其祖先) 报 ClassCastException 却以为是类型写错
Class.forName(name) 用调用方的加载器 跨模块时找不到类
Class.forName(name, false, loader) 显式指定加载器 没注意 false 是不初始化

隔离不是靠包名,是靠加载器实例:两个模块各带一份 fastjson,即使版本相同,互传对象也会炸。

2. 双亲委派:loadClass 里的六行代码

JDK 17 的 ClassLoader.loadClass(String, boolean) 逻辑没变过,缩掉计时相关的代码后就是这样:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);          // 1. 已加载过?
        if (c == null) {
            try {
                if (parent != null) c = parent.loadClass(name, false);  // 2. 先问父
                else c = findBootstrapClassOrNull(name);                // 3. 到顶了问引导
            } catch (ClassNotFoundException ignored) { }
            if (c == null) c = findClass(name);      // 4. 父找不到才自己找
        }
        if (resolve) resolveClass(c);
        return c;
    }
}

顺序是"向上委派、向下兜底":请求先交到最顶,顶层找不到才逐层往下自己找。所以 classpath 上的 java/lang/String.class 永远轮不到——引导加载器已先给出 java.base 里那份。

加载器 JDK 17 中的实现 搜索范围
引导(Bootstrap) C++ 实现,null 表示 java.base 等核心模块
平台(Platform) jdk.internal.loader.ClassLoaders$PlatformClassLoader java.sql、java.xml 等平台模块
应用(App) ClassLoaders$AppClassLoader classpath / 模块路径
自定义 继承 ClassLoader 由 findClass 决定

JDK 9 之前第二个叫"扩展类加载器"(sun.misc.Launcher$ExtClassLoader),模块化后改名平台加载器。

3. 第二道锁:defineClass 的包名保护

就算你绕过 loadClass,直接 defineClass("java.lang.String", ...),也会被拦下。按 JDK 17 核对,抛的是:

java.lang.SecurityException: Prohibited package name: java.lang

写一个继承 ClassLoader 的内部类,把 defineClass 暴露成 public,然后:

byte[] junk = new byte[]{1, 2, 3, 4};          // 故意不是合法 class 文件
new Exposer().def("java.lang.String", junk, 0, junk.length);

细节值得注意:传进去的字节根本不是 class 文件,报的却是包名异常而不是 ClassFormatError。因为包名检查在 preDefineClass 里,早于 native 的格式校验——这道锁在最前面。

三条路径都堵死:classpath 放同名文件由双亲委派挡、自定义加载器硬 findClass 由包名检查挡、反射改内部字段由模块封装挡。

4. -Xbootclasspath:一条已经走不通的老路

JDK 8 时代覆盖核心类有两条"合法"路径:-Xbootclasspath/p: 把目录前置到引导类路径(用来打补丁、替换 rt.jar 里的类),-Xbootclasspath/a: 追加到尾部。前者是当年各种"Java 破解/增强"的入口。

JDK 9 模块化后,前置那条被移除。JDK 17 上直接报错:

$ java -Xbootclasspath/p:. -version
-Xbootclasspath/p is no longer a supported option.
Error: Could not create the Java Virtual Machine.

而追加形式仍被接受:

$ java -Xbootclasspath/a:/opt/patch -version
openjdk version "17.0.20.1" 2026-08-18

一句话对照:JDK 8 的 -Xbootclasspath/p: 前置覆盖已经报错,-Xbootclasspath/a: 只能追加、覆盖不了已有类,-Djava.endorsed.dirs 整个移除,替代品是 --patch-module。

5. Tomcat 为什么要打破双亲委派

严格双亲委派下,Web 应用永远用不到自己带的 log4j、spring——父加载器(装着 Tomcat 的 lib)会先给出它的版本。但每个 war 必须能带自己的依赖且互不污染,于是 WebappClassLoaderBase 把顺序倒了过来。

步骤 标准 loadClass Tomcat WebappClassLoaderBase
1 查已加载 查已加载(本地缓存)
2 委派父 先查 webapp 自己的 WEB-INF/classes、WEB-INF/lib
3 到顶查引导 再委派父(system classloader)
4 自己 findClass 最后才委派引导,且 java.* 直接放行

实现里还有一层 filter():java.* 一律不允许 webapp 定义。所以"打破双亲委派"不是"想覆盖什么就覆盖什么",而是在受控包名之外把查找顺序调成本地优先。

context.xml 里的 <Context delegate="true" /> 能恢复成父优先,用于统一依赖版本;delegate=false(默认)时本地 jar 生效。这是排查"改了 jar 但行为没变"的关键开关。

6. SPI:线程上下文类加载器存在的理由

java.sql.DriverManager 由平台加载器加载,它要实例化 classpath 上的 com.mysql.cj.jdbc.Driver——平台加载器看不见。父加载器加载不到子加载器的类,这是双亲委派的固有方向性。

解决办法是把"用哪个加载器去找"变成参数,放在线程上:

ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
// 内部等价于:
ServiceLoader.load(Driver.class, Thread.currentThread().getContextClassLoader());

ClassLoader tccl = Thread.currentThread().getContextClassLoader();
Class<?> impl = Class.forName("com.mysql.cj.jdbc.Driver", true, tccl);
场景 用哪个加载器 原因
普通 Class.forName 调用方加载器 同模块内够用
SPI / ServiceLoader TCCL 接口在上层、实现在下层
容器内业务代码、框架反序列化 TCCL 或显式传入 线程由容器创建,TCCL 指向 webapp

坑在于线程池会传递错误的 TCCL:容器线程被复用后,TCCL 可能还指着上一个已停止的 webapp。很多容器因此在请求开始时显式设置、结束时还原。

7. 泄漏:类加载器为什么回收不掉

webapp 停止后,只要 WebappClassLoader 还被任何 GC Root 引用,它加载过的所有类就都活着,连同静态字段、常量池、字节码。反复热部署几十次,Metaspace 就满了。

用 jcmd <pid> VM.classloader_stats 与 VM.metaspace 看:类加载器数量与类数量同步单调增长,就是典型信号。

泄漏点 引用链 处理
线程未停止 Thread → contextClassLoader → WebappClassLoader 停应用时 shutdown 自建线程池
ThreadLocal 残留 Thread → ThreadLocalMap → 值类型 → 加载器 finally 里 remove()
JDBC 驱动注册 DriverManager → registeredDrivers → Driver → 加载器 停应用时 deregisterDriver
静态监听器 父加载器里的单例 → 监听器 → 子加载器类 反注册,或用弱引用
定时任务 ScheduledThreadPoolExecutor 队列 → Runnable 停应用时 shutdownNow

后两行的方向是关键:父加载器持有的对象引用了子加载器的类,就会把整个子加载器钉死。顺序可以倒,引用必须断。

JDK 17 默认开启 -XX:+ClassUnloadingWithConcurrentMark;但卸载的前提是"类加载器不可达",引用链没断,参数再对也没用。

8. 排错:五种典型现象

现象 原因 处理
ClassCastException: X cannot be cast to X 同名类被两个加载器加载 统一由父加载器提供该依赖
NoClassDefFoundError 但 jar 明明在 加载器层级看不到该路径 检查 TCCL 与 delegate
Prohibited package name: java.lang 自定义了 java.* 包 改包名,别覆盖核心类
热部署后 Metaspace 持续上涨 加载器未回收 查线程、ThreadLocal、驱动注册
ServiceLoader 找不到实现 没用 TCCL 或缺少 META-INF/services 显式传加载器,查资源路径

第二行的完整含义是"首次解析时失败了",真正原因常藏在更早的 ExceptionInInitializerError 里。

9. 边界:什么时候该重新设计,而不是继续调加载器

症状 调加载器能否解决 更合适的做法
多版本依赖冲突 部分 模块化拆分或独立进程
插件互相隔离 能 每个插件一个加载器 + 明确 SPI 接口
热部署内存增长 只能缓解 收敛静态状态,或干脆重启进程
核心类行为需要改 不能 换实现,或用字节码增强框架
类加载性能差 不能 减少反射、控制类数量、用 CDS

最后一条常被忽略:类加载本身有成本,-Xlog:class+load 里类数量过万的项目光加载就要几秒,CDS(-XX:SharedArchiveFile)比在加载器层面折腾有效得多。

10. 自测

  1. 同一个 class 文件被两个加载器加载,ca == cb 为什么是 false?
  2. loadClass 的查找顺序是什么?哪一步决定了 classpath 上的 java.lang.String 无效?
  3. Prohibited package name 是在哪一步抛出的,为什么传入非法字节也报这个错?
  4. -Xbootclasspath/p 在 JDK 17 上表现如何?替代方案是什么?
  5. Tomcat 的 delegate 开关改变的是什么顺序?
  6. 一个停止的 webapp 为什么还能让 Metaspace 持续增长?
S05-X05 · 类加载器隔离:为什么核心类库不会被同名类覆盖
https://www.clxhxhhr.top/posts/5658/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。