Java 双亲委派机制:原理、源码与如何打破
Java 双亲委派机制:原理、源码与如何打破
类加载器是 JVM 里非常容易被问到、也非常容易被误解的一块内容。双亲委派不是一个复杂算法,它更像是一条类加载的安全边界:先让父加载器尝试加载,父加载器不行时,子加载器才自己动手。
一、先从一个问题开始
假设我们自己写一个类:
package java.lang;
/**
* 演示错误地自定义 JDK 核心包下的 String 类。
*/
public class String {
/**
* 程序入口方法,用于观察自定义 java.lang.String 是否能被正常执行。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
System.out.println("自定义 java.lang.String 被执行了");
}
}
这个类能不能替换 JDK 自带的 java.lang.String?
正常情况下不能。因为 JVM 加载类时不会让应用程序类加载器直接说了算,而是会先向上委托给父加载器,最终优先由 Bootstrap ClassLoader 去加载 JDK 核心类库里的 java.lang.String。
这就是双亲委派模型最直观的价值:保证核心类库的类型安全和加载一致性。
二、类加载器层级
Java 里的类加载器大致可以分成这几类:
Bootstrap ClassLoader
↑
Platform / Extension ClassLoader
↑
Application ClassLoader
↑
Custom ClassLoader
在不同 JDK 版本中名字略有差异:
| JDK 版本 | 第二层加载器 | 说明 |
|---|---|---|
| JDK 8 及以前 | Extension ClassLoader | 加载 jre/lib/ext 或 java.ext.dirs 下的类 |
| JDK 9 以后 | Platform ClassLoader | 模块化之后替代 Extension ClassLoader,加载平台相关模块 |
2.1 Bootstrap ClassLoader
Bootstrap ClassLoader 是最顶层的类加载器,用 C/C++ 实现,Java 代码里通常看到的是 null。
它主要负责加载 Java 核心类库,例如:
java.lang.Objectjava.lang.Stringjava.util.ArrayList
这些类来自 JDK 的基础模块或核心运行时镜像。
2.2 Platform / Extension ClassLoader
JDK 8 以前叫 Extension ClassLoader,JDK 9 之后因为模块化改成 Platform ClassLoader。
它负责加载一些平台级别的类,不是业务应用自己写的类,也不是最核心的 Bootstrap 类。
2.3 Application ClassLoader
Application ClassLoader 也叫系统类加载器,负责加载应用 classpath 下的类。我们平时写的大多数业务类,默认都是它加载的。
可以用下面的代码观察:
/**
* 打印类加载器层级,观察当前应用的类加载路径。
*/
public class ClassLoaderPrinter {
/**
* 程序入口方法,打印当前类加载器及其父加载器。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
ClassLoader classLoader = ClassLoaderPrinter.class.getClassLoader();
while (classLoader != null) {
System.out.println("当前类加载器:" + classLoader);
classLoader = classLoader.getParent();
}
System.out.println("当前类加载器:Bootstrap ClassLoader");
}
}
常见输出类似:
当前类加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@xxxx
当前类加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@xxxx
当前类加载器:Bootstrap ClassLoader
三、什么是双亲委派?
双亲委派的核心规则很简单:
一个类加载器收到类加载请求时,先把请求委托给父加载器;父加载器加载不了,子加载器才尝试自己加载。
流程可以画成这样:
加载 com.example.UserService
│
▼
Custom ClassLoader 收到请求
│ 先委托
▼
Application ClassLoader
│ 先委托
▼
Platform / Extension ClassLoader
│ 先委托
▼
Bootstrap ClassLoader
│
├── 能加载:直接返回 Class
│
└── 不能加载:向下回退
▼
子加载器尝试 findClass
注意这里的“父”不是继承关系,而是组合关系。ClassLoader 里保存了一个 parent 字段,用来表示委派链路上的父加载器。
四、从源码思路看加载流程
ClassLoader#loadClass 的源码逻辑可以简化成下面这样。下面这段是伪代码,用来表达主流程,不是可直接编译的 JDK 源码:
/**
* 演示双亲委派核心流程的伪代码。
*/
protected Class<?> loadClass(String name) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
打印日志("开始加载类,类名:" + name);
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
try {
if (parent != null) {
打印日志("委派给父加载器,类名:" + name);
clazz = parent.loadClass(name);
} else {
打印日志("委派给 Bootstrap ClassLoader,类名:" + name);
clazz = bootstrapClassLoader.loadClass(name);
}
} catch (ClassNotFoundException ignored) {
打印日志("父加载器未找到类,准备由当前加载器加载,类名:" + name);
}
if (clazz == null) {
clazz = findClass(name);
打印日志("当前加载器完成类加载,类名:" + name);
}
}
return clazz;
}
}
真实源码中还有同步锁、并行加载、模块化等细节,但主线就是这三步:
- 先查缓存:
findLoadedClass(name),避免重复加载。 - 向上委派:优先让父加载器加载。
- 自己加载:父加载器抛出
ClassNotFoundException后,才调用findClass(name)。
这也是为什么我们自定义类加载器时,通常应该重写 findClass,而不是直接重写 loadClass。
五、为什么需要双亲委派?
5.1 保证核心类库不会被随便替换
如果没有双亲委派,应用程序完全可以自己写一个 java.lang.String、java.lang.Object、java.util.HashMap,然后让业务类优先加载它。
这样会带来两个问题:
- 核心 API 的行为可能被篡改。
- JVM 里同名核心类可能出现多份,类型体系会混乱。
双亲委派让 JDK 核心类优先由 Bootstrap ClassLoader 加载,业务代码没有机会覆盖它们。
5.2 保证类的唯一性
在 JVM 里,判断两个类是否相同,不只看类的全限定名,还要看加载它的类加载器。
也就是说:
类唯一标识 = 类加载器 + 类全限定名
同一个 com.example.User,如果被两个不同的 ClassLoader 加载,在 JVM 看来就是两个不同的类。
这也是一些插件化、热部署、容器隔离场景容易出现 ClassCastException 的原因:明明类名一样,但加载器不同。
5.3 避免重复加载
父加载器已经加载过的类,子加载器直接复用即可。这样能减少重复加载,也能让基础类在整个应用中保持一致。
六、怎么“破坏”双亲委派?
严格来说,“破坏双亲委派”不是指把 JVM 规则彻底砸掉,而是指不再完全遵循先父后子的加载顺序。
常见方式有四类。
七、方式一:重写 loadClass,改成子优先
默认建议重写 findClass,因为 loadClass 里已经实现了双亲委派。如果我们故意重写 loadClass,并让当前加载器先加载指定包名,就可以形成“子优先”的策略。
下面是一个简化示例:
import java.io.IOException;
import java.io.InputStream;
/**
* 子优先类加载器示例,用于演示如何改变默认双亲委派流程。
*/
public class ChildFirstClassLoader extends ClassLoader {
/**
* 创建子优先类加载器。
*
* @param parent 父类加载器
*/
public ChildFirstClassLoader(ClassLoader parent) {
super(parent);
}
/**
* 优先由当前类加载器加载指定包下的类,其他类仍然走双亲委派。
*
* @param name 类的全限定名
* @param resolve 是否解析类
* @return 加载后的 Class 对象
* @throws ClassNotFoundException 类不存在时抛出
*/
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
System.out.println("收到类加载请求,类名:" + name);
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) {
System.out.println("命中已加载类,类名:" + name);
return loadedClass;
}
if (name.startsWith("com.example.plugin.")) {
try {
System.out.println("当前加载器优先加载插件类,类名:" + name);
Class<?> clazz = findClass(name);
if (resolve) {
resolveClass(clazz);
}
return clazz;
} catch (ClassNotFoundException ex) {
System.out.println("当前加载器未找到插件类,回退到父加载器,类名:" + name);
}
}
System.out.println("委派给父加载器,类名:" + name);
return super.loadClass(name, resolve);
}
}
/**
* 从 classpath 中读取 class 字节码并定义类。
*
* @param name 类的全限定名
* @return 加载后的 Class 对象
* @throws ClassNotFoundException 类不存在或读取失败时抛出
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = name.replace('.', '/') + ".class";
try (InputStream inputStream = getResourceAsStream(path)) {
if (inputStream == null) {
throw new ClassNotFoundException(name);
}
byte[] bytes = inputStream.readAllBytes();
System.out.println("读取类字节码成功,类名:" + name + ",字节数:" + bytes.length);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException ex) {
throw new ClassNotFoundException(name, ex);
}
}
}
这个加载器的策略是:
com.example.plugin.下的类:当前加载器优先。- 其他类:继续走默认双亲委派。
为什么不建议所有类都子优先?
因为如果连 java.*、javax.*、jakarta.*、框架公共 API 都乱加载,很容易出现类型冲突、链接错误、安全问题。实际工程里即使要破坏,也应该只对明确的隔离包生效。
八、方式二:线程上下文类加载器
线程上下文类加载器,也就是 Thread.currentThread().getContextClassLoader(),是 Java 里非常典型的一次“反向委派”。
为什么需要它?
因为有些基础框架类由父加载器加载,但它们需要反过来加载应用层的实现类。
最典型的例子是 SPI:
ServiceLoader<MyService> loader = ServiceLoader.load(MyService.class);
ServiceLoader 是 JDK 类,由 Bootstrap ClassLoader 加载。但具体的 MyService 实现类通常在业务 classpath 下,只能由 Application ClassLoader 或更下层的加载器看到。
如果完全按照双亲委派,Bootstrap ClassLoader 看不到业务实现类。于是 Java 提供线程上下文类加载器,让父层代码可以使用当前线程绑定的应用类加载器去加载实现类。
示例:
/**
* 演示线程上下文类加载器如何让父层代码加载应用层类。
*/
public class ContextClassLoaderDemo {
/**
* 程序入口方法,打印线程上下文类加载器信息。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
System.out.println("当前线程上下文类加载器:" + contextClassLoader);
}
}
常见使用场景:
- JDBC Driver 加载
ServiceLoaderSPI- JNDI
- 一些日志框架、容器框架
这不是粗暴地重写 loadClass,但它确实绕开了“父加载器只能向上委派”的限制,让父层代码能看到子层实现。
九、方式三:Tomcat 的 WebAppClassLoader
Tomcat 是破坏双亲委派的经典工程案例。
一个 Tomcat 里可以部署多个 Web 应用:
Tomcat
├── app-a/WEB-INF/classes
├── app-a/WEB-INF/lib
├── app-b/WEB-INF/classes
└── app-b/WEB-INF/lib
如果完全使用默认双亲委派,会有两个问题:
-
不同 Web 应用无法很好地隔离依赖。
app-a 想用guava-18,app-b 想用guava-31,如果都交给公共父加载器,很容易冲突。 -
Web 应用自己的类应该优先于容器公共类。
应用升级依赖时,不应该轻易影响其他应用或 Tomcat 自身。
所以 Tomcat 的 WebAppClassLoader 大体采用:
- JDK 核心类:仍然先交给父加载器,不能乱来。
- Web 应用自己的
WEB-INF/classes、WEB-INF/lib:应用类加载器优先。 - Tomcat 容器类和公共类:由更上层加载器负责。
这是一种非常务实的设计:不是彻底不要双亲委派,而是在应用隔离边界内使用子优先策略。
十、方式四:OSGi、模块化与插件化
OSGi、IDE 插件系统、一些自研插件框架,也会打破默认双亲委派。
它们通常不是简单的父子树,而是更复杂的网络结构:
Plugin A ClassLoader ─── 依赖 ───> Plugin B ClassLoader
│
└────────────── 依赖 ───> Common API ClassLoader
这种场景要解决的问题是:
- 插件之间隔离。
- 插件可以声明自己导出哪些包。
- 插件可以声明自己依赖哪些包。
- 同一个系统中允许存在同一个库的多个版本。
双亲委派更适合“树形层级 + 共享基础类”的模型;插件系统更需要“按依赖关系精确可见”的模型。
十一、破坏双亲委派的风险
破坏双亲委派不是炫技,它通常是为了解决隔离和扩展问题。用不好会带来很多坑。
11.1 ClassCastException
两个类全限定名一样,但类加载器不同,JVM 会认为它们不是同一个类。
com.example.User loaded by ClassLoaderA
com.example.User loaded by ClassLoaderB
这两个 User 不能互相强转。
11.2 LinkageError
同一个类被不同加载器以不兼容方式加载,可能触发:
NoClassDefFoundErrorNoSuchMethodErrorClassFormatErrorUnsupportedClassVersionErrorLinkageError
这类问题经常出现在依赖版本冲突、热部署残留、插件卸载不干净的场景。
11.3 内存泄漏
类加载器本身也可以被 GC 回收,但前提是它加载的类、对象、线程、静态变量都没有再被引用。
Web 容器热部署后,如果旧应用的线程还活着,或者静态缓存还引用旧 ClassLoader,就会导致旧类加载器无法回收,最终可能引发 Metaspace OOM。
11.4 安全边界被削弱
如果允许业务代码随便覆盖核心类或公共 API,系统行为会变得不可预测。越底层的类,越不应该随意使用子优先加载。
十二、面试中怎么回答?
可以按这个结构回答:
12.1 双亲委派是什么?
类加载器收到加载请求后,先委托父加载器加载,父加载器加载不了时,当前加载器才自己加载。
12.2 为什么要双亲委派?
主要有三点:
- 保护 JDK 核心类库不被篡改。
- 保证类加载的一致性,避免同名核心类出现多份。
- 避免重复加载,让父加载器已加载的类可以被子加载器复用。
12.3 怎么破坏?
常见方式:
- 自定义 ClassLoader,重写
loadClass,实现子优先。 - 使用线程上下文类加载器,让父层代码加载子层实现,例如 SPI。
- Web 容器使用自己的类加载策略,例如 Tomcat 的 WebAppClassLoader。
- OSGi、插件系统、模块化框架基于依赖关系做类隔离。
12.4 破坏有什么风险?
重点说:
- 类名相同但加载器不同,会导致类型不相等。
- 可能出现
ClassCastException、NoSuchMethodError、LinkageError。 - 热部署和插件卸载不当可能导致 ClassLoader 泄漏。
- 不应该对子优先策略放得太宽,尤其不能乱加载核心包。
十三、常见误区
误区一:父加载器是父类
不是。ClassLoader 之间的父子关系不是继承,而是委派链。parent 是一个字段,不是 Java 继承关系。
误区二:双亲委派绝对不能破坏
不是。Tomcat、SPI、OSGi 都在不同程度上绕开或调整了双亲委派。关键不是能不能破坏,而是有没有清晰的隔离边界。
误区三:只要类名一样就是同一个类
不是。JVM 判断类是否相同,要同时看类加载器和类全限定名。
误区四:自定义 ClassLoader 就一定破坏双亲委派
不是。如果只是重写 findClass,仍然是在默认 loadClass 的双亲委派流程下工作。真正改变委派顺序,通常要重写 loadClass。
总结
双亲委派机制可以用一句话概括:
先让父加载器加载,父加载器加载不了,子加载器再加载。
它解决的是类加载的安全性、一致性和复用问题。默认情况下,我们应该遵守这个模型,自定义类加载器时优先重写 findClass。
但在 SPI、Web 容器、插件化、模块化这类场景中,默认双亲委派又不够灵活,需要通过线程上下文类加载器、子优先加载器或更复杂的模块依赖关系来打破它。
最后记住一个判断标准:
如果目标是保护核心类和共享基础能力,应该遵守双亲委派;如果目标是应用隔离、插件隔离或实现发现,才考虑有边界地打破它。

浙公网安备 33010602011771号