02.2 - 双亲委派模型与打破
定位: 类加载器的协作契约——决定"谁来加载哪个类"的规则 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 原理说明、安全优势、SPI 打破动机、TCCL 的桥接作用
一、这是什么?为什么需要它?
问题: 谁来加载一个类?
当一个 JVM 需要加载 java.lang.String 时,问题就来了——ClassPath 上可能同时存在多个同名的 String.class(JRE 自带的、应用 jar 里的、甚至恶意构造的)。到底加载哪一个?
如果没有明确的规则,就会产生混乱——甚至安全漏洞。
模型: 向上委派
双亲委派模型(Parent Delegation Model)的规则极其简单:
当一个类加载器收到类加载请求,它不会自己直接加载,而是先将请求委派给父加载器。只有父加载器无法完成加载时,子加载器才会自己尝试。
AppClassLoader.loadClass("com.example.Foo")
│
↓ (先委派给父)
ExtClassLoader.loadClass("com.example.Foo")
│
↓ (再委派给父)
BootstrapClassLoader.loadClass("com.example.Foo")
│
↓ Bootstrap 找不到 → 回到 Ext
↓
ExtClassLoader 尝试 findClass("com.example.Foo")
│
↓ Ext 也找不到 → 回到 App
↓
AppClassLoader.findClass("com.example.Foo") → 在 classpath 找到 → 加载二、原理拆解
2.1 三层 ClassLoader
┌──────────────────────────────────────────────────────────────┐
│ 三层类加载器层级 │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Bootstrap ClassLoader (启动类加载器) │ │
│ │ │ │
│ │ - 负责: $JAVA_HOME/lib/rt.jar, core.jar │ │
│ │ - 加载: java.lang.*, java.util.*, java.io.* 等核心库 │ │
│ │ - 实现: C++ 实现,不是 Java 对象,没有 parent │ │
│ │ - 获取: 在 Java 代码中获取不到——返回 null │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ ↑ 委派 │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ Extension ClassLoader (扩展类加载器) │ │
│ │ │ │
│ │ - 负责: $JAVA_HOME/lib/ext/*.jar 或 │ │
│ │ java.ext.dirs 指定的目录 │ │
│ │ - 加载: 扩展 API (如 javax.*) │ │
│ │ - 实现: sun.misc.Launcher$ExtClassLoader (Java) │ │
│ │ - parent: null (看似奇怪,但逻辑上 parent 是 Bootstrap)│ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ ↑ 委派 │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ Application ClassLoader (应用类加载器) │ │
│ │ │ │
│ │ - 负责: classpath (-cp) 上所有的 jar 和目录 │ │
│ │ - 加载: 我们写的业务代码 │ │
│ │ - 实现: sun.misc.Launcher$AppClassLoader (Java) │ │
│ │ - parent: ExtClassLoader │ │
│ │ - 获取: ClassLoader.getSystemClassLoader() │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 自定义 ClassLoader (用户扩展) │ │
│ │ parent 默认为 AppClassLoader │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘2.2 为什么需要向上委派?
原因一:安全隔离——核心类库不可被替换
// 假设你写了一个自己的 java.lang.String
package java.lang;
public class String {
// 替换了核心逻辑,企图窃取信息
}双亲委派模型下,当自定义 ClassLoader 要加载 java.lang.String 时,它先委派给 Application → Extension → Bootstrap。Bootstrap 在自己的 rt.jar 中找到了 java.lang.String,加载并返回。你的 String 永远不会被用到。
这就是 Java 安全模型的第一道防线——即使你写了同名的核心类,双亲委派机制保证优先加载 JRE 自带的版本。
即使你使用
defineClass手动加载,JVM 也有保护机制:对于java.*开头的包,只有 Bootstrap ClassLoader 有权限加载——其他加载器如果试图加载会抛SecurityException。
原因二:避免重复加载
JVM 中一个类由 (ClassLoader + 全限定名) 唯一标识。如果同一个类被不同的 ClassLoader 加载了两次:
- 这两个类在 JVM 中被视为完全不同的类型(即使字节码完全一样)
instanceof检查会失败- 类型转换会抛
ClassCastException
双亲委派确保顶级类只被 Bootstrap 加载一次,所有子孙加载器共享该类的定义。
// 同一个类被两个 ClassLoader 加载 —— 变成两个不同的类型
ClassLoader loaderA = new URLClassLoader(new URL[]{url});
ClassLoader loaderB = new URLClassLoader(new URL[]{url});
Class<?> classA = loaderA.loadClass("com.example.Foo");
Class<?> classB = loaderB.loadClass("com.example.Foo");
System.out.println(classA == classB); // false!
System.out.println(classA.isInstance(classB.newInstance())); // false!2.3 为什么需要打破委派?
SPI 场景:当核心库需要调用应用实现时
最典型的例子是 JDBC:
DriverManager.getConnection(url)
│
├─ DriverManager 在 rt.jar 中,由 Bootstrap ClassLoader 加载
│
├─ DriverManager 需要加载 JDBC 驱动 (如 com.mysql.cj.jdbc.Driver)
│ 这个驱动在 mysql-connector-java.jar,由 AppClassLoader 加载
│
└─ 问题: Bootstrap 加载的代码不能直接调 AppClassLoader 加载的类
(双亲委派的方向是向上,不是向下)这是双亲委派模型的固有缺陷——委派方向是单向的:子→父。但某些场景需要父加载器调用子加载器的类。
解决方案:线程上下文类加载器 (TCCL)
TCCL 是一个存储在 Thread 中的 ClassLoader 引用,不是新的类加载器。它是一个钩子,让核心库代码能临时借用应用类加载器的能力。
┌─ Bootstrap ClassLoader ───────────────────────┐
│ │
│ DriverManager.getConnection(url) │
│ ↓ │
│ ServiceLoader.load(Driver.class) │
│ ↓ │
│ 读取 META-INF/services/java.sql.Driver │
│ ↓ (得到 com.mysql.cj.jdbc.Driver 类名) │
│ ↓ │
│ Thread.currentThread().getContextClassLoader() │
│ ↓ │
│ → AppClassLoader 加载 mysql 驱动类 │
│ │
└────────────────────────────────────────────────┘设置 TCCL 的典型代码:
// 核心库(Bootstrap 加载的)中的写法
Thread currentThread = Thread.currentThread();
ClassLoader tccl = currentThread.getContextClassLoader();
// 用 TCCL 加载应用类
Class<?> driverClass = tccl.loadClass("com.mysql.cj.jdbc.Driver");本质: TCCL 是在单向委派的层级结构中,开了一个"反向通道"。Bootstrap 加载的代码通过这个通道可以"借用" AppClassLoader 的能力。
2.4 loadClass 与 findClass 的职责分离
java.lang.ClassLoader 的设计体现了"模板方法模式"——
// 模板方法: 实现委派逻辑
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 委派父加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 委派 Bootstrap
}
} catch (ClassNotFoundException e) {
// 父加载器抛异常 → 交给子加载器
}
if (c == null) {
c = findClass(name); // 4. 自己加载
}
}
if (resolve) resolveClass(c);
return c;
}
// 可重写的方法: 用户只需实现这个
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}这就是为什么自定义 ClassLoader 只需要重写 findClass 而不是 loadClass——delegation 逻辑是固定模式,一般不需要修改。
三、图解全景
┌─────────────────────────────────────────────────────────────────────┐
│ 双亲委派 + SPI 打破示意 │
│ │
│ Normal (向上委派) │
│ │
│ 请求加载 java.lang.String │
│ │ │
│ ▼ │
│ ┌─ 自定义 CL ──┐ ┌─ App CL ──┐ ┌─ Ext CL ──┐ ┌─ Boot CL ──┐
│ │ 委派给父 │ ──→ │ 委派给父 │ ──→ │ 委派给父 │ ──→ │ 加载核心类 │
│ │ (找不到自己) │ ←── │ (找不到) │ ←── │ (找不到) │ ←── │ 返回 Class │
│ └──────────────┘ └───────────┘ └───────────┘ └────────────┘
│ │
│ SPI (向下借用) │
│ │
│ ┌─ Bootstrap ──────────────────────┐ │
│ │ DriverManager.getConnection(url) │ │
│ │ ↓ │ │
│ │ TCCL = AppClassLoader │ │
│ │ ↓ │ │
│ │ 调用 App ClassLoader 加载类 │ ───→ 反向借用 │
│ └───────────────────────────────────┘ │ │
│ ▼ │
│ ┌─ App ClassLoader ──┐ │
│ │ 加载 MySQL Driver │ │
│ │ 加载 PostgreSQL │ │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘四、实战验证
4.1 验证 java.lang.String 不能被替换
// TryReplaceString.java —— 尝试用自定义 ClassLoader 加载 java.lang.String
public class TryReplaceString {
public static void main(String[] args) throws Exception {
ClassLoader customLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if ("java.lang.String".equals(name)) {
// 尝试用自己的类定义替换 String
byte[] bytes = "fake".getBytes();
return defineClass(name, bytes, 0, bytes.length);
// ↑ 会抛 SecurityException: Prohibited package name: java.lang
}
return super.loadClass(name);
}
};
try {
customLoader.loadClass("java.lang.String");
} catch (Exception e) {
System.out.println("Security check caught: " + e);
}
// 验证: String 始终是 JRE 自带的
System.out.println("String class loader: " + String.class.getClassLoader());
// 输出: null (表示 Bootstrap ClassLoader)
}
}4.2 验证 TCCL 的 SPI 桥接
// TCCLDemo.java —— 观察 TCCL 的工作方式
public class TCCLDemo {
public static void main(String[] args) {
// 默认 TCCL 是 AppClassLoader
System.out.println("Default TCCL: " + Thread.currentThread().getContextClassLoader());
// 模拟 SPI 场景: Bootstrap 加载的代码通过 TCCL 加载应用类
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
try {
Class<?> clazz = tccl.loadClass("com.mysql.cj.jdbc.Driver");
System.out.println("Loaded via TCCL: " + clazz.getName());
System.out.println("Loaded by: " + clazz.getClassLoader());
} catch (ClassNotFoundException e) {
System.out.println("MySQL driver not on classpath, that's expected for demo");
}
}
}五、面试视角
| 追问 | 答案要点 |
|---|---|
| 双亲委派模型是什么? | 类加载器在收到类加载请求时,先委派给父加载器,父加载器无法完成时才自己尝试加载。目的是保证核心类库的安全和唯一性 |
| 双亲委派的两个核心优势是什么? | 安全隔离(核心类不能被子加载器替换)和避免重复加载(同一类在 JVM 中只有一份定义) |
| 哪些场景打破了双亲委派? | SPI(JDBC、JNDI)需要通过 TCCL 反向借用;Tomcat WebappClassLoader 先自己加载;OSGI 网状依赖;热部署场景 |
| SPI 是如何打破双亲委派的? | 核心库代码(Bootstrap 加载)通过 Thread.currentThread().getContextClassLoader() 获取应用类加载器,加载 META-INF/services 中声明的实现类。ServiceLoader 封装了这一过程 |
| TCCL 设置在哪里?不设置会怎样? | TCCL 是 Thread 对象的实例字段。默认继承父线程的 TCCL,默认为 AppClassLoader。不设置的话 SPI 机制默认走 AppClassLoader,在大多数场景下是可用的 |
loadClass 和 findClass 的关系? | loadClass 实现委派逻辑(模板方法),findClass 是子类需要重写的钩子方法。职责分离:委派逻辑固定,加载逻辑可自定义 |
| Bootstrap ClassLoader 为什么在 Java 中获取不到(返回 null)? | Bootstrap 由 C++ 实现,不是 Java 对象,在 JVM 内部没有对应的 Java 实例。getClassLoader() 返回 null 表示该类由 Bootstrap 加载 |
如果自己写一个 java.lang.String 会怎么样? | 自定义 ClassLoader 加载时 JVM 抛出 SecurityException("Prohibited package name: java.lang")。核心包名被保护,只有 Bootstrap 能加载 |
| 双亲委派模型有什么缺点? | 委派方向单一,顶层加载器无法调用底层加载器的类(SPI 场景)。父加载器不知道子加载器能加载哪些类,必须借助 TCCL 之类的"反向通道" |
📚 相关链接
- **类加载生命周期** — 类加载器在生命周期中的角色
- **自定义类加载器实战** — 如何实现自定义加载器
- **OSGI与SPI模块化** — SPI 和 OSGI 的详细原理
- **方法区与元空间** — 类加载后元数据在方法区的存储
- ← 返回 **类加载索引**