Skip to content
Go back

双亲委派机制——为什么你不能自定义java.lang.String

双亲委派:为什么你写的 java.lang.String 永远不会被加载?

一句话结论(30s)

双亲委派的本质是”自底向上检查、自顶向下加载”的类加载顺序——收到加载请求先逐级向上委派给父加载器,父加载器加载不了才自己加载。它存在的核心价值是安全与唯一:因为核心类(如 java.lang.String)只能由 Bootstrap 加载且不可被自定义类替换,同一个全限定名不会被重复加载。权衡是它”只能向上委派”导致上层代码无法直接调用下层实现,于是衍生出 JDBC SPI(线程上下文类加载器)、Tomcat WebApp 隔离、OSGi 等打破委派的场景。

核心原理(2min)

JVM 的类加载器是三层结构:Bootstrap(C++ 内置,加载 rt.jar)→ Extension(加载 jre/lib/ext)→ Application(加载 classpath),三者是父子关系。ClassLoader.loadClass 的核心逻辑是:先查 findLoadedClass 缓存,未命中则委派给 parent.loadClass,父加载器也找不到(抛 ClassNotFoundException)才调用自己的 findClass 加载——这就是”向上委派、向下查找”。因为委派逻辑写在 loadClass 里,所以自定义类加载器只应重写 findClass 而非 loadClass,否则会破坏委派导致核心类被替换。打破委派的三大场景(JDBC SPI 的 TCCL 反向委派、Tomcat WebApp 优先加载自身 WEB-INF/classes、OSGi 平级查找)都源于同一个需求:上层代码需要调用下层实现。

底层深入(5-10min)

一个实验

// 在项目中自定义
package java.lang;
public class String {
    public static void main(String[] args) {
        System.out.println("Hello from my String!");
    }
}

编译通过,但运行时:java.lang.NoSuchMethodError: main

不是因为你的 String 不存在,而是因为 JVM 加载的仍然是 rt.jar 中的核心 String 类——你的自定义 String 类的 main 方法根本不在加载路径上。

这就是双亲委派机制的威力。

这里先停下来想一层:为什么编译能通过,运行时却报 NoSuchMethodError,而不是加载你写的那份 String? 因为编译期只做语法和类型检查,不决定”运行时到底加载哪个类”;到了运行期,JVM 收到 java.lang.String 的加载请求后先一路向上委派,最终由 Bootstrap 加载了 rt.jar 里的核心 String,你那份同名的字节码根本没机会被加载——所以你的 main 方法自然不在执行路径上。

三层类加载器

Bootstrap ClassLoader (C++)
  ↑ 加载: rt.jar, resources.jar
  │ 最顶层,JVM 内置,C++ 实现
  │ 没有 Java 层面的父加载器

Extension ClassLoader (Java)
  ↑ 加载: jre/lib/ext/
  │ 父加载器: Bootstrap

Application ClassLoader (Java)
  加载: classpath(用户代码)
  父加载器: Extension

双亲委派的核心代码

