把一份自己写的 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. 自测
- 同一个 class 文件被两个加载器加载,
ca == cb为什么是 false? loadClass的查找顺序是什么?哪一步决定了 classpath 上的java.lang.String无效?Prohibited package name是在哪一步抛出的,为什么传入非法字节也报这个错?-Xbootclasspath/p在 JDK 17 上表现如何?替代方案是什么?- Tomcat 的
delegate开关改变的是什么顺序? - 一个停止的 webapp 为什么还能让 Metaspace 持续增长?