Skip to content

类加载机制:双亲委派模型与打破

提出问题

类加载器是 Java 虚拟机的基石,负责将 .class 字节码文件加载到内存并生成对应的 java.lang.Class 对象。双亲委派模型(Parent Delegation Model)是 JDK 1.2 以来的默认加载策略,但许多面试者只记住了"向上委派,向下加载"的口诀,却说不清为什么要有这个模型、在什么场景下必须打破它、以及 Tomcat 和 JDBC 分别是怎么打破的。

生产线上,ClassLoader 导致的问题非常顽固:某个线上应用发布后 Metaspace 持续增长,两周后触发 OOM 进程被 Kill;或者同一个 jar 的不同版本冲突导致 NoSuchMethodError 反复横跳。这些都是 P7+ 工程师必须能独立定位的问题。理解类加载机制的本质,等于理解 Java 运行时隔离和安全性的第一道防线。

分析问题

双亲委派模型的完整链路

双亲委派模型定义了三层类加载器架构:

加载器JDK 8 名称JDK 9+ 名称加载范围实现语言
启动类加载器Bootstrap ClassLoaderBootstrap ClassLoader$JAVA_HOME/lib/rt.jarjava.lang.* 等核心类C++(JVM 内建)
扩展/平台类加载器Extension ClassLoaderPlatform ClassLoaderjre/lib/ext → JDK 9+ 加载平台模块Java
应用类加载器Application ClassLoaderApplication ClassLoaderclasspath-cpCLASSPATH)上的类Java

ClassLoader.loadClass() 被调用时,完整的执行流程如下:

java
// JDK 默认实现,核心逻辑约 30 行
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 第 1 步:检查该类是否已被当前 ClassLoader 加载过
        // 避免重复加载,也是类唯一性的保证之一
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    // 第 2 步:委派给父加载器
                    // 递归向上,直到 Bootstrap ClassLoader
                    c = parent.loadClass(name, false);
                } else {
                    // 第 3 步:没有父加载器,直接交给 Bootstrap
                    // Bootstrap 是 C++ 实现,通过 JNI 调用
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载 → 不处理,继续
            }
            if (c == null) {
                // 第 4 步:父加载器链全都无法加载,自己尝试 findClass()
                // 子类通过重写 findClass() 实现自定义加载逻辑
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

双亲委派模型的时序图:

Application ClassLoader            Extension ClassLoader        Bootstrap ClassLoader
       │                                  │                           │
       │── loadClass("com.example.X")─────│                           │
       │                                  │── loadClass("com.example.X")─│
       │                                  │                           │── findLoadedClass("com.example.X") → null
       │                                  │                           │── findBootstrapClassOrNull → null
       │                                  │〈── ClassNotFoundException─│
       │                                  │── findClass("com.example.X") → 找不到
       │〈── ClassNotFoundException───────│
       │── findClass("com.example.X") → 加载成功

       │── loadClass("java.lang.String")──│
       │                                  │── loadClass("java.lang.String")─│
       │                                  │                           │── findLoadedClass → 已加载
       │                                  │〈── 返回 String.class───────│
       │〈── 返回 String.class────────────│

这个模型的核心价值有两个:

  • 安全性java.lang.String 永远由 Bootstrap 加载。如果用户代码里写一个 com.lang.String 想替换核心 API,由于双亲委派机制,Bootstrap 层已经加载了 java.lang.String,你自己的 String 类根本不会被加载。JDK 9+ 模块化(JPMS)通过 package 封装的导出规则进一步加固了这一点——Bootstrap 层模块的包如果未导出,子加载器连加载同名包都不允许。

  • 唯一性:同一个类在同一个 ClassLoader 空间内只加载一次。JVM 中判断两个类是否"相同"的标准是:全限定类名 + 定义类加载器。同一个 ClassLoader 加载同一个类两次,只会返回同一个 Class<?> 对象。这是 findLoadedClass() 的缓存作用。

JDBC 的 SPI 如何打破双亲委派

JDBC 是"反向打破"的经典案例。java.sql.DriverManagerrt.jar 中,由 Bootstrap ClassLoader 加载。但数据库驱动(如 com.mysql.cj.jdbc.Driver)在应用程序的 classpath 上,由 Application ClassLoader 加载。Bootstrap 无法访问 Application 的类——这是双亲委派自身的盲区。

解决方案:线程上下文类加载器(Thread Context ClassLoader, TCCL)

java
// DriverManager 中获取驱动的本质逻辑(JDK 源码简化)
public class DriverManager {
    static {
        // 初始化时加载所有 META-INF/services/java.sql.Driver 中声明的驱动
        loadInitialDrivers();
    }
    
    private static void loadInitialDrivers() {
        String drivers = AccessController.doPrivileged(
            new GetPropertyAction("jdbc.drivers")
        );
        
        // 核心:通过 ServiceLoader 加载
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        Iterator<Driver> driversIterator = loadedDrivers.iterator();
        
        try {
            while (driversIterator.hasNext()) {
                // 这行会触发驱动类的加载和注册
                // 驱动类的构造函数中会调用 DriverManager.registerDriver(this)
                driversIterator.next();
            }
        } catch (Throwable ignored) {
            // 一个驱动加载失败不影响其他驱动
        }
    }
}

ServiceLoader.load(Driver.class) 内部做了什么?

java
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取当前线程的上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return new ServiceLoader<>(service, cl);
}

