双亲委派:为什么你写的 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.ClassLoader 中 loadClass(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),只有当父加载器抛 ClassNotFoundException 且 c 仍为 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 是其中最经典的案例。
章末提问
- “双亲委派机制解决了什么问题?” 结论先行:解决核心类安全与类唯一性两大问题。因为加载请求总是先向上委派,核心类只能由 Bootstrap 加载、无法被自定义类冒名替换;同时同一全限定名只会被父加载器加载一次,避免重复加载。
- “为什么自定义类加载器要重写 findClass 而不是 loadClass?” 结论先行:因为 loadClass 里写着委派逻辑,重写它会破坏委派、让核心类可被替换。因为 findClass 只负责”从来源读字节并定义类”、默认抛异常,重写它不会改变向上委派的顺序。
- “双亲委派是绝对的吗?哪些场景会打破它?为什么?” 结论先行:不绝对,JDBC SPI、Tomcat WebApp 隔离、OSGi 都会打破它,共同原因是”上层代码需要调用下层实现”。因为委派只能向上,Bootstrap 够不到 classpath 里的实现类,必须借 TCCL 等手段补一条向下通路。
- “两个类加载器加载了同名同字节码的类,它们能互相赋值吗?” 结论先行:不能,它们是完全不同的两个类。因为 JVM 以”全限定名 + ClassLoader”二元组判定类身份,字节码相同并不等于同一个类。
- “
instanceof返回 false 的根本原因是什么?” 结论先行:因为对象所属的 Class 对象和右操作数类型不是同一个 Class 对象。因为它们的全限定名虽相同,但分别由不同 ClassLoader 定义,属于两个不同的类。