什么是双亲委派?怎么破坏?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 对 JVM 类加载机制的理解:面试官想考察你是否了解类加载器的层次结构,以及双亲委派模型是如何工作的。
  2. 为何要设计双亲委派:不仅仅是背诵定义,更重要的是理解它的设计初衷——保证 Java 核心类库的安全性和唯一性,避免用户自定义类覆盖核心类。
  3. 破坏双亲委派的场景与手段:考察你是否在实际开发或框架设计中遇到过需要打破常规的场景,比如 Tomcat 如何隔离 Web 应用、JDBC 如何加载驱动等。
  4. 源码级认知:通过 loadClass 方法的实现逻辑,判断你是否读过 JVM 或 ClassLoader 相关的源码。
  5. 扩展性与灵活性的权衡:理解双亲委派带来的好处,同时也知道在某些情况下它会成为瓶颈,需要破坏它来获得更大的灵活性。

核心答案

双亲委派模型 是 Java 类加载器在加载类时采用的一种委托机制。当一个类加载器收到加载类的请求时,它首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此,所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父类加载器反馈自己无法加载这个类时(它的搜索范围中没有找到所需的类),子类加载器才会尝试自己去加载。

这种机制保证了 Java 程序运行的安全性和稳定性。例如,我们写的 java.lang.Object 类,无论在哪一个类加载器中,最终都会委托给启动类加载器加载,从而保证了 JVM 中只有唯一的一份 Object 类。

破坏双亲委派 通常指不遵循这个委托流程,让类加载器尝试自己先加载类,或者在特定场景下绕过父加载器直接加载。常见的破坏方式包括:

  • 自定义 ClassLoader 并重写 loadClass 方法,不遵守先委托父类的逻辑。
  • 利用线程上下文类加载器,将父加载器无法加载的类交给子加载器加载(典型应用如 JDBC)。
  • OSGi 或模块化框架 为了实现模块间代码隔离,完全颠覆了双亲委派的树形结构,采用网状结构。

深度解析

原理/机制

在 JVM 中,类加载器主要有三层:

  • 启动类加载器(Bootstrap ClassLoader):C++ 实现,负责加载 <JAVA_HOME>/lib 目录下的核心类库。
  • 扩展类加载器(Extension ClassLoader):Java 实现,负责加载 <JAVA_HOME>/lib/ext 目录下的类库(JDK 9 后被平台类加载器取代)。
  • 应用程序类加载器(Application ClassLoader):负责加载 classpath 上的类,也是我们代码中默认的上下文类加载器。

双亲委派的核心代码在 java.lang.ClassLoaderloadClass 方法中:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先,检查类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 如果有父加载器,则委托父加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 如果没有父加载器,说明是顶层,调用启动类加载器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父类加载器无法加载,抛出异常说明
            }

            if (c == null) {
                // 如果父类加载器没加载到,再调用自己的 findClass 去加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

可见,标准的实现总是先看父类加载器能不能加载,最后才轮到自己。

如何破坏双亲委派?

1. 重写 loadClass 方法,改变委托顺序

最常见的破坏方式就是直接覆盖 loadClass,把查找顺序反过来,或者完全自定义规则。

public class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        // 先自己尝试加载
        synchronized (getClassLoadingLock(name)) {
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    c = findClass(name); // 先用自己的 findClass
                } catch (ClassNotFoundException e) {
                    // 自己加载失败,再委托给父类
                    c = super.loadClass(name, resolve);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 实现自己的类查找逻辑,比如从指定目录读取 .class 文件
        // ...
    }
}

这种自定义加载器在 Tomcat 等 Web 容器中很常见,每个 Web 应用都有自己的类加载器,优先加载 /WEB-INF/classes 下的类,实现了应用之间的隔离。

2. 线程上下文类加载器

这是 JDK 1.2 引入的一种“破坏”手段,用于解决 Java 核心 SPI(Service Provider Interface)的加载问题。比如 JDBC,java.sql.DriverManager 是由启动类加载器加载的,但它需要加载厂商提供的具体驱动实现(如 com.mysql.jdbc.Driver),这些实现位于 classpath 下,启动类加载器无法加载。为了解决这个矛盾,引入了线程上下文类加载器,让核心类库可以通过这个加载器去加载外部实现类。

// 在 DriverManager 中,通过线程上下文类加载器加载驱动
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
// 内部会调用 Thread.currentThread().getContextClassLoader()

Thread.currentThread().getContextClassLoader() 默认返回的是应用程序类加载器,可以加载 classpath 下的类。这样,启动类加载器就“逆向”使用了子加载器来加载类,打破了双亲委派的层次性。

3. 热部署与模块化框架

像 OSGi 这样的框架,为了实现模块的热插拔,每个模块都有自己的类加载器,模块之间可以互相引用,形成了网状结构,完全脱离了双亲委派的树形结构。JDK 9 的模块化系统(Project Jigsaw)虽然保留了层次化,但也允许模块间更灵活的依赖。

对比分析

特性双亲委派模型破坏双亲委派(以 Tomcat 为例)
加载顺序先父后子先子后父
类唯一性保证核心类库一致,避免冲突实现应用隔离,同一个类可被不同加载器加载多次
安全性高,防止恶意代码覆盖核心 API需要谨慎处理,否则可能引发 LinkageError
适用场景标准 Java 应用Web 容器、热部署、OSGi 等

最佳实践

  • 尽量遵循双亲委派:除非有明确需求(如隔离、热部署),否则不要随意破坏。破坏后需要自己处理类的唯一性和垃圾回收。
  • 理解上下文类加载器的用途:编写框架或 SPI 实现时,正确使用 Thread.currentThread().getContextClassLoader() 可以避免类加载问题。
  • 注意 JDK 版本变化:JDK 9 之后移除了扩展类加载器,引入了平台类加载器,层次略有变化,但双亲委派的理念没变。
  • 避免在并发环境下自定义类加载器:类加载器的 loadClass 方法默认是同步的,如果自定义实现破坏了同步,可能导致同一个类被加载多次。

常见误区

  • 误区:认为双亲委派模型中,所有类加载器都有一个父加载器。
    • 正解:启动类加载器没有父加载器,它的 parentnull
  • 误区:认为重写 findClass 就是破坏了双亲委派。
    • 正解findClassloadClass 中最后调用的方法,用于自定义类加载逻辑,但委托顺序没有变,所以并没有破坏双亲委派。只有重写 loadClass 并改变委托顺序才算破坏。
  • 误区:认为线程上下文类加载器是一个特殊的加载器。
    • 正解:它只是 Thread 类中的一个属性,用来保存一个 ClassLoader 引用,默认是应用程序类加载器。它本身并不是一种新的加载器类型。

总结

双亲委派模型是 Java 类加载器的核心安全机制,通过层层委托确保核心类库的一致性和安全性;而破坏它则是为了解决特定场景下的类隔离、SPI 加载等问题,需要深入理解类加载原理才能正确运用。