关键点:ServiceLoader 通过 TCCL(默认是 Application ClassLoader)来加载实现类,而不是用 Bootstrap ClassLoader。这就是父加载器请求子加载器加载,对双亲委派模型的逆向打破。

踩坑案例:TCCL 设置不当

我遇到过的一个生产事故:一个 Spring Boot 应用使用了自定义线程池,在线程池中通过 DriverManager.getConnection() 获取数据库连接。自定义线程池中的线程没有继承原来的 TCCL,TCCL 为 null。结果运行时抛出 ClassNotFoundException: com.mysql.cj.jdbc.Driver

java
// 问题代码
executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
    // 这里 TCCL 为 null,DriverManager 无法加载驱动
    Connection conn = DriverManager.getConnection(url, user, pass);
});

// 修复方案:提交任务时设置 TCCL
executor.submit(() -> {
    ClassLoader original = Thread.currentThread().getContextClassLoader();
    try {
        Thread.currentThread().setContextClassLoader(
            this.getClass().getClassLoader()  // 或者从主线程传入
        );
        Connection conn = DriverManager.getConnection(url, user, pass);
    } finally {
        Thread.currentThread().setContextClassLoader(original);
    }
});

类似的 SPI 机制还有 JNDI、JAXP、JAXB、SLF4J 等。面试追问通常会问:如果 setContextClassLoader 设置不当会发生什么?答案是:驱动/SDK 加载失败,连接池初始化报 ClassNotFoundException,这是微服务多模块部署中常见的坑。

Tomcat 的 Webapp ClassLoader 如何打破双亲委派

Tomcat 的类加载器体系包含 4 层:

