Java 双亲委派机制:原理、源码与如何打破

Java 双亲委派机制:原理、源码与如何打破

类加载器是 JVM 里非常容易被问到、也非常容易被误解的一块内容。双亲委派不是一个复杂算法,它更像是一条类加载的安全边界:先让父加载器尝试加载,父加载器不行时,子加载器才自己动手。


一、先从一个问题开始

假设我们自己写一个类:

package java.lang;

/**
 * 演示错误地自定义 JDK 核心包下的 String 类。
 */
public class String {
    /**
     * 程序入口方法,用于观察自定义 java.lang.String 是否能被正常执行。
     *
     * @param args 命令行参数
     */
    public static void main(String[] args) {
        System.out.println("自定义 java.lang.String 被执行了");
    }
}

这个类能不能替换 JDK 自带的 java.lang.String

正常情况下不能。因为 JVM 加载类时不会让应用程序类加载器直接说了算,而是会先向上委托给父加载器,最终优先由 Bootstrap ClassLoader 去加载 JDK 核心类库里的 java.lang.String

这就是双亲委派模型最直观的价值:保证核心类库的类型安全和加载一致性


二、类加载器层级

Java 里的类加载器大致可以分成这几类:

Bootstrap ClassLoader
        ↑
Platform / Extension ClassLoader
        ↑
Application ClassLoader
        ↑
Custom ClassLoader

在不同 JDK 版本中名字略有差异:

JDK 版本 第二层加载器 说明
JDK 8 及以前 Extension ClassLoader 加载 jre/lib/extjava.ext.dirs 下的类
JDK 9 以后 Platform ClassLoader 模块化之后替代 Extension ClassLoader,加载平台相关模块

2.1 Bootstrap ClassLoader

Bootstrap ClassLoader 是最顶层的类加载器,用 C/C++ 实现,Java 代码里通常看到的是 null

它主要负责加载 Java 核心类库,例如:

  • java.lang.Object
  • java.lang.String
  • java.util.ArrayList

这些类来自 JDK 的基础模块或核心运行时镜像。

2.2 Platform / Extension ClassLoader

JDK 8 以前叫 Extension ClassLoader,JDK 9 之后因为模块化改成 Platform ClassLoader。

它负责加载一些平台级别的类,不是业务应用自己写的类,也不是最核心的 Bootstrap 类。

2.3 Application ClassLoader

Application ClassLoader 也叫系统类加载器,负责加载应用 classpath 下的类。我们平时写的大多数业务类,默认都是它加载的。

可以用下面的代码观察:

/**
 * 打印类加载器层级,观察当前应用的类加载路径。
 */
public class ClassLoaderPrinter {

    /**
     * 程序入口方法,打印当前类加载器及其父加载器。
     *
     * @param args 命令行参数
     */
    public static void main(String[] args) {
        ClassLoader classLoader = ClassLoaderPrinter.class.getClassLoader();
        while (classLoader != null) {
            System.out.println("当前类加载器:" + classLoader);
            classLoader = classLoader.getParent();
        }
        System.out.println("当前类加载器:Bootstrap ClassLoader");
    }
}

常见输出类似:

当前类加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@xxxx
当前类加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@xxxx
当前类加载器:Bootstrap ClassLoader

三、什么是双亲委派?

双亲委派的核心规则很简单:

一个类加载器收到类加载请求时,先把请求委托给父加载器;父加载器加载不了,子加载器才尝试自己加载。

流程可以画成这样:

加载 com.example.UserService
        │
        ▼
Custom ClassLoader 收到请求
        │  先委托
        ▼
Application ClassLoader
        │  先委托
        ▼
Platform / Extension ClassLoader
        │  先委托
        ▼
Bootstrap ClassLoader
        │
        ├── 能加载:直接返回 Class
        │
        └── 不能加载:向下回退
                    ▼
         子加载器尝试 findClass

注意这里的“父”不是继承关系,而是组合关系。ClassLoader 里保存了一个 parent 字段,用来表示委派链路上的父加载器。


四、从源码思路看加载流程

ClassLoader#loadClass 的源码逻辑可以简化成下面这样。下面这段是伪代码,用来表达主流程,不是可直接编译的 JDK 源码:

/**
 * 演示双亲委派核心流程的伪代码。
 */
protected Class<?> loadClass(String name) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        打印日志("开始加载类,类名:" + name);

        Class<?> clazz = findLoadedClass(name);
        if (clazz == null) {
            try {
                if (parent != null) {
                    打印日志("委派给父加载器,类名:" + name);
                    clazz = parent.loadClass(name);
                } else {
                    打印日志("委派给 Bootstrap ClassLoader,类名:" + name);
                    clazz = bootstrapClassLoader.loadClass(name);
                }
            } catch (ClassNotFoundException ignored) {
                打印日志("父加载器未找到类,准备由当前加载器加载,类名:" + name);
            }

            if (clazz == null) {
                clazz = findClass(name);
                打印日志("当前加载器完成类加载,类名:" + name);
            }
        }

        return clazz;
    }
}

真实源码中还有同步锁、并行加载、模块化等细节,但主线就是这三步:

  1. 先查缓存findLoadedClass(name),避免重复加载。
  2. 向上委派:优先让父加载器加载。
  3. 自己加载:父加载器抛出 ClassNotFoundException 后,才调用 findClass(name)

这也是为什么我们自定义类加载器时,通常应该重写 findClass,而不是直接重写 loadClass


五、为什么需要双亲委派?

5.1 保证核心类库不会被随便替换

如果没有双亲委派,应用程序完全可以自己写一个 java.lang.Stringjava.lang.Objectjava.util.HashMap,然后让业务类优先加载它。

这样会带来两个问题:

  • 核心 API 的行为可能被篡改。
  • JVM 里同名核心类可能出现多份,类型体系会混乱。

双亲委派让 JDK 核心类优先由 Bootstrap ClassLoader 加载,业务代码没有机会覆盖它们。

5.2 保证类的唯一性

在 JVM 里,判断两个类是否相同,不只看类的全限定名,还要看加载它的类加载器。

也就是说:

类唯一标识 = 类加载器 + 类全限定名

同一个 com.example.User,如果被两个不同的 ClassLoader 加载,在 JVM 看来就是两个不同的类。

这也是一些插件化、热部署、容器隔离场景容易出现 ClassCastException 的原因:明明类名一样,但加载器不同。

5.3 避免重复加载

父加载器已经加载过的类,子加载器直接复用即可。这样能减少重复加载,也能让基础类在整个应用中保持一致。


六、怎么“破坏”双亲委派?

严格来说,“破坏双亲委派”不是指把 JVM 规则彻底砸掉,而是指不再完全遵循先父后子的加载顺序

常见方式有四类。


七、方式一:重写 loadClass,改成子优先

默认建议重写 findClass,因为 loadClass 里已经实现了双亲委派。如果我们故意重写 loadClass,并让当前加载器先加载指定包名,就可以形成“子优先”的策略。

下面是一个简化示例:

import java.io.IOException;
import java.io.InputStream;

/**
 * 子优先类加载器示例,用于演示如何改变默认双亲委派流程。
 */
public class ChildFirstClassLoader extends ClassLoader {

    /**
     * 创建子优先类加载器。
     *
     * @param parent 父类加载器
     */
    public ChildFirstClassLoader(ClassLoader parent) {
        super(parent);
    }

    /**
     * 优先由当前类加载器加载指定包下的类,其他类仍然走双亲委派。
     *
     * @param name 类的全限定名
     * @param resolve 是否解析类
     * @return 加载后的 Class 对象
     * @throws ClassNotFoundException 类不存在时抛出
     */
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            System.out.println("收到类加载请求,类名:" + name);

            Class<?> loadedClass = findLoadedClass(name);
            if (loadedClass != null) {
                System.out.println("命中已加载类,类名:" + name);
                return loadedClass;
            }

            if (name.startsWith("com.example.plugin.")) {
                try {
                    System.out.println("当前加载器优先加载插件类,类名:" + name);
                    Class<?> clazz = findClass(name);
                    if (resolve) {
                        resolveClass(clazz);
                    }
                    return clazz;
                } catch (ClassNotFoundException ex) {
                    System.out.println("当前加载器未找到插件类,回退到父加载器,类名:" + name);
                }
            }

