打破双亲委派场景

面试官问:"双亲委派能被打破吗?"

候选人小赵说:"不能吧,这是 JVM 的机制。"面试官追问:"那 Tomcat 怎么实现每个 Web 应用加载自己版本的 Spring 的?"

小赵愣在原地。

这道题的答案是:能打破,而且必须打破。双亲委派是一个优秀的设计原则,但不是铁律。当你有多个独立的模块需要各自的类隔离时,就必须打破它。

一、为什么要打破双亲委派 🔴

1.1 问题场景

场景一:多版本共存

Tomcat 服务器上部署了 3 个 Web 应用:
- App A: Spring 5.x
- App B: Spring 4.x(遗留系统)
- App C: Spring 5.x

如果使用标准的双亲委派:
AppClassLoader → ExtClassLoader → Bootstrap

            Bootstrap 加载的 Spring 版本只有一个

问题:App B 的 Spring 4.x 无法运行(类不兼容)

场景二:热部署

传统方式:修改类文件 → 停机 → 重新部署 → 启动
热部署:修改类文件 → 重新加载类 → 无需重启

热部署需要:卸载旧 ClassLoader → 创建新 ClassLoader → 重新加载类
但旧 ClassLoader 加载的类不能被卸载(因为 GC Roots 持有引用)
→ 需要类隔离,每个模块有独立的 ClassLoader

场景三:SPI 机制

JDBC 驱动加载:
1. java.sql.DriverManager 由 Bootstrap ClassLoader 加载
2. MySQL Driver 由 Application ClassLoader 加载
3. Bootstrap 看不到 Application 的类

如何让 Bootstrap 能加载 MySQL Driver?
→ 使用线程上下文类加载器绕过双亲委派

1.2 打破方式一览

打破方式原理典型场景
线程上下文类加载器通过 Thread.setContextClassLoader() 设置SPI(JDBC、JNDI)
自定义 ClassLoader 不调用父加载器重写 loadClass,不委派父类Tomcat WebAppClassLoader
ThreadLocal + ClassLoader 隔离每个线程有自己的 ClassLoader模块化框架
OSGi 类加载器每个 Bundle 独立 ClassLoader,支持动态导出/导入OSGi 框架

二、场景一:Tomcat 的热部署与类隔离 🟡

2.1 Tomcat 类加载器的层级

Bootstrap
  └── Extension
        └── System (AppClassLoader)
              └── Common (Tomcat 全局)
                    ├── WebAppClassLoader1 (App A, Spring 5)
                    ├── WebAppClassLoader2 (App B, Spring 4)
                    └── WebAppClassLoader3 (App C, Spring 5)

2.2 WebAppClassLoader 的打破策略

Tomcat 的 WebappClassLoader 重写了 loadClass 方法,采用了相反的加载顺序

// Tomcat WebappClassLoader.loadClass() 的核心逻辑
public Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        // Step 1: 检查自己是否已经加载过
        Class<?> clazz = findLoadedClass(name);
        if (clazz != null) {
            return clazz;
        }

        // Step 2: 检查是否是需要委托给父加载器的"系统类"
        // Tomcat 强制规定:java.* 和 javax.* 必须由 Bootstrap 加载
        if (name.startsWith("java.") || name.startsWith("javax.")) {
            return super.loadClass(name, resolve);
        }

        // Step 3: 先从本地仓库加载(打破双亲委派!)
        clazz = findClass(name);
        if (clazz != null) {
            return clazz;
        }

        // Step 4: 最后才委托给父加载器
        clazz = parent.loadClass(name);
        return clazz;
    }
}

关键点:Tomcat 的 WebAppClassLoader 先自己加载,再委托父加载器。这与标准的双亲委派完全相反。

2.3 为什么 Tomcat 要打破双亲委派?

假设不打破双亲委派:

1. App B 需要加载 com.springframework.core.SpringContext
2. 委托父加载器(AppClassLoader)
3. AppClassLoader 从 classpath 加载 Spring 5
4. App B 加载的是 Spring 5,但代码是用 Spring 4 写的
5. 运行时出现 NoSuchMethodError 等类兼容性问题

打破后:
1. App B 需要加载 com.springframework.core.SpringContext
2. 先在自己的 WebAppClassLoader 中查找
3. 找到了,App B 的 /WEB-INF/lib/spring-4.jar
4. 正确加载 Spring 4

2.4 Tomcat 的类加载器白名单

// Tomcat 必须强制委托给父加载器的包
// 原因:这些包由 JVM 或 JDK 提供,不能用 Web 应用的同名类覆盖
private static final String[] DELEGATING_PARAMS = {
    "java.",           // JDK 核心类
    "javax.",          // JDK 扩展
    "org.apache.catalina.",  // Tomcat 核心
    "org.apache.tomcat.",
    "org.apache.naming.",
    "sun.",
    "oracle.",
    "com.sun.",        // JDK 内置类
    "com.google.",
    // ... 更多白名单
};
⚠️

面试陷阱:有人说"Tomcat 完全抛弃了双亲委派"。准确答案是:不是完全抛弃,而是有选择地打破。Tomcat 的 WebAppClassLoader 仍然对 java.*javax.* 等核心包使用标准双亲委派,因为这些类必须由 Bootstrap 加载。打破的只是应用类。

三、场景二:SPI 与线程上下文类加载器 🟡

3.1 JDBC 驱动的加载流程

JDBC 是打破双亲委派的经典案例,但很多人不清楚它的完整流程。

