双亲委派模型
面试官问:"什么是双亲委派模型?为什么要这样设计?"
候选人小李说:"为了安全,防止核心类被篡改。"面试官追问:"那如果我想加载一个自定义的 java.lang.String 呢?"
小李说:"会被父加载器拦截..."面试官:"那如果我的类加载器加载不了 java.lang.String,但我强制调用自己的加载逻辑呢?"
小李答不上来。
这道题真正考察的是:你知不知道双亲委派的实现细节,以及它的边界在哪里。
一、双亲委派的核心原理 🔴
1.1 什么是双亲委派
双亲委派模型(Parent Delegation Model):当一个类加载器收到加载请求时,它不会自己先去加载,而是把这个请求委托给父类加载器去处理。层层往上传递,直到 Bootstrap ClassLoader。只有父加载器反馈无法完成时,子加载器才会尝试自己加载。
用户自定义 ClassLoader
↓ loadClass()
Extension ClassLoader
↓ loadClass()
Bootstrap ClassLoader
↓ 如果找到了
返回 Class 对象
↓ 如果找不到
Extension ClassLoader
↓ 如果找到了
返回 Class 对象
↓ 如果找不到
用户自定义 ClassLoader
↓ findClass()
自己加载
1.2 源码解析:loadClass 的实现
// ClassLoader.loadClass() 的核心逻辑(JDK 8 源码)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// Step 1: 检查这个类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c != null) {
return c; // 已经加载,直接返回
}
// Step 2: 委派给父类加载器
try {
ClassLoader parent = this.parent;
if (parent != null) {
c = parent.loadClass(name, false); // 递归调用父类的 loadClass
} else {
// 没有父加载器(Bootstrap),委托给 Bootstrap
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类找不到这个类
}
// Step 3: 父类都找不到,自己来
if (c == null) {
c = findClass(name); // 子类重写此方法
}
return c;
}
}
双亲委派的三个关键步骤:
- 检查是否已加载:
findLoadedClass 查询当前加载器的缓存
- 委派父加载器:递归调用父加载器的
loadClass
- 自己加载:
findClass 由子类实现
1.3 为什么要双亲委派
答案一:安全(最常被提及)
防止核心类库被篡改。如果允许自定义类加载器直接加载 java.lang.String,恶意代码可以伪造一个假的 String 类来绕过安全检查。
恶意代码的攻击路径:
1. 自定义 ClassLoader
2. 加载 java.lang.String(假的)
3. 覆盖核心 String 的行为
4. 绕过安全管理器检查
答案二:类的唯一性保证
JVM 中类的唯一性由"类加载器 + 类名"共同确定。双亲委派保证核心类库的类由同一个加载器加载,从而保证了类的行为一致性。
答案三:避免重复加载
父加载器加载过的类,子加载器不需要重复加载。如果父加载器已经加载过,直接返回即可。
⚠️
面试陷阱:有人说"双亲委派只是为了安全"。准确答案是:安全是主要原因,但不是唯一原因。避免重复加载、保证类的唯一性也是重要原因。更深层的思考是:双亲委派本质上是一种层级化的信任机制——高层级加载器加载的类对低层级加载器是"可信的",低层级加载器的行为被限制在它的作用域内。
二、三层类加载器详解 🟡
2.1 Bootstrap ClassLoader
// Bootstrap ClassLoader 负责加载核心类库
// 它的实现是 C++ 编写的,在 Java 代码中获取为 null
ClassLoader cl = String.class.getClassLoader(); // null
// 它加载的类包括:
// - java.lang.*
// - java.util.*
// - java.io.*
// - sun.*
// 等核心 API
2.2 Extension ClassLoader
// Extension ClassLoader 负责加载 $JAVA_HOME/jre/lib/ext/ 下的类
// 或者由系统属性 java.ext.dirs 指定的目录
// 实现:sun.misc.Launcher$ExtClassLoader
ClassLoader extLoader = new sun.misc.Launcher$ExtClassLoader();
2.3 Application ClassLoader
// Application ClassLoader 负责加载 CLASSPATH 下的类
// 实现:sun.misc.Launcher$AppClassLoader
// 这是应用程序默认使用的类加载器
// 我们写的代码通常都是由 AppClassLoader 加载的
ClassLoader appLoader = ClassLoaderTest.class.getClassLoader();
2.4 类加载器的层级验证
public class ClassLoaderDemo {
public static void main(String[] args) {
// 当前类的类加载器是 AppClassLoader
ClassLoader cl = ClassLoaderDemo.class.getClassLoader();
System.out.println("当前类加载器: " + cl); // AppClassLoader
System.out.println("父加载器: " + cl.getParent()); // ExtClassLoader
System.out.println("祖父加载器: " + cl.getParent().getParent()); // null
}
}
三、线程上下文类加载器 🟡
3.1 问题引入:SPI 如何跨层加载?
JDBC 是 Java 最经典的 SPI 案例。java.sql.Driver 接口由 JDK 提供(Bootstrap ClassLoader 加载),但实现类(如 MySQL Driver)由厂商提供(Application ClassLoader 加载)。
问题:Bootstrap ClassLoader 看不到 Application ClassLoader 中的类,怎么加载 MySQL Driver?
// java.sql.DriverManager 的源码(JDK 8)
// 它通过线程上下文类加载器来加载 Driver 实现
public class DriverManager {
static {
// 使用线程上下文类加载器加载 Driver
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl != null) {
ServiceLoader<Driver> loadedDrivers =
ServiceLoader.load(Driver.class, cl);
}
}
}
3.2 线程上下文类加载器的原理
// 每个线程都有一个关联的 ContextClassLoader
// 默认继承自父线程的 ContextClassLoader
// 主线程的 ContextClassLoader 默认是 AppClassLoader
// 获取
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 设置(通常是框架代码在初始化时设置)
Thread.currentThread().setContextClassLoader(myClassLoader);
// 用途:
// 1. SPI 机制(JDBC、JNDI、JAXP)
// 2. OSGi 框架的类加载
// 3. Tomcat 的 WebAppClassLoader
3.3 面试追问:双亲委派会被打破吗?
答案是:会的。 但不是通过重写 loadClass,而是通过线程上下文类加载器。
打破双亲委派的路径:
1. Bootstrap ClassLoader 加载了 java.sql.DriverManager
2. DriverManager 需要加载 MySQL 的 Driver 实现
3. 但 Bootstrap 看不到 MySQL Driver(它在 AppClassLoader 中)
4. 通过 Thread.currentThread().getContextClassLoader() 获取 AppClassLoader
5. 用 AppClassLoader 加载 MySQL Driver
这个机制"反向"使用了类加载器层级,绕过了双亲委派的限制。
【面试官心理】
这道题我能追问到 P7 级别,是因为线程上下文类加载器是理解 Java SPI 和 OSGi 的基础。知道双亲委派的边界在哪里,才能真正理解 Java 的类加载体系。
四、双亲委派的局限与例外 🟡
4.1 双亲委派不能解决的问题
4.2 Tomcat 的类加载器设计
Bootstrap ClassLoader
↓
Extension ClassLoader
↓
System ClassLoader (AppClassLoader)
↓
Common ClassLoader (Tomcat 全局共享)
↓
WebApp ClassLoader (每个 Web 应用独立)
↓
Jasper ClassLoader (JSP 编译后)
Tomcat 的 WebApp ClassLoader 默认不使用双亲委派:
// Tomcat WebappClassLoaderBase.loadClass() 的逻辑
// 先检查自己加载过没有,没有的话再检查父加载器
// 这样每个 Web 应用可以加载自己版本的类(如老版本的 Spring)
💡
面试加分点:能说出"Tomcat 的 WebAppClassLoader 重写了 loadClass,先检查自己的缓存,没有再调用父类。目的就是打破双亲委派,实现 Web 应用之间的类隔离",说明他对 Web 容器的类加载机制有深入理解。
五、生产问题排查 🟢
5.1 类加载器导致的 ClassCastException
// 问题代码
ClassLoader cl1 = new MyClassLoader();
ClassLoader cl2 = new MyClassLoader();
Class<?> c1 = cl1.loadClass("com.example.Foo");
Class<?> c2 = cl2.loadClass("com.example.Foo");
Object obj = c1.newInstance();
// c1 和 c2 虽然类名相同,但由不同的加载器加载
// 所以 c1 != c2
// 这行代码会抛出 ClassCastException
// 原因:obj 的 Class 是 c1,但强转目标类型是 c2
((Foo) obj); // ClassCastException!
根因:JVM 中类的唯一性由"类加载器 + 类名"共同确定。两个不同的类加载器加载同一个类,在 JVM 中是两个完全不同的类。
5.2 排查命令
# 查看进程中的类加载器
jps -l | grep java
# 输出:12345 com.example.Application
# 查看类的加载来源
jcmd 12345 VM.classload_stats
# 查看特定类的加载器
jcmd 12345 VM.native_memory
# 使用 Arthas 查看类加载器
arthas> classloader -l
# 列出所有类加载器及其加载的类数量
【架构权衡】
双亲委派模型是 Java 类加载体系的基石,但它的本质是受限的信任机制——每个类加载器只能信任它的父加载器加载的类,不能依赖子加载器。
理解这个模型,关键在于理解它的边界:SPI 通过线程上下文类加载器打破边界,OSGi 和 Tomcat 通过自定义加载器打破边界。打破是为了解决真实问题,坚守是为了保证安全——这个平衡就是架构设计的艺术。