Bootstrap ClassLoader

    └── Common ClassLoader(加载 $CATALINA_HOME/lib/*.jar)

            ├── Catalina ClassLoader(加载 Tomcat 内部类)

            └── Shared ClassLoader(所有 Webapp 共享)

                    └── WebappX ClassLoader(每个 Web 应用一个)
                          ├── WEB-INF/lib/*.jar
                          └── WEB-INF/classes/

关键区别在于 Webapp ClassLoader 先自己尝试加载,加载不到才委派给父加载器:

java
// Tomcat 的 WebappClassLoaderBase.loadClass() 简化逻辑
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 第 1 步:检查本地缓存(已加载过就直接返回)
    Class<?> clazz = findLoadedClass0(name);
    if (clazz != null) return clazz;
    clazz = findLoadedClass(name);
    if (clazz != null) return clazz;
    
    // 第 2 步:检查系统类(java.* 等,仍由 Bootstrap 加载)
    // 防止篡改 JDK 核心 API
    if (name.startsWith("java.")) {
        try {
            return parent.loadClass(name, resolve);
        } catch (ClassNotFoundException e) { /* 忽略 */ }
    }
    
    // 第 3 步:自己加载(WEB-INF/lib 和 WEB-INF/classes)
    // 这是打破的关键:先己后人
    try {
        return findClass(name);
    } catch (ClassNotFoundException e) { /* 忽略 */ }
    
    // 第 4 步:自己加载不到,才委派给父加载器链
    // 遵循双亲委派继续向上查找
    return super.loadClass(name, resolve);
}

为什么需要这种"先己后人"的设计?

场景:一个 Tomcat 部署两个应用,A 用 Spring 4.3.30,B 用 Spring 5.3.25。如果遵循双亲委派:

父加载器加载 spring-core-4.3.30.jar → 
B 应用尝试加载 Spring 5 的 DispatcherServlet → 
父加载器返回 Spring 4 的版本 → 
B 应用运行时调用一个 4.x 没有的方法 → NoSuchMethodError