// JDBC 4.0 之前(需要手动加载驱动)
Class.forName("com.mysql.cj.jdbc.Driver");  // 手动注册驱动
Connection conn = DriverManager.getConnection(url, user, pass);

// JDBC 4.0 之后(SPI 自动加载)
Connection conn = DriverManager.getConnection(url, user, pass);
// 不需要 Class.forName 了!

3.2 DriverManager 的加载逻辑

// java.sql.DriverManager 的静态初始化块(JDK 8 源码)
static {
    // 使用线程上下文类加载器加载 Driver 实现
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    if (cl != null) {
        // 加载 java.sql.Driver 的 SPI 实现
        ServiceLoader<Driver> loadedDrivers =
            ServiceLoader.load(Driver.class, cl);
        Iterator<Driver> i = loadedDrivers.iterator();
        while (i.hasNext()) {
            // 注册驱动
            registerDriver(i.next());
        }
    }
    // 清理缓存...
}

完整流程

1. Bootstrap ClassLoader 加载 DriverManager(因为它在 java.sql 包中)
2. 应用程序(AppClassLoader)需要使用 MySQL Driver
3. DriverManager 需要加载 MySQL 驱动类
4. 但 Bootstrap 看不到 MySQL Driver(它在 AppClassLoader 的 classpath 中)
5. Bootstrap 通过 Thread.currentThread().getContextClassLoader()
   获取到 AppClassLoader
6. 用 AppClassLoader 加载 com.mysql.cj.jdbc.Driver
7. 成功!

3.3 JNDI 的场景

JNDI 的 InitialContext 也是类似:

// JNDI 需要通过线程上下文类加载器加载
Context ctx = new InitialContext();
Object obj = ctx.lookup("jdbc/MyDS");

// InitialContext 内部:
// ClassLoader cl = Thread.currentThread().getContextClassLoader();
// Object obj = naming.lookup(name, cl);

3.4 面试追问:如何破坏双亲委派?

直接方式:重写 ClassLoader.loadClass()

class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        // 完全不使用父加载器,自己加载所有类
        Class<?> clazz = findLoadedClass(name);
        if (clazz == null) {
            clazz = findClass(name);  // 直接从自己的路径加载
        }
        return clazz;
    }
}

但这种写法是反模式。正确的方式是理解业务场景后,有选择地打破。

四、场景三:OSGi 的模块化类加载 🟢

4.1 OSGi 的设计思想

OSGi(Open Service Gateway Initiative)是一个模块化框架,每个 Bundle 有自己的 ClassLoader:

Bundle A (版本 1.0)          Bundle B (版本 2.0)
┌────────────────────┐      ┌────────────────────┐
│ A's ClassLoader     │      │ B's ClassLoader    │
│ → 加载 A 的类       │      │ → 加载 B 的类       │
│ → 导入 B 的 1.0 版本│      │ → 导入 A 的 2.0 版本│
└────────────────────┘      └────────────────────┘

4.2 OSGi 的类加载策略

OSGi 使用导入/导出包的方式管理类加载:

// Bundle A 的 MANIFEST.MF
Export-Package: com.example.api; version=1.0

// Bundle B 的 MANIFEST.MF
Import-Package: com.example.api; version="[1.0,2.0)"

OSGi 的类加载器不再遵循双亲委派,而是遵循模块依赖图

Bundle A 导出 com.example.api v1.0
Bundle B 导入 com.example.api
Bundle C 导入 com.example.api
→ B 和 C 各自持有自己的 com.example.api 版本
→ 它们之间不互相干扰

4.3 OSGi vs Tomcat

维度TomcatOSGi
类加载策略自定义 WebAppClassLoader每个 Bundle 独立 ClassLoader
模块边界Web 应用级别Bundle 级别(更细粒度)
热部署支持(重启 Web 应用)支持(动态安装/卸载 Bundle)
依赖管理简单(classpath)复杂(版本范围、依赖解析)
💡

面试加分点:能说出"OSGi 的类加载器之间通过 Bundle 的依赖关系来确定加载顺序,而不是简单的父子层级。当 Bundle A 依赖 Bundle B 时,加载顺序是 B → A。这是 OSGi 能够支持真正的模块化和动态更新的核心",说明他对模块化有深入理解。

五、生产踩坑:类加载器冲突排查 🟡

5.1 典型冲突现象

错误日志:
java.lang.NoSuchMethodError: org.springframework.core.io.Resource.<init>()
    at org.springframework.context.support.ClassPathXmlApplicationContext.<init>

或者:
java.lang.ClassCastException: com.example.Foo cannot be cast to com.example.Foo
    (同一个类名,但由不同的 ClassLoader 加载)

5.2 排查方法

# 方法一: Arthas 查看类加载器
arthas> classloader -l
  ClassLoader name           | Class count
  com.example.MyClassLoader  | 12
  sun.misc.Launcher$AppClassLoader | 234

# 方法二:查看某个类由哪个 ClassLoader 加载
arthas> classloader -c <classLoaderHash> com.example.Foo

# 方法三:打印类加载器链路
arthas> classloader -t com.example.Foo

【架构权衡】 打破双亲委派是为了解决真实问题:

  • Tomcat 打破是为了 Web 应用隔离
  • SPI 打破是为了跨模块依赖注入
  • OSGi 打破是为了真正的模块化

但打破意味着放弃安全的保护,所以必须慎之又慎。Tomcat 用白名单限制必须委派的包,SPI 用线程上下文类加载器这个"线程级"的上下文传递——每种打破方式都有自己的安全边界。