            System.out.println("委派给父加载器,类名:" + name);
            return super.loadClass(name, resolve);
        }
    }

    /**
     * 从 classpath 中读取 class 字节码并定义类。
     *
     * @param name 类的全限定名
     * @return 加载后的 Class 对象
     * @throws ClassNotFoundException 类不存在或读取失败时抛出
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = name.replace('.', '/') + ".class";
        try (InputStream inputStream = getResourceAsStream(path)) {
            if (inputStream == null) {
                throw new ClassNotFoundException(name);
            }
            byte[] bytes = inputStream.readAllBytes();
            System.out.println("读取类字节码成功,类名:" + name + ",字节数:" + bytes.length);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException ex) {
            throw new ClassNotFoundException(name, ex);
        }
    }
}

这个加载器的策略是:

  • com.example.plugin. 下的类:当前加载器优先。
  • 其他类:继续走默认双亲委派。

为什么不建议所有类都子优先?

因为如果连 java.*javax.*jakarta.*、框架公共 API 都乱加载,很容易出现类型冲突、链接错误、安全问题。实际工程里即使要破坏,也应该只对明确的隔离包生效。


八、方式二:线程上下文类加载器

线程上下文类加载器,也就是 Thread.currentThread().getContextClassLoader(),是 Java 里非常典型的一次“反向委派”。

为什么需要它?

因为有些基础框架类由父加载器加载,但它们需要反过来加载应用层的实现类。

最典型的例子是 SPI:

ServiceLoader<MyService> loader = ServiceLoader.load(MyService.class);

ServiceLoader 是 JDK 类,由 Bootstrap ClassLoader 加载。但具体的 MyService 实现类通常在业务 classpath 下,只能由 Application ClassLoader 或更下层的加载器看到。

如果完全按照双亲委派,Bootstrap ClassLoader 看不到业务实现类。于是 Java 提供线程上下文类加载器,让父层代码可以使用当前线程绑定的应用类加载器去加载实现类。

示例:

/**
 * 演示线程上下文类加载器如何让父层代码加载应用层类。
 */
public class ContextClassLoaderDemo {

    /**
     * 程序入口方法,打印线程上下文类加载器信息。
     *
     * @param args 命令行参数
     */
    public static void main(String[] args) {
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println("当前线程上下文类加载器:" + contextClassLoader);
    }
}

常见使用场景:

  • JDBC Driver 加载
  • ServiceLoader SPI
  • JNDI
  • 一些日志框架、容器框架

这不是粗暴地重写 loadClass,但它确实绕开了“父加载器只能向上委派”的限制,让父层代码能看到子层实现。


九、方式三:Tomcat 的 WebAppClassLoader

Tomcat 是破坏双亲委派的经典工程案例。

一个 Tomcat 里可以部署多个 Web 应用:

Tomcat
 ├── app-a/WEB-INF/classes
 ├── app-a/WEB-INF/lib
 ├── app-b/WEB-INF/classes
 └── app-b/WEB-INF/lib

如果完全使用默认双亲委派,会有两个问题:

  1. 不同 Web 应用无法很好地隔离依赖。
    app-a 想用 guava-18,app-b 想用 guava-31,如果都交给公共父加载器,很容易冲突。

  2. Web 应用自己的类应该优先于容器公共类。
    应用升级依赖时,不应该轻易影响其他应用或 Tomcat 自身。

所以 Tomcat 的 WebAppClassLoader 大体采用:

  • JDK 核心类:仍然先交给父加载器,不能乱来。
  • Web 应用自己的 WEB-INF/classesWEB-INF/lib:应用类加载器优先。
  • Tomcat 容器类和公共类:由更上层加载器负责。

这是一种非常务实的设计:不是彻底不要双亲委派,而是在应用隔离边界内使用子优先策略


十、方式四:OSGi、模块化与插件化

OSGi、IDE 插件系统、一些自研插件框架,也会打破默认双亲委派。

它们通常不是简单的父子树,而是更复杂的网络结构:

Plugin A ClassLoader ─── 依赖 ───> Plugin B ClassLoader
        │
        └────────────── 依赖 ───> Common API ClassLoader

