文章目录
- "双亲委派说了无数遍,但为什么 SPI 必须打破它?"——类加载器从根讲清
-
- 一、类加载的五个阶段
- 二、类加载器三层结构
- 三、双亲委派:流程、源码、为什么要这样设计
-
- 3.1 一个请求怎么走
- 3.2 源码长这样
- 3.3 为什么要这样设计
- 四、类的唯一标识:不是全限定名说了算
- 五、打破双亲委派的第一种方式:重写 loadClass
- 六、SPI:为什么"不得不"打破双亲委派
-
- 6.1 矛盾在哪
- 6.2 怎么解决:线程上下文类加载器
- 6.3 除了 JDBC,还有哪些 SPI?
- 七、Tomcat:第三次打破双亲委派
-
- 7.1 不打破会怎样
- 7.2 Tomcat 的加载器架构
- 八、破坏双亲委派的时间线
- 九、两个易混淆概念
-
- 9.1 ClassNotFoundException vs NoClassDefFoundError
- 9.2 loadClass vs forName
- 十、总结
“双亲委派说了无数遍,但为什么 SPI 必须打破它?”——类加载器从根讲清
你背过无数遍:Bootstrap → Platform → Application,先向上委派,失败再回落。然后面试官问:SPI 为什么要打破双亲委派?你卡住了。
因为只背了流程,没想清楚加载器到底解决了什么问题。
这一篇从类加载过程讲到双亲委派,再讲到 SPI、Tomcat 怎么打破它,彻底捋干净。
一、类加载的五个阶段
加载 → 验证 → 准备 → 解析 → 初始化
↖ 链接 ↗
| 加载 | 读 .class 字节码,在方法区生成 Klass 元数据,堆上生成 Class<?> 对象 | Klass + Class 对象 |
| 验证 | 检查魔数 0xCAFEBABE、版本号、类型安全 | 通过或 VerifyError |
| 准备 | 静态变量分配内存、赋类型零值 | static int a = 3 此时 a = 0 |
| 解析 | 常量池符号引用 → 直接引用(方法地址、字段偏移量) | 晚期解析,用到才转 |
| 初始化 | 执行 <clinit>:静态赋值 + 静态代码块 | = 3 此时才生效 |
二、类加载器三层结构
Bootstrap ClassLoader(C++ 实现,JVM 内建)
↑ 爹
Platform ClassLoader(JDK 8 叫 Extension,JDK 9 改 Platform)
↑ 爹
Application ClassLoader(classpath 下你写的代码)
| Bootstrap | 核心类库:java.lang.*、java.util.* 等 |
| Platform | JDK 扩展模块:java.sql 等 |
| Application | 你的代码、classpath 下的 jar |
JDK 9 的变化:Extension ClassLoader 被废弃,换成 Platform ClassLoader。rt.jar 被拆分成多个 .jmod 模块——原因是单一的 rt.jar 太庞大,不利于模块化裁剪。
三、双亲委派:流程、源码、为什么要这样设计
3.1 一个请求怎么走
// 你要加载 com.foo.User
Class<?> c = ClassLoader.getSystemClassLoader().loadClass("com.foo.User");
loadClass("com.foo.User")
→ 先查缓存:findLoadedClass("com.foo.User") → 没有
→ 委派给爹 Platform
→ Platform 委派给 Bootstrap
→ Bootstrap:不在我的管辖范围,加载不了
→ Platform:也不在我管,加载不了
→ Application:我来,去 classpath 找 → 找到了,定义成 Class 对象
3.2 源码长这样
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 第一步:查缓存,加载过了直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 第二步:交给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name); // 到顶了,交给 Bootstrap
}
} catch (ClassNotFoundException ignored) { }
// 第三步:爹不行,自己来
if (c == null) {
c = findClass(name);
}
}
return c;
}
}
3.3 为什么要这样设计
防止核心类被篡改。
你完全可以写一个:
package java.lang;
public class String {
// 恶意代码:偷偷把用户密码发到远程服务器
}
编译出来一个 java.lang.String 放 classpath 下。如果没有双亲委派,ApplicationClassLoader 去 classpath 加载到你的假 String——核心类库被打穿。
有了双亲委派就没事:
要加载 java.lang.String
→ Application:爹,你装这个?
→ Bootstrap:java.lang.* 归我管,rt.jar 有真货,已经装好了
→ 直接返回真 String
→ 你的假 String 根本没机会被加载
另一个好处是复用。 java.lang.String 被 Bootstrap 加载一次,方法区里只存一份 Klass,所有子加载器共享,不重复占内存。
四、类的唯一标识:不是全限定名说了算
类的身份 = 全限定类名 + 类加载器实例
同一个 com.foo.User,被不同加载器加载 → JVM 认为是两个不同的类。
// 这两个 User 全限定名都是 com.foo.User
// 但分别被两个不同的 WebAppClassLoader 加载 → 两个不同的类
User.class.equals(userFromApp2) // false
这就是 Tomcat 能做应用隔离的底层原理——每个应用用一个 WebAppClassLoader,类的名字虽然一样,但加载器不同,类空间完全隔开。
五、打破双亲委派的第一种方式:重写 loadClass
public class MyClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 不先委派父加载器,自己先加载
Class<?> c = findClass(name);
if (c != null) return c;
// 自己找不到才走双亲委派
return super.loadClass(name);
}
}
重写 findClass() 不算破坏,只是换个地方找字节码。重写 loadClass() 才是破坏——改了查找顺序。
但即使你写了一个加载器,java.* 开头的类你照样加载不了——defineClass() 方法里有 Prohibited package name: java.lang 的保护,这是最后一道防线。
六、SPI:为什么"不得不"打破双亲委派
6.1 矛盾在哪
JDBC 的场景:
java.sql.DriverManager → 在 rt.jar,由 Bootstrap 加载
com.mysql.cj.jdbc.Driver → 在 classpath,由 AppClassLoader 加载
DriverManager 要加载 MySQL 驱动:
→ Bootstrap 里的代码要去加载 classpath 下的类
→ 但按双亲委派,Bootstrap 是爹,只能往上,不能"往下"找 AppClassLoader
→ Bootstrap 找不到 MySQL 驱动!
6.2 怎么解决:线程上下文类加载器
// ServiceLoader 内部
public static <S> ServiceLoader<S> load(Class<S> service) {
// 拿到当前线程的上下文类加载器——就是 AppClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}
本质:爹(Bootstrap)借用儿子(AppClassLoader)去加载类。 打破单向委派。
线程上下文类加载器的几个特性:
- 每个线程有自己的 contextClassLoader,存在 Thread 类的字段里
- main 线程默认是 AppClassLoader
- 可以通过 setContextClassLoader() 手动设置
6.3 除了 JDBC,还有哪些 SPI?
JNDI、JCE、JAXB、SLF4J……只要接口在核心库、实现在 classpath,都用这套来打破双亲委派。
七、Tomcat:第三次打破双亲委派
7.1 不打破会怎样
Tomcat 同时部署 App1(Spring 5)和 App2(Spring 6)。如果走标准委派,父加载器会先加载 Spring 5——那 App2 也没得选,全应用共用一个版本。谁先部署就谁赢,后部署的炸。
7.2 Tomcat 的加载器架构
Bootstrap → JVM 核心类
↑
System → classpath
↑
Common → Tomcat 全局共享
↑
┌─────── WebApp1 ───────┐ ┌─────── WebApp2 ───────┐
│ WebAppClassLoader │ │ WebAppClassLoader │
│ /WEB-INF/classes │ │ /WEB-INF/classes │
│ /WEB-INF/lib/*.jar │ │ /WEB-INF/lib/*.jar │
│ (Spring 5) │ │ (Spring 6) │
└───────────────────────┘ └───────────────────────┘
每个 Web 应用独立的 WebAppClassLoader,优先从自己的 /WEB-INF/ 加载:
和标准双亲委派反过来了:先自己加载,不行再找爹。
这样 App1 和 App2 各用各的 Spring 版本,同名的类因为不同加载器加载而被当成不同的类,互不影响。
八、破坏双亲委派的时间线
| 第一次 | JDK 1.2 前遗留代码 | 当时没有 findClass(),只能重写 loadClass() |
| 第二次 | SPI(JDBC 等) | Bootstrap 里接口需要加载 classpath 里的实现类 |
| 第三次 | Web 容器(Tomcat) | 多应用类隔离 + 热部署 |
| 第四次 | OSGi | 模块网状依赖,动态加载/卸载 Bundle |
| 第五次 | JDK 9 模块化 | 废弃 Extension,引入 Platform + module path |
九、两个易混淆概念
9.1 ClassNotFoundException vs NoClassDefFoundError
| 类型 | Exception | Error |
| 场景 | 运行时显式加载:Class.forName("com.mysql.Driver") | 编译时有,运行时链接阶段找不到 |
| 典型原因 | 类路径漏了 jar | jar 冲突 / 静态初始化失败 |
// ClassNotFoundException:jar 没在 classpath
Class.forName("com.mysql.cj.jdbc.Driver");
// NoClassDefFoundError:编译时有,运行时 jar 被删了
// 编译时 import SomeClass,运行时这个类不见了
9.2 loadClass vs forName
ClassLoader.loadClass("com.foo.User"); // 只加载,不初始化(不触发 <clinit>)
Class.forName("com.foo.User"); // 加载 + 初始化(触发 <clinit>)
JDBC 里 Class.forName("com.mysql.cj.jdbc.Driver") 就是利用初始化触发静态代码块来注册驱动。
十、总结
点赞收藏,下次面试问到类加载器,直接翻这篇就够了。






