Skip to content

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
// 假设你写了一个自己的 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 加载一次,所有子孙加载器共享该类的定义。

java
// 同一个类被两个 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 的典型代码:

java
// 核心库(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 的设计体现了"模板方法模式"——

java
// 模板方法: 实现委派逻辑
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 不能被替换

java
// 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 桥接

java
// 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,在大多数场景下是可用的
loadClassfindClass 的关系?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 的详细原理
  • **方法区与元空间** — 类加载后元数据在方法区的存储
  • ← 返回 **类加载索引**

Knowledge4J — Java 知识库