这种场景要解决的问题是:

  • 插件之间隔离。
  • 插件可以声明自己导出哪些包。
  • 插件可以声明自己依赖哪些包。
  • 同一个系统中允许存在同一个库的多个版本。

双亲委派更适合“树形层级 + 共享基础类”的模型;插件系统更需要“按依赖关系精确可见”的模型。


十一、破坏双亲委派的风险

破坏双亲委派不是炫技,它通常是为了解决隔离和扩展问题。用不好会带来很多坑。

11.1 ClassCastException

两个类全限定名一样,但类加载器不同,JVM 会认为它们不是同一个类。

com.example.User loaded by ClassLoaderA
com.example.User loaded by ClassLoaderB

这两个 User 不能互相强转。

11.2 LinkageError

同一个类被不同加载器以不兼容方式加载,可能触发:

  • NoClassDefFoundError
  • NoSuchMethodError
  • ClassFormatError
  • UnsupportedClassVersionError
  • LinkageError

这类问题经常出现在依赖版本冲突、热部署残留、插件卸载不干净的场景。

11.3 内存泄漏

类加载器本身也可以被 GC 回收,但前提是它加载的类、对象、线程、静态变量都没有再被引用。

Web 容器热部署后,如果旧应用的线程还活着,或者静态缓存还引用旧 ClassLoader,就会导致旧类加载器无法回收,最终可能引发 Metaspace OOM。

11.4 安全边界被削弱

如果允许业务代码随便覆盖核心类或公共 API,系统行为会变得不可预测。越底层的类,越不应该随意使用子优先加载。


十二、面试中怎么回答?

可以按这个结构回答:

12.1 双亲委派是什么?

类加载器收到加载请求后,先委托父加载器加载,父加载器加载不了时,当前加载器才自己加载。

12.2 为什么要双亲委派?

主要有三点:

  1. 保护 JDK 核心类库不被篡改。
  2. 保证类加载的一致性,避免同名核心类出现多份。
  3. 避免重复加载,让父加载器已加载的类可以被子加载器复用。

12.3 怎么破坏?

常见方式:

  1. 自定义 ClassLoader,重写 loadClass,实现子优先。
  2. 使用线程上下文类加载器,让父层代码加载子层实现,例如 SPI。
  3. Web 容器使用自己的类加载策略,例如 Tomcat 的 WebAppClassLoader。
  4. OSGi、插件系统、模块化框架基于依赖关系做类隔离。

12.4 破坏有什么风险?

重点说:

  • 类名相同但加载器不同,会导致类型不相等。
  • 可能出现 ClassCastExceptionNoSuchMethodErrorLinkageError
  • 热部署和插件卸载不当可能导致 ClassLoader 泄漏。
  • 不应该对子优先策略放得太宽,尤其不能乱加载核心包。

十三、常见误区

误区一:父加载器是父类

不是。ClassLoader 之间的父子关系不是继承,而是委派链。parent 是一个字段,不是 Java 继承关系。

误区二:双亲委派绝对不能破坏

不是。Tomcat、SPI、OSGi 都在不同程度上绕开或调整了双亲委派。关键不是能不能破坏,而是有没有清晰的隔离边界。

误区三:只要类名一样就是同一个类

不是。JVM 判断类是否相同,要同时看类加载器和类全限定名。

误区四:自定义 ClassLoader 就一定破坏双亲委派

不是。如果只是重写 findClass,仍然是在默认 loadClass 的双亲委派流程下工作。真正改变委派顺序,通常要重写 loadClass


总结

双亲委派机制可以用一句话概括:

先让父加载器加载,父加载器加载不了,子加载器再加载。

它解决的是类加载的安全性、一致性和复用问题。默认情况下,我们应该遵守这个模型,自定义类加载器时优先重写 findClass

但在 SPI、Web 容器、插件化、模块化这类场景中,默认双亲委派又不够灵活,需要通过线程上下文类加载器、子优先加载器或更复杂的模块依赖关系来打破它。

最后记住一个判断标准:

如果目标是保护核心类和共享基础能力,应该遵守双亲委派;如果目标是应用隔离、插件隔离或实现发现,才考虑有边界地打破它。

posted @ 2026-07-01 10:02  松鼠航  阅读(11)  评论(0)    收藏  举报