下面是 OpenJDK java.lang.ClassLoaderloadClass(String, boolean) 的真实实现:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 1. 先检查该类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 2. 父加载器存在 → 向上委派
                    c = parent.loadClass(name, false);
                } else {
                    // 2'. 没有父加载器 → 交给 Bootstrap 加载器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器找不到,预期中的异常
            }

            if (c == null) {
                // 3. 父加载器仍未加载成功 → 由自己加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // 记录父委派耗时与 findClass 耗时统计
                PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

对照真实代码可以看到,委派路径就是 parent.loadClass(name, false)(父为 null 时走 findBootstrapClassOrNull),只有当父加载器抛 ClassNotFoundExceptionc 仍为 null 时,才会落到 findClass(name) 自己加载——“向上委派、向下查找”的顺序被 loadClass 固化下来。resolve 标志为 true 时,加载完成后还会调用 resolveClass(c) 触发链接。

💭 思考:为什么 parent 为 null 时要单独走 findBootstrapClassOrNull,而不是直接自己加载?——Bootstrap 是 C++ 内建加载器,没有 Java 层的 ClassLoader 对象,所以 AppClassLoader 的 parent 字段在 Java 侧就是 null。但这个 null 不代表”没有父加载器”,而代表”父加载器是 C++ 的 Bootstrap”。若把 null 误当成”没人可委派、我自己来”,java.lang.String 这类核心类就永远轮不到 Bootstrap 加载、甚至被自己 define 出来,安全与唯一同时崩掉。所以要用 native 的 findBootstrapClassOrNull 显式补上”最顶层那一段委派”,让委派链真正贯通到底。

findClass 的默认实现只是抛异常,留给子类重写:

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

为什么自定义类加载器只应重写 findClass() 而不是 loadClass()?因为 loadClass() 包含委派逻辑——重写它可能会破坏双亲委派,导致核心类被自定义类替换,造成安全漏洞;而 findClass() 默认抛异常、只负责”从某个来源读字节并定义类”,重写它不会破坏委派顺序。

想通这个,一个更尖锐的问题会自然冒出来:双亲委派既然是为了”安全 + 唯一”,为什么还会被打破? 因为委派是单向的——只能向上、不能向下。一旦出现”上层代码需要调用下层实现”(比如 rt.jar 里的 DriverManager 要调用 classpath 里的 MySQL 驱动),Bootstrap 永远够不到 classpath 里的类,纯向上委派就卡死了。所以”打破委派”不是推翻安全,而是给单向委派补一条”向下”的通路——下面三个场景全都源于这同一个需求。

打破双亲委派的三大经典场景

1. JDBC 的 SPI 反向委派

DriverManager 由 Bootstrap ClassLoader 加载(在 rt.jar 中),但它需要调用第三方 JDBC 驱动(com.mysql.cj.jdbc.Driver),这些驱动在 classpath 下,由 AppClassLoader 加载。

Bootstrap ClassLoader 加载不了 classpath 下的类——父加载器无法向下委派,只能向上。

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

// DriverManager 中的使用模式
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// cl 默认是 AppClassLoader
ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class, cl);

TCCL 默认是 AppClassLoader,可以被 Bootstrap 加载的代码获取到,用它来加载 classpath 下的 SPI 实现类——实现”子加载器为父加载器提供服务”的反向模式。

💭 思考:为什么 TCCL 要挂在 Thread 上,而不是做成一个全局变量?——“该用哪个类加载器加载 SPI 实现”这个信息,只有”发起调用”的那条线程最清楚:它正跑在哪个应用里、处于哪个类加载器上下文。挂在线程上,Bootstrap 加载的 DriverManager 才能通过 Thread.currentThread().getContextClassLoader() 拿到这次调用的上下文,从而够到 classpath 里的驱动。若做成全局变量,多个类加载器共存(如 Tomcat 多个 WebApp)时就会串味,A 应用的线程加载到 B 应用的同名驱动——TCCL 本质是把”委派方向”交给线程上下文来临时指定。

2. Tomcat 的 WebApp 类隔离

Tomcat 类加载器层次:
  Bootstrap
    → Common (所有 WebApp 共享)
      → WebApp1 (优先加载 WEB-INF/classes,不向上委派)
      → WebApp2 (优先加载 WEB-INF/classes,不向上委派)

每个 WebApp 有独立 ClassLoader,优先从自己的 /WEB-INF/classes 加载。两个应用使用不同版本的同一 jar 包互不冲突——打破了”先向上委派”的规则。

3. OSGi 模块化

OSGi 没有层次结构,每个 Bundle 自己的 ClassLoader 按 Import-Package 声明在平级查找,完全不同于双亲委派的树状结构。

同一接口 instanceof 返回 false 的谜案

两个 ClassLoader 各自加载了同一个全限定名的接口类 com.example.UserService。虽然字节码完全一样,但在 JVM 中这是两个不同的 Class 对象——全限定名相同 + ClassLoader 不同 = 不同类。一个 ClassLoader 加载的类的实例,在另一个 ClassLoader 中 instanceof 返回 false。

这里停一下:字节码一字不差,为什么 instanceof 还会返回 false? 因为 JVM 判断”是不是同一个类”的标准是”全限定名 + 定义它的 ClassLoader”这个二元组,而不是字节码内容。同一个 ClassLoader 里全限定名唯一的类才会被认定为同一类;两个加载器各自 define 出来的 com.example.UserService 虽然长得一样,却是两个互不相认的 Class 对象。

总结

双亲委派的核心价值是安全性+唯一性:核心类只能由 Bootstrap 加载(不可替换),同一个类不被重复加载(确定性)。打破双亲委派的场景都源于同一个需求:“上层代码需要调用下层的实现”,JDBC SPI 是其中最经典的案例。

章末提问


Share this post on:

Previous Post
RocketMQ DLedger——基于Raft的CommitLog多数派提交
Next Post
中断机制详解:从硬件信号到内核处理的全链路