本文目录导读:

- 双亲委派模型的常规流程(回顾)
- 经典案例:Tomcat 的 WebAppClassLoader
- 具体场景案例:为什么打破双亲委派能解决冲突?
- 手写一个最小的“打破双亲委派” Demo
- 另一个著名案例:JDK 9+ 的模块系统(JPMS)与 SPI
- 打破双亲委派的风险与注意事项
在 Java 中,打破双亲委派模型(Breaking Parent-Delegate Model)是许多框架(如 Tomcat、Spring Boot、OSGi、热部署工具)实现类隔离、热替换、Lib 冲突解决的核心机制。
最常见的打破双亲委派案例是 Tomcat 的 WebAppClassLoader。
下面我将从 原理、核心代码、实际案例(Tomcat)、以及一个手写的最小 Demo 来详细讲解。
双亲委派模型的常规流程(回顾)
正常情况下,ClassLoader.loadClass() 的逻辑是:
- 检查类是否已加载。
- 若未加载,先委托给父加载器(Parent)尝试加载。
- 若父加载器加载失败,才由自身(当前加载器)尝试加载。
打破的关键在于:重写 loadClass 方法,改变“先父后子”的顺序,改为“先子后父”或“优先从特定路径加载”。
经典案例:Tomcat 的 WebAppClassLoader
背景:一个 Tomcat 容器部署了多个 Web 应用(WAR包),每个应用可能使用了不同版本的同一个第三方库(Spring 4 和 Spring 5),如果所有应用都委托给同一个 Common ClassLoader,则只能加载一个版本,另一个会报 NoSuchMethodError。
Tomcat 的解决方案:
它定义了一个 WebappClassLoader,这个加载器打破了双亲委派,其加载顺序是:
- 先自己尝试加载(从
/WEB-INF/classes和/WEB-INF/lib中加载)。 - 如果没有找到,再委派给父加载器(JVM 的 AppClassLoader 或 ExtClassLoader)。
为什么不完全依赖父加载器? 因为父加载器(Common ClassLoader)加载的是 Tomcat 全局共享的类,而 Web 应用自己的类必须隔离,如果父加载器已经加载了 Foo.class,子加载器永远不会加载 Foo.class。
Tomcat 核心逻辑代码(简化示意)
Tomcat 的 WebappClassLoaderBase 重写了 loadClass 方法,核心逻辑如下:
// 简化自 Tomcat 源码 (WebappClassLoaderBase.java)
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 检查是否已经加载过(JVM 内部缓存)
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
// 2. (打破双亲委派的关键) 先尝试自己加载(从WEB-INF/classes和WEB-INF/lib)
try {
clazz = findClass(name);
} catch (ClassNotFoundException e) {
// 3. 自己加载失败,才委派给父加载器
if (clazz == null) {
try {
clazz = super.loadClass(name, false); // 这里会走父加载器逻辑
} catch (ClassNotFoundException e2) {
throw e2;
}
}
}
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
// 关键:findClass 如何找到类? 查找 WEB-INF/classes 目录和 WEB-INF/lib 下的 JAR。
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 从 /WEB-INF/classes 目录找 .class 文件
// 2. 从 /WEB-INF/lib 下的 jar 中找 .class 文件
// 找到后使用 defineClass 生成 Class 对象
}
注释:这只是一种简化模式,Tomcat 对系统类库(如 `java.`)依然强制委托给 Bootstrap,防止破坏 JDK 核心类。*
具体场景案例:为什么打破双亲委派能解决冲突?
假设有两个应用 AppA 和 AppB:
AppA使用了MyLib v1.0(包含com.demo.Util)AppB使用了MyLib v2.0(包含com.demo.Util,但方法签名不同)
如果不打破双亲委派:
系统只有一个 AppClassLoader,它会加载第一个找到的 Util 类(v1.0),当 AppB 调用 Util 的新方法时,会报 NoSuchMethodError。
Tomcat 打破后:
AppA的WebAppClassLoaderA在自己的WEB-INF中找到了Util(v1.0),将其加载。AppB的WebAppClassLoaderB在自己的WEB-INF中找到了Util(v2.0),将其加载。- 两个类在 JVM 中完全独立,互不干扰。
- 当
AppB找不到时,才会往上找父加载器,但通常 AppB 能找到自己的。
手写一个最小的“打破双亲委派” Demo
为了直观演示,我们写一个最简单的自定义类加载器,它重写 loadClass 方法,强制要求优先自己加载(模拟热部署/类隔离场景)。
前提:为了演示,我们需要一个不在 classpath 中的类文件,或者直接生成字节码,这里我们可以创建一个 Hello.class 放在某个磁盘路径下。
代码演示:
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class BreakingDelegationDemo {
// 模拟一个存在于磁盘上的类文件(不在 JVM 的 classpath 中)
// 假设 /tmp/classes/com/demo/Hello.class 存在
// 这里我们直接用字符串作为类的字节码来源(如果是复杂情况,可以读取文件)
public static void main(String[] args) throws Exception {
// 1. 自定义一个打破双亲委派的类加载器
ClassLoader customLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 安全检查:如果是 JDK 核心类,必须委托给系统(不能打破)
if (name.startsWith("java.")) {
return super.loadClass(name);
}
// 2. 打破双亲委派:"自己先去磁盘找"
System.out.println("[CustomLoader] 尝试自己加载: " + name);
String fileName = "/tmp/classes/" + name.replace('.', '/') + ".class";
try {
byte[] bytes = readFile(fileName);
if (bytes != null) {
// 使用 defineClass 将字节码转为 Class 对象
return defineClass(name, bytes, 0, bytes.length);
}
} catch (IOException e) {
// 忽略,去父加载器找
}
// 3. 自己没找到,调用父加载器(这里体现“委派”)
System.out.println("[CustomLoader] 自己未找到,委派给父: " + name);
return super.loadClass(name);
}
};
// 2. 准备一个目标类(比如直接加载一个已有的类,看它是被谁加载的)
// 注意:Hello 类在 classpath 里,父加载器能找到它;
// 但因为我们破了规则,会先尝试从 /tmp/classes 找,找不到才用父加载器。
Class<?> clazz = customLoader.loadClass("com.demo.Hello");
System.out.println("加载成功: " + clazz.getClassLoader());
// 3. 对比:如果用系统类加载器加载,看看它是否不同
ClassLoader systemLoader = ClassLoader.getSystemClassLoader();
Class<?> clazzSys = systemLoader.loadClass("com.demo.Hello");
System.out.println("系统加载成功: " + clazzSys.getClassLoader());
// 验证:两个类是否相同(类名相同但类加载器不同,则视为不同的类)
System.out.println("是否为同一个类对象: " + (clazz == clazzSys));
}
private static byte[] readFile(String path) throws IOException {
File file = new File(path);
if (!file.exists()) {
return null;
}
try (FileInputStream fis = new FileInputStream(file)) {
return fis.readAllBytes();
}
}
}
运行结果分析:
- 如果你把
com.demo.Hello编译并放到/tmp/classes下,CustomLoader会优先加载它(即使它也在 classpath 中),且打印“尝试自己加载”。 - 如果没有放在
/tmp/classes,则会委派给父加载器,从 classpath 中加载。
另一个著名案例:JDK 9+ 的模块系统(JPMS)与 SPI
在 JDK 9 之前,ClassLoader.getSystemClassLoader() 会打破双亲委派的一个经典例子是 JDBC / SPI(Service Provider Interface),DriverManager 加载 JDBC Driver 时,因为 DriverManager 是 Bootstrap 类加载器加载的,而 JDBC Driver 在 Application Classpath,Bootstrap 无法看不见它们,导致无法加载,这就有了 Thread.currentThread().getContextClassLoader() 的诞生,它在“父加载器”环境中调用“子加载器”的上下文加载器来加载实现类。
打破双亲委派的风险与注意事项
| 优点 | 缺点/风险 |
|---|---|
| 类隔离:不同模块使用不同版本的库互不干扰 | 类重复加载:同一份类被多个加载器加载,浪费内存 |
| 热部署:重新加载类(替换 JAR 即可) | instanceof 相等性问题:A.class.isInstance(B) 可能为 false,因为类比较不仅看类名,还看类加载器 |
| 框架扩展:如 Web 容器 / OSGi | 容易破坏类型一致性,导致 ClassCastException |
| 实现 SPI 扩展 | 需手动处理资源、类路径、安全策略 |
核心代码总结:
打破双亲委派 = 重写 ClassLoader.loadClass(String, boolean) 方法,将原本的 parent.loadClass() 逻辑延后,自己的 findClass() 逻辑提前。
这样,Java 生态中的 Web 容器(Tomcat、Jetty)、OSGi(Eclipse)、热部署工具(JRebel、Arthas 等)才能高效稳定地工作。