双亲委派模型

面试官问:"什么是双亲委派模型?为什么要这样设计?"

候选人小李说:"为了安全,防止核心类被篡改。"面试官追问:"那如果我想加载一个自定义的 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;
    }
}

双亲委派的三个关键步骤

  1. 检查是否已加载findLoadedClass 查询当前加载器的缓存
  2. 委派父加载器:递归调用父加载器的 loadClass
  3. 自己加载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 双亲委派不能解决的问题

问题场景解决方案
类的隔离Tomcat 每个 Web 应用需要加载自己版本的类自定义 ClassLoader,不使用双亲委派
热部署OSGi 需要在不重启 JVM 的情况下更新类自定义 ClassLoader,重新加载类
SPI 跨层加载JDBC 需要加载厂商提供的 Driver线程上下文类加载器

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 通过自定义加载器打破边界。打破是为了解决真实问题,坚守是为了保证安全——这个平衡就是架构设计的艺术。