每个 Webapp 自己的 ClassLoader 先加载自己 WEB-INF/lib 下的 jar,完美实现了类版本隔离。但有两个例外:

  • java.* 开头的类仍然由 Bootstrap 加载,防止篡改核心 API
  • javax.servlet.* 等 Servlet API 类由 Common ClassLoader 加载,避免不同 Webapp 各自加载导致类型不兼容(类型不兼容的表现:A 应用传一个 HttpServletRequest 给 B 应用的代码,但两者由不同的 ClassLoader 加载,JVM 判定为不同类型,抛出 ClassCastException

实战:ClassLoader 泄漏排查

ClassLoader 泄漏是 Metaspace OOM 的常见元凶,尤其在 Spring Boot 热部署、Tomcat 重部署、动态代理生成大量代理类的场景。核心问题:旧 ClassLoader 被某个对象持有引用,导致该 ClassLoader 和它加载的所有类都无法被 GC

一次真实的 Metaspace OOM 排查过程:

某 Spring Boot 应用使用 DevTools 热部署,每天部署 10 次,一周后触发 OOM,应用被 Kill。

排查步骤:

bash
# 第 1 步:查看类加载器数量和加载的类数
jmap -clstats <pid> | head -30

# 典型输出:
# ClassLoader         Classes  Bytes     Parent  Alive?  Type
# 0x00000007c06a3a80  1523    8.5M      0x...   dead    RestartClassLoader
# 0x00000007c086a090  1498    8.2M      0x...   dead    RestartClassLoader
# 0x00000007c0a2e6a0  1467    8.0M      0x...   dead    RestartClassLoader
# ... 共 50+ 个 dead 的 RestartClassLoader

每个 dead 的 ClassLoader 占用 8MB 左右,50 个就是 400MB,Metaspace 默认大小只有 256MB(JDK 8 默认),直接被撑爆。

第 2 步:用 MAT 或 jhat 分析 heap dump,找出谁持有旧 ClassLoader 的引用。

bash
# 生成 heap dump
jmap -dump:live,format=b,file=heap.hprof <pid>

# 用 MAT 打开后,执行 OQL 查询所有 ClassLoader 实例
# SELECT * FROM INSTANCEOF java.lang.ClassLoader

常见的泄漏持有者:

持有者类型典型场景修复方法
ThreadLocal在应用启动时 set 了一个 ThreadLocal,应用关闭时没 remove@PreDestroy 或监听器中调用 ThreadLocal.remove()
Log4j/Logback LoggerLogger 持有 ClassLoader 引用,应用关闭时 Logger 未销毁使用 SLF4J 的 LoggerContext.stop() 清理
后台线程(ScheduledExecutorService)定时任务线程持有了启动时的 ClassLoader应用关闭时 shutdown() 线程池
已注册的 Listener未取消注册的 ServletContextListenerHttpSessionListenercontextDestroyed() 中清理
JDBC DriverDriverManager 持有已注册的驱动引用contextDestroyed()DriverManager.deregisterDriver()

预防措施(最佳实践清单):

bash
# 启动参数加监控
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:MetaspaceSize=256m    # 设置初始大小,避免频繁 GC
-XX:MaxMetaspaceSize=512m # 设置上限,防止无限制增长

对于热部署框架,Spring Boot DevTools 使用 RestartClassLoader 机制,每次重启创建新 ClassLoader 替代旧的。但需要确保所有持有 ClassLoader 引用的对象在应用关闭时被清理

java
@Component
public class ClassLoaderCleanupListener {
    
    @PreDestroy
    public void cleanup() {
        // 1. 清理 ThreadLocal
        // 2. 取消注册所有 JDBC Driver
        Enumeration<Driver> drivers = DriverManager.getDrivers();
        while (drivers.hasMoreElements()) {
            Driver driver = drivers.nextElement();
            DriverManager.deregisterDriver(driver);
        }
        // 3. 关闭线程池
        // 4. 清理 Logger 上下文
    }
}

总结

四种打破双亲委派的场景对比

场景打破方式核心原因技术实现注意事项
JDBC SPI逆向:父 loader 通过 TCCL 请求子 loader核心 API 在 Bootstrap,实现类在 classpathServiceLoader.load() + Thread.currentThread().getContextClassLoader()TCCL 设错会导致驱动加载失败,多线程场景需显式传递
Tomcat 多应用正向:Webapp loader 先自己加载不同版本 jar 隔离WebappClassLoaderBase 重写 loadClass(),先己后人java.* 仍由 Bootstrap 加载,javax.servlet.* 由 Common 加载
OSGi 模块化完全自定义:网络化类加载器模块间版本隔离 + 热更新每个 bundle 一个 ClassLoader,通过 Import-Package 声明依赖复杂度高,大型项目才用,调试困难
热部署(Spring Boot DevTools)创建新 ClassLoader 替换旧的加载修改后的类RestartClassLoader 每次重启创建新实例注意旧 ClassLoader 泄漏,详见上方实战排查

面试话术

"我会先确认面试官问的是哪个层面的打破。如果是 SPI,我会讲 TCCL 的机制和 ServiceLoader 的源码;如果是 Tomcat 隔离,我会讲 WebappClassLoader 先加载的源码设计;如果是 OSGi,我会讲模块化类加载器网络的版本策略。日常开发中,我最常遇到的是 Metaspace OOM,排查时先用 jmap -clstats 看 ClassLoader 数,再用 MAT 追 GC Root 路径,重点关注 ThreadLocal、JDBC Driver 注册、后台线程这几个常见泄漏点。"

灵魂拷问

  • 为什么 Class.forName("com.mysql.cj.jdbc.Driver") 在 JDBC 4.0 之后不需要手动写了?—— SPI 自动发现机制
  • 两个 ClassLoader 加载了同一个类的两个副本,它们之间能互相转换吗?—— 不能,JVM 认为它们是不同的类型,instanceof 返回 false
  • 为什么 java.lang.String 不能被自定义 ClassLoader 加载?—— JVM 安全规范,loadClass() 内部对 java.* 有特殊判断
  • 如果 Tomcat 的 Common ClassLoaderWebapp ClassLoader 都加载了同一个类,谁优先?—— 取决于类名,如果是 java.*javax.servlet.*,Common 优先;否则 Webapp 优先

参考:JDK 源码 java.lang.ClassLoaderjava.util.ServiceLoader;Tomcat 源码 org.apache.catalina.loader.WebappClassLoaderBase;《深入理解 Java 虚拟机》第 7 章

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。