深入 JVM 类加载:说说 JVM 的类加载机制和双亲委派模型
作者 :Weisian
发布时间:2026年2月25日

📌 系列导读 :在前几篇中,我们依次建立了 JVM 的全局认知、详解了运行时数据区、深入分析了堆内存结构。今天,我们来探讨面试中出现频率极高 的知识点------JVM 类加载机制和双亲委派模型。
这道题在 Java 中高级面试中的出现率超过 85% ,是 JVM 知识体系的核心模块。理解类加载机制,不仅能帮你顺利通过面试,更能让你在日常开发中理解框架原理(如 Spring、Tomcat)、解决 ClassNotFound 异常、实现热部署等高级功能。
如果说堆内存是对象的"家园",那么类加载机制就是类的"出生证明"。理解类如何被加载、验证、初始化,以及双亲委派模型的设计思想,是区分普通程序员和资深工程师的关键。
今天,我们将从类加载过程、双亲委派模型、类加载器层次、打破委派场景、实战应用 五个维度,层层递进地拆解这道面试必考题,并附上创作思路、得分要点、避坑指南,助你面试中脱颖而出。
一、类加载机制整体流程 ------ 先建立全局认知
1.1 类加载生命周期
一个类从被加载到 JVM 内存中开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
┌─────────────────────────────────────────────────────────────────┐
│ 类的生命周期 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 │
│ │
│ └──────────────────┬──────────────────┘ │
│ │ │
│ 类加载过程(5 个阶段) │
│ │
└─────────────────────────────────────────────────────────────────┘
其中,加载、验证、准备、初始化、卸载 这 5 个阶段的顺序是确定的,而解析阶段则可能在初始化之后进行(为了支持动态绑定)。

1.2 类加载五阶段详解
| 阶段 | 核心任务 | 执行时机 | 是否可干预 |
|---|---|---|---|
| 加载 | 查找并读取.class 文件,生成 Class 对象 | 首次主动使用时 | ✅ 可自定义 |
| 验证 | 确保字节码符合规范,安全 | 加载后 | ❌ JVM 控制 |
| 准备 | 为静态变量分配内存,设默认值 | 验证后 | ❌ JVM 控制 |
| 解析 | 符号引用转直接引用 | 初始化前(可延迟) | ❌ JVM 控制 |
| 初始化 | 执行静态代码块和静态变量赋值 | 首次主动使用时 | ✅ 可部分干预 |
💡 记忆口诀 :
"加验准解初,类加载五步;双亲来委派,安全不重复"

1.3 类加载时机(何时触发)
┌─────────────────────────────────────────────────────────────────┐
│ 触发类加载的 6 种场景 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 创建对象实例:new MyClass() │
│ 2. 访问静态成员:MyClass.staticField / MyClass.staticMethod() │
│ 3. 反射调用:Class.forName("com.example.MyClass") │
│ 4. 初始化子类:初始化子类时,父类先加载 │
│ 5. 主类启动:java -cp . com.example.MainClass │
│ 6. JDK7+ 动态语言支持:MethodHandle 解析 │
│ │
│ ⚠️ 注意:以下场景不会触发类加载 │
│ - 访问静态常量(final static) │
│ - 定义数组:MyClass[] array │
│ - 访问 Class 对象:MyClass.class │
│ │
└─────────────────────────────────────────────────────────────────┘
1.4 代码示例:类加载时机验证
java
public class ClassLoadTimingDemo {
static {
System.out.println("ParentClass 初始化");
}
}
class ChildClass extends ClassLoadTimingDemo {
static {
System.out.println("ChildClass 初始化");
}
}
public class Test {
public static void main(String[] args) {
// 场景 1:创建对象 → 触发加载
// new ChildClass();
// 场景 2:访问静态成员 → 触发加载
// System.out.println(ClassLoadTimingDemo.class);
// 场景 3:反射调用 → 触发加载
// Class.forName("com.example.ChildClass");
// 场景 4:初始化子类 → 父类先加载
// new ChildClass();
// 场景 5:访问静态常量 → 不触发父类加载
System.out.println(ClassLoadTimingDemo.CONSTANT);
}
static final String CONSTANT = "常量";
}
输出结果:
# 场景 1-4:输出
ParentClass 初始化
ChildClass 初始化
# 场景 5:仅输出(不触发父类加载)
常量
✅ 面试金句 :
"类的初始化是懒加载的,只有首次主动使用时才触发;编译期确定的静态常量(final static)会被存入调用类的常量池,不会触发定义类的初始化(但会触发加载);而运行期确定的静态常量,仍会触发定义类的初始化。"
补充详解:静态常量与类加载的关系
核心困惑:"静态常量不也是类里定义的吗?不加载类怎么获取这个常量呢?"
用通俗的比喻拆解你的困惑
你可以把类加载的 5 个阶段想象成"开一家奶茶店":
| 阶段 | 比喻 | 说明 |
|---|---|---|
| 加载 | 找到奶茶店的营业执照(.class 文件),录入系统(生成 Class 对象) | 类的"入场券" |
| 验证 | 检查营业执照是否合法(字节码规范) | 安全"守门员" |
| 准备 | 给奶茶店分配经营场地,准备基础设备(静态变量赋零值) | 静态变量的"初始值" |
| 解析 | 把"隔壁街的糖厂"(符号引用)换成具体的地址(直接引用) | 符号引用的"落地" |
| 初始化 | 招聘员工、调试设备、制定价格、正式营业(执行静态代码块 + 静态变量赋值) | 类的"激活" |
👉 答案 :获取静态常量确实需要加载类 (加载 + 验证 + 准备),但不需要初始化类------就像你想知道奶茶店的招牌奶茶价格(静态常量),只需要看到店门口的价目表(编译期存入调用类的常量池),不需要等店铺正式营业(初始化)。
用代码和底层原理详细解释
1. 代码示例:验证静态常量不触发初始化
java
public class ConstantDemo {
// 编译期常量
public static final String CONSTANT = "hello";
static {
// 初始化阶段才会执行的静态代码块
System.out.println("ConstantDemo 初始化了");
}
}
public class Test {
public static void main(String[] args) {
// 访问静态常量
System.out.println(ConstantDemo.CONSTANT);
// 输出结果:只打印 "hello",不会打印 "ConstantDemo 初始化了"
// 证明:只加载了 ConstantDemo 类,但没有初始化
}
}
2. 底层原理:常量池的"存储优化"
JVM 在编译 Test 类时,会做一个关键优化:
- 把
ConstantDemo.CONSTANT的值("hello")直接存入Test类的常量池 Test类运行时,直接从自己的常量池取"hello",不需要真正访问ConstantDemo类- 此时
ConstantDemo类会被加载 (生成 Class 对象),但不会执行<clinit>()方法(初始化)
3. 关键区分:这两种常量的行为完全不同
| 常量类型 | 是否触发定义类的初始化 | 原因 |
|---|---|---|
static final String A = "hello"(编译期常量) |
❌ 不触发 | 编译期存入调用类常量池,直接取值 |
static final String B = new String("hello")(运行期常量) |
✅ 触发 | 需要执行 new 操作,必须初始化类 |
java
public class ConstantDemo2 {
// 运行期常量(不是编译期确定)
public static final String RUNTIME_CONSTANT = new String("world");
static {
System.out.println("ConstantDemo2 初始化了");
}
}
public class Test2 {
public static void main(String[] args) {
// 访问运行期常量,会触发初始化
System.out.println(ConstantDemo2.RUNTIME_CONSTANT);
// 输出结果:
// ConstantDemo2 初始化了
// world
}
}
4. 用 JVM 指令验证(面试加分项)
通过 javap -v 查看字节码,能直观看到差异:
编译期常量的字节码(Test 类):
// 访问 ConstantDemo.CONSTANT 的指令
ldc #2 // String hello
👉 直接从 Test 类的常量池(#2)取"hello",没有任何对 ConstantDemo 类的引用。
运行期常量的字节码(Test2 类):
// 访问 ConstantDemo2.RUNTIME_CONSTANT 的指令
getstatic #2 // Field ConstantDemo2.RUNTIME_CONSTANT:Ljava/lang/String;
👉 执行 getstatic 指令访问 ConstantDemo2 的静态字段,必须触发 ConstantDemo2 的初始化。
小结:核心知识点
| 知识点 | 说明 |
|---|---|
| 类加载 ≠ 类初始化 | 加载是"找到并读取类文件",初始化是"执行静态代码块 + 静态变量赋值";获取编译期常量只需要加载类,不需要初始化类 |
| 编译期常量的特殊处理 | 值在编译期确定的 final static 常量,会被存入调用类的常量池,调用时直接取值,不触发定义类的初始化 |
| 运行期常量的正常处理 | 值在运行期确定的 final static 常量(如 new String()),调用时需要触发定义类的初始化 |
二、类加载五阶段详解 ------ 核心拆解
2.1 加载(Loading)------ 类的"入场券"
加载是类加载过程的第一个阶段,核心任务是将磁盘上的.class 字节码文件加载到 JVM 内存中。
┌─────────────────────────────────────────────────────────────────┐
│ 加载阶段核心任务 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 通过全限定名获取定义此类的二进制字节流 │
│ - 从文件系统读取(.class 文件) │
│ - 从网络获取(Applet) │
│ - 运行时计算生成(动态代理) │
│ - 从数据库中读取 │
│ │
│ 2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构 │
│ - 在方法区生成类元数据 │
│ - 在堆中生成 java.lang.Class 对象 │
│ │
│ 3. 在内存中生成一个代表这个类的 java.lang.Class 对象 │
│ - 作为访问方法区类元数据的入口 │
│ - 通过反射访问类的入口 │
│ │
└─────────────────────────────────────────────────────────────────┘
加载过程示意图:
磁盘.class 文件
│
▼
┌─────────────────┐
│ 类加载器 │
│ (ClassLoader) │
└─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ 方法区 │ │ 堆内存 │
│ (类元数据) │ ←──→ │ (Class 对象) │
└─────────────────┘ └─────────────────┘

2.2 验证(Verification)------ 安全"守门员"
验证是确保.class 字节码文件符合 JVM 规范,防止恶意代码破坏虚拟机。
┌─────────────────────────────────────────────────────────────────┐
│ 验证阶段核心任务 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 文件格式验证 │
│ - 检查魔数(0xCAFEBABE) │
│ - 检查版本号(主次版本号) │
│ - 检查常量池结构 │
│ │
│ 2. 元数据验证 │
│ - 检查是否有父类(除 Object 外) │
│ - 检查是否继承了 final 类 │
│ - 检查是否实现了抽象方法 │
│ │
│ 3. 字节码验证 │
│ - 检查操作数栈类型匹配 │
│ - 检查跳转指令目标合法 │
│ - 检查方法调用参数匹配 │
│ │
│ 4. 符号引用验证 │
│ - 检查符号引用是否存在 │
│ - 检查访问权限是否合法 │
│ │
└─────────────────────────────────────────────────────────────────┘
验证失败示例:
java
// 以下代码无法通过验证,JVM 会拒绝加载
public class InvalidClass {
public void test() {
int[] arr = new int[5];
arr[10] = 100; // 数组越界,字节码验证会检查
}
}
⚠️ 注意 :
验证阶段非常重要,但不是必须的。如果代码已经过反复验证,可以使用
-Xverify:none关闭验证,加快类加载速度(生产环境不建议)。
2.3 准备(Preparation)------ 静态变量的"初始值"
准备阶段为类的静态变量分配内存,并设置默认值(零值),这些变量所使用的内存都将在方法区中进行分配。
┌─────────────────────────────────────────────────────────────────┐
│ 准备阶段核心任务 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 为静态变量分配内存(在方法区) │
│ 2. 设置默认值(零值),不是代码中赋的值 │
│ │
│ 数据类型 默认值 │
│ ───────────────────────── │
│ byte/short/int/long 0 │
│ float/double 0.0 │
│ char '\u0000' │
│ boolean false │
│ 引用类型 null │
│ │
│ ⚠️ 注意:final static 常量在准备阶段直接赋值为代码中的值 │
│ │
└─────────────────────────────────────────────────────────────────┘
初始值 vs 用户指定值
| 变量定义 | 准备阶段值 | 初始化阶段值 |
|---|---|---|
static int a = 10; |
0 | 10 |
static boolean b = true; |
false | true |
static String c = "hello"; |
null | "hello" |
static final int d = 20; |
20 | 20(无变化) |
代码示例:
java
public class PreparationDemo {
// 准备阶段:value = 0(默认值)
// 初始化阶段:value = 100(代码赋值)
public static int value = 100;
// 准备阶段:CONSTANT = 200(final static 直接赋值)
public static final int CONSTANT = 200;
public static void main(String[] args) {
System.out.println(value); // 输出:100
System.out.println(CONSTANT); // 输出:200
}
}
2.4 解析(Resolution)------ 符号引用的"落地"
解析 阶段将常量池中的符号引用 替换为直接引用。
┌─────────────────────────────────────────────────────────────────┐
│ 解析阶段核心任务 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 符号引用 vs 直接引用 │
│ ───────────────────────── │
│ │
│ 符号引用: │
│ - 用一组符号描述目标(字符串、标识符) │
│ - 不依赖内存地址,编译期生成 │
│ - 示例:"java/lang/String"、"getValue:()I" │
│ │
│ 直接引用: │
│ - 直接指向目标的内存地址(指针、偏移量) │
│ - 依赖内存布局,运行时生成 │
│ - 示例:0x00000007C0001000(内存地址) │
│ │
│ 解析对象: │
│ 1. 类或接口 │
│ 2. 字段 │
│ 3. 方法 │
│ 4. 接口方法 │
│ │
└─────────────────────────────────────────────────────────────────┘
符号引用转直接引用示意:
解析前(符号引用) 解析后(直接引用)
┌─────────────────┐ ┌─────────────────┐
│ "java/lang/String" │ │ 0x00000007C0001000 │
│ "getValue:()I" │ ──────→ │ 0x00000007C0002000 │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
方法区常量池 方法区类元数据
解析时机
解析阶段不一定在准备阶段之后,可能在初始化阶段之后进行,这是为了支持动态绑定(多态)。

2.5 初始化(Initialization)------ 类的"激活"
初始化是类加载的最后阶段,执行类中定义的 Java 程序代码(静态代码块和静态变量赋值)。
┌─────────────────────────────────────────────────────────────────┐
│ 初始化阶段核心任务 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 执行类构造器<clinit>()方法 │
│ - 由编译器自动收集类中的所有静态变量赋值动作 │
│ - 收集类中静态代码块中的语句 │
│ - 按源码顺序合并执行 │
│ │
│ 2. 父类<clinit>()先执行 │
│ - 初始化子类时,父类先初始化 │
│ - 接口初始化时,父接口不初始化 │
│ │
│ 3. <clinit>()与<init>()区别 │
│ - <clinit>():类构造器,静态变量/代码块,JVM 调用 │
│ - <init>():实例构造器,实例变量/构造代码块,new 时调用 │
│ │
└─────────────────────────────────────────────────────────────────┘
类初始化的时机(面试高频)
类只有在首次主动使用时才会初始化,被动使用不会触发初始化。
| 主动使用(触发初始化) | 被动使用(不触发初始化) |
|---|---|
| new 一个对象 | 引用静态常量(编译期确定) |
| 调用类的静态方法 | 定义类数组 |
| 访问类的静态变量(非 final) | 访问父类的静态变量(子类不初始化) |
| 反射调用类 | 加载类时仅加载,不初始化 |
| 初始化子类(父类先初始化) |
代码示例:
java
public class InitializationDemo {
// 静态变量赋值
public static int value = 100;
// 静态代码块
static {
System.out.println("静态代码块执行");
value = 200;
}
// 实例变量
public int instanceValue = 10;
// 实例代码块
{
System.out.println("实例代码块执行");
}
// 构造方法
public InitializationDemo() {
System.out.println("构造方法执行");
}
public static void main(String[] args) {
System.out.println("value = " + value); // 触发类初始化
new InitializationDemo(); // 触发实例初始化
}
}
输出结果:
静态代码块执行
value = 200
实例代码块执行
构造方法执行
✅ 面试金句 :
"类初始化只执行一次,实例初始化每次 new 都执行。
<clinit>()由 JVM 自动调用,<init>()由 new 指令调用。"

三、双亲委派模型 ------ 类加载的核心机制
3.1 什么是双亲委派模型?
**双亲委派模型(Parents Delegation Model)**是 JVM 类加载器的工作机制,核心思想是:类加载请求先委托给父加载器,父加载器无法加载时,子加载器才尝试加载。
┌─────────────────────────────────────────────────────────────────┐
│ 双亲委派模型工作流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 类加载请求 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Custom Loader │ ──→ 先委托父加载器 │
│ │ (自定义加载器) │ │
│ └─────────────────┘ │
│ │ │
│ ▼ 委托 │
│ ┌─────────────────┐ │
│ │ App Loader │ ──→ 先委托父加载器 │
│ │ (应用加载器) │ │
│ └─────────────────┘ │
│ │ │
│ ▼ 委托 │
│ ┌─────────────────┐ │
│ │ Ext Loader │ ──→ 先委托父加载器 │
│ │ (扩展加载器) │ │
│ └─────────────────┘ │
│ │ │
│ ▼ 委托 │
│ ┌─────────────────┐ │
│ │ Bootstrap Loader│ ──→ 顶层,无法再委托 │
│ │ (启动加载器) │ 尝试加载 │
│ └─────────────────┘ │
│ │ │
│ ▼ 加载失败,逐层返回 │
│ 子加载器尝试加载 │
│ │
└─────────────────────────────────────────────────────────────────┘

3.2 类加载器层次结构
┌─────────────────────────────────────────────────────────────────┐
│ 类加载器层次结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Bootstrap ClassLoader │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ - C++ 实现(null) │ │ │
│ │ │ - 加载 JDK 核心类库(rt.jar、charsets.jar) │ │ │
│ │ │ - 路径:$JAVA_HOME/jre/lib │ │ │
│ │ │ - 优先级:最高 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↑ 委托 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Extension ClassLoader │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ - Java 实现(sun.misc.Launcher$ExtClassLoader) │ │ │
│ │ │ - 加载扩展类库(ext 目录) │ │ │
│ │ │ - 路径:$JAVA_HOME/jre/lib/ext │ │ │
│ │ │ - 优先级:高 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↑ 委托 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Application ClassLoader │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ - Java 实现(sun.misc.Launcher$AppClassLoader) │ │ │
│ │ │ - 加载 classpath 下的应用类 │ │ │
│ │ │ - 路径:-cp、-classpath、CLASSPATH 环境变量 │ │ │
│ │ │ - 优先级:中 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↑ 委托 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Custom ClassLoader │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ - 用户自定义(继承 ClassLoader) │ │ │
│ │ │ - 加载自定义路径的类 │ │ │
│ │ │ - 路径:自定义配置 │ │ │
│ │ │ - 优先级:低 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 类加载器对比表
| 加载器 | 实现语言 | 加载路径 | 父加载器 | 典型类 |
|---|---|---|---|---|
| Bootstrap | C++ | $JAVA_HOME/jre/lib | 无 | java.lang.String |
| Extension | Java | $JAVA_HOME/jre/lib/ext | Bootstrap | javax.crypto.* |
| Application | Java | classpath | Extension | 用户自定义类 |
| Custom | Java | 自定义 | Application | 插件类、热部署类 |

3.4 双亲委派源码分析
java
// java.lang.ClassLoader 核心源码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1. 先检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 有父加载器,委托给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 无父加载器(Bootstrap),尝试加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 4. 父加载器无法加载,自己尝试加载
}
if (c == null) {
// 5. 自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
✅ 面试金句 :
"双亲委派的核心是'先问父,父不行我再上'。这保证了核心类库的安全性和类的唯一性。"
四、双亲委派的好处 ------ 为什么这样设计?
4.1 安全性 ------ 防止核心 API 被篡改
┌─────────────────────────────────────────────────────────────────┐
│ 安全性保障示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 场景:用户自定义一个 java.lang.String 类 │
│ │
│ 无双亲委派: │
│ ┌─────────────────┐ │
│ │ App Loader │ ──→ 加载用户自定义的 String │
│ │ │ 核心类被篡改,系统崩溃! │
│ └─────────────────┘ │
│ │
│ 有双亲委派: │
│ ┌─────────────────┐ │
│ │ App Loader │ ──→ 委托 │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Bootstrap Loader│ ──→ 加载 JDK 核心 String │
│ │ │ 核心类安全,系统稳定! │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2 避免重复加载 ------ 保证类的唯一性
┌─────────────────────────────────────────────────────────────────┐
│ 避免重复加载示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 场景:多个应用都依赖 commons-lang.jar │
│ │
│ 无双亲委派: │
│ ┌───────────┐ ┌───────────┐ │
│ │ App1 Loader│ │ App2 Loader│ │
│ │ 加载 A 类 │ │ 加载 A 类 │ → 内存中两份 A 类,浪费! │
│ └───────────┘ └───────────┘ │
│ │
│ 有双亲委派: │
│ ┌───────────┐ ┌───────────┐ │
│ │ App1 Loader│ │ App2 Loader│ │
│ │ 委托 │ │ 委托 │ │
│ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Ext Loader │ ──→ 加载一份 A 类,共享! │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 沙箱机制 ------ 隔离不同来源的代码
| 机制 | 说明 | 应用场景 |
|---|---|---|
| 命名空间隔离 | 不同加载器加载的类属于不同命名空间 | Applet、Web 应用 |
| 可见性控制 | 子加载器可见父加载器加载的类,反之不可见 | 插件系统 |
| 版本隔离 | 不同应用可使用同一类的不同版本 | 多版本共存 |
💡 通俗解释 :
"双亲委派就像公司的审批流程------员工先找经理,经理找总监,总监找 CEO。CEO 处理不了,再逐级往下。这样保证了核心决策的权威性和一致性。"
五、打破双亲委派 ------ 实际应用场景
5.1 为什么要打破双亲委派?
双亲委派模型虽然安全,但在某些场景下需要灵活加载,这时需要打破双亲委派:
| 场景 | 原因 | 解决方案 |
|---|---|---|
| SPI 机制 | 核心库需要加载厂商实现 | 线程上下文类加载器 |
| 热部署 | 应用不重启更新代码 | 自定义类加载器 |
| 容器隔离 | 多应用独立类空间 | 独立类加载器 |
| 模块隔离 | 模块间类版本隔离 | OSGi 类加载器 |

5.2 场景 1:JDBC SPI 机制
java
// java.sql.DriverManager(JDK 核心类,由 Bootstrap 加载)
public class DriverManager {
// 注册驱动
public static void registerDriver(java.sql.Driver driver) {
// ...
}
}
// com.mysql.cj.jdbc.Driver(厂商实现,由 Application 加载)
public class Driver implements java.sql.Driver {
static {
// 静态代码块中注册自己
DriverManager.registerDriver(new Driver());
}
}
问题:
┌─────────────────────────────────────────────────────────────────┐
│ JDBC SPI 类加载问题 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ DriverManager(Bootstrap 加载) │
│ │ │
│ ▼ 需要加载 │
│ Driver 实现(Application 加载) │
│ │
│ 问题:父加载器无法访问子加载器加载的类! │
│ │
│ 解决:使用线程上下文类加载器 │
│ Thread.currentThread().getContextClassLoader() │
│ │
└─────────────────────────────────────────────────────────────────┘
解决方案代码:
解决方案 :使用线程上下文类加载器(Thread Context ClassLoader)。
java
// SPI 加载机制
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// 内部使用线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader.load(Driver.class, cl);
5.3 场景 2:Tomcat 容器隔离
问题:Tomcat 需要隔离不同 Web 应用的类库,允许不同应用使用不同版本的同一个类。
解决方案 :每个 Web 应用有自己的 WebAppClassLoader,优先加载自己目录下的类,而不是委托给父加载器。
Tomcat 类加载器架构:
┌─────────────────────────────────────────┐
│ Bootstrap ClassLoader │
└─────────────────┬───────────────────────┘
│
┌─────────────────┴───────────────────────┐
│ Extension ClassLoader │
└─────────────────┬───────────────────────┘
│
┌─────────────────┴───────────────────────┐
│ Common ClassLoader (Tomcat 公共类) │
└─────────────────┬───────────────────────┘
│
┌─────────┴─────────┐
│ │
┌───────┴───────┐ ┌───────┴───────┐
│ WebAppClassLoader1 │ │ WebAppClassLoader2 │
│ (Web 应用 1) │ │ (Web 应用 2) │
└───────────────────┘ └───────────────────┘

5.4 场景 3:热部署/热加载
问题:模块更新时,需要重新加载类,而不需要重启 JVM。
解决方案:替换类加载器。当模块更新时,创建新的类加载器加载新的类,旧的类加载器和类一起被回收。
java
// 自定义类加载器实现热部署
public class HotSwapClassLoader extends ClassLoader {
private String classPath;
public HotSwapClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadClassData(name);
return defineClass(name, bytes, 0, bytes.length);
}
private byte[] loadClassData(String name) {
// 从指定路径读取.class 文件
String path = classPath + name.replace(".", "/") + ".class";
try (FileInputStream fis = new FileInputStream(path)) {
byte[] bytes = new byte[fis.available()];
fis.read(bytes);
return bytes;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public static void main(String[] args) throws Exception {
// 第一次加载
HotSwapClassLoader loader1 = new HotSwapClassLoader("/tmp/classes/");
Class<?> clazz1 = loader1.loadClass("com.example.HotClass");
Object obj1 = clazz1.getDeclaredConstructor().newInstance();
System.out.println(obj1);
// 模拟类文件更新
// ... 修改 HotClass.java 并重新编译 ...
// 第二次加载:创建新的类加载器
HotSwapClassLoader loader2 = new HotSwapClassLoader("/tmp/classes/");
Class<?> clazz2 = loader2.loadClass("com.example.HotClass");
Object obj2 = clazz2.getDeclaredConstructor().newInstance();
System.out.println(obj2);
// 两个类不同,因为类加载器不同
System.out.println(clazz1 == clazz2); // 输出:false
}
}
使用场景:
| 场景 | 说明 |
|---|---|
| IDE 热部署 | IntelliJ IDEA、Eclipse 的热更新 |
| 应用服务器 | Tomcat、Jetty 的 war 包热部署 |
| 脚本引擎 | Groovy、JRuby 的动态脚本加载 |
| 插件系统 | Eclipse 插件、IDEA 插件 |

5.5 打破双亲委派的方式
┌─────────────────────────────────────────────────────────────────┐
│ 打破双亲委派的 3 种方式 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 重写 loadClass() 方法 │
│ - 不委托父加载器,直接自己加载 │
│ - 示例:Tomcat WebAppClassLoader │
│ │
│ 2. 使用线程上下文类加载器 │
│ - Thread.currentThread().getContextClassLoader() │
│ - 示例:JDBC SPI、JNDI、JAXB │
│ │
│ 3. 自定义加载器层级 │
│ - 设置父加载器为 null 或自定义父加载器 │
│ - 示例:OSGi 模块系统 │
│ │
└─────────────────────────────────────────────────────────────────┘
六、面试回答模板 ------ 直接可用
6.1 标准回答(1-2 分钟)
面试官:说说 JVM 的类加载机制和双亲委派模型?
候选人:
类加载过程分为 5 个阶段:
第一,加载,查找并读取.class 文件,生成 Class 对象;
第二,验证,确保字节码符合规范;
第三,准备,为静态变量分配内存并设默认值;
第四,解析,将符号引用转为直接引用;
第五,初始化,执行静态代码块和静态变量赋值。
双亲委派模型是指:类加载请求先委托给父加载器,
父加载器无法加载时,子加载器才尝试加载。
类加载器层次:Bootstrap(C++ 实现,核心类库)→
Extension(扩展类库)→ Application(应用类)→ Custom(自定义)。
双亲委派的好处:安全性(防止核心 API 被篡改)、
避免重复加载(保证类的唯一性)、沙箱机制(隔离代码)。

6.2 进阶回答(展现深度)
候选人:
(先说标准答案,然后补充)
关于类加载机制,我想补充三点:
第一,双亲委派的打破场景。JDBC SPI 使用线程上下文类加载器,
Tomcat 为每个 Web 应用创建独立类加载器实现隔离,
热部署场景自定义加载器不委托父加载器。
第二,类加载时机。只有首次主动使用时才触发加载,
访问 final static 常量不会触发类加载。
第三,实际项目中的应用。我曾遇到过 ClassNotFoundException,
排查发现是自定义类加载器的父加载器设置错误,导致无法加载应用类。
调整后使用 Application ClassLoader 作为父加载器,问题解决。
另外,在使用 Spring Boot 的热部署时,也用到了打破双亲委派的机制,
通过 DevTools 的类加载器实现类的重新加载。
✅ 回答技巧:
- 先说 5 个加载阶段
- 说明双亲委派流程
- 补充打破场景(展现深度)
- 结合项目经验(增加说服力)
七、得分要点与避坑指南
7.1 得分要点(必须覆盖)
| 维度 | 关键点 | 分值占比 |
|---|---|---|
| 加载过程 | 5 个阶段名称和核心任务 | 30% |
| 双亲委派 | 委派流程、加载器层次 | 30% |
| 好处 | 安全性、避免重复加载、沙箱 | 20% |
| 打破场景 | SPI、Tomcat、热部署 | 20% |
7.2 避坑指南(常见错误)
| 错误说法 | 正确理解 |
|---|---|
| "Bootstrap 是 Java 实现的" | Bootstrap 是 C++ 实现,返回 null |
| "准备阶段赋代码中的值" | 准备阶段赋默认值,初始化阶段赋代码值 |
| "双亲委派不能打破" | SPI、Tomcat、热部署都打破了双亲委派 |
| "解析阶段一定在初始化前" | 解析可在初始化前或后(延迟解析) |
| "子类加载器能加载父类" | 父加载器加载的类,子类可见;反之不可见 |
7.3 加分项(展现深度)
- ✅ 能说出
<clinit>()与<init>()的区别 - ✅ 了解线程上下文类加载器的作用
- ✅ 知道 Tomcat 类加载器架构
- ✅ 能结合 SPI 机制说明打破双亲委派
- ✅ 了解类加载器隔离与内存泄漏的关系

八、实战场景深度解析
8.1 场景一:自定义类加载器父加载器设置错误导致 ClassNotFoundException
先明确核心前提
在开始分析前,先记住两个关键规则:
| 规则 | 说明 |
|---|---|
| 父加载器的默认规则 | 自定义类加载器如果不手动指定父加载器,默认父加载器是「当前执行类的类加载器」(通常就是 Application ClassLoader,也叫系统类加载器) |
| 双亲委派的核心逻辑 | 子加载器加载类时,会先委托父加载器加载;只有父加载器加载失败,子加载器才会自己加载 |
1. 场景还原(为什么会报错?)
先模拟一个真实项目中容易踩的坑,你就能立刻理解:
错误代码(导致 ClassNotFoundException):
java
// 自定义类加载器:手动把父加载器设为 null(错误操作)
public class WrongClassLoader extends ClassLoader {
private String classPath;
// 错误点:构造方法手动指定父加载器为 null
public WrongClassLoader(String classPath) {
super(null); // 父加载器设为 null → 等效于父加载器是 Bootstrap ClassLoader
this.classPath = classPath;
}
// 重写 findClass 方法:从指定路径加载类
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadClassBytes(name);
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
private byte[] loadClassBytes(String className) throws IOException {
String path = classPath + className.replace(".", "/") + ".class";
try (FileInputStream fis = new FileInputStream(path)) {
byte[] bytes = new byte[fis.available()];
fis.read(bytes);
return bytes;
}
}
public static void main(String[] args) throws Exception {
// 自定义加载器:父加载器是 Bootstrap
WrongClassLoader loader = new WrongClassLoader("target/classes/");
// 尝试加载应用类 com.example.MyService
// 报错:ClassNotFoundException
Class<?> clazz = loader.loadClass("com.example.MyService");
}
}
报错原因拆解
| 步骤 | 说明 |
|---|---|
| 1. 加载流程 | 调用 loader.loadClass("com.example.MyService") 时,因为双亲委派,先委托父加载器(Bootstrap ClassLoader)加载 |
| 2. 父加载器失败 | Bootstrap 只能加载 rt.jar 里的核心类(如 java.lang.String),根本找不到 com.example.MyService(应用类,在 classpath 下) |
| 3. 子加载器尝试 | 父加载器加载失败后,才轮到自定义加载器自己加载 → 但如果自定义加载器的 classPath 配置有误(比如路径写错),就会抛出 ClassNotFoundException |
更常见的坑
即使 classPath 配置正确,也可能因为「父加载器层级错误」导致依赖类加载失败:
- 比如
MyService依赖org.springframework.stereotype.Service(Spring 类,在 classpath 下) - 自定义加载器委托 Bootstrap 加载
Service类 → Bootstrap 找不到 → 自定义加载器自己也找不到(因为它的classPath只指向自己的业务类,没有包含 Spring 包) - 最终报错:
ClassNotFoundException: org.springframework.stereotype.Service
解决方案(对应描述里的「调整父加载器为 Application ClassLoader」)
java
// 正确的自定义类加载器:父加载器用默认的 Application ClassLoader
public class CorrectClassLoader extends ClassLoader {
private String classPath;
// 正确写法:不指定父加载器(默认父加载器是 Application ClassLoader)
// 或显式指定:super(ClassLoader.getSystemClassLoader())
public CorrectClassLoader(String classPath) {
// super(); // 等价于 super(ClassLoader.getSystemClassLoader())
this.classPath = classPath;
}
// 其余代码和上面一致...
public static void main(String[] args) throws Exception {
CorrectClassLoader loader = new CorrectClassLoader("target/classes/");
// 加载流程:
// 1. 委托父加载器(Application)加载 com.example.MyService
// 2. Application 能找到 classpath 下的 MyService → 直接加载成功
// 3. 即使 MyService 依赖 Spring 类,Application 也能加载(因为 Spring 包在 classpath 下)
Class<?> clazz = loader.loadClass("com.example.MyService");
System.out.println("加载成功:" + clazz.getName());
}
}
核心结论
描述里的「自定义类加载器的父加载器设置错误」,本质是:
| 问题 | 说明 |
|---|---|
| 错误原因 | 错误地把父加载器设为 Bootstrap/Extension(层级太高),导致应用类/第三方依赖类无法被父加载器加载 |
| 直接后果 | 自定义加载器自己又没配置这些类的加载路径,最终抛出 ClassNotFoundException |
| 解决方案 | 调整为 Application ClassLoader 作为父加载器后,父加载器能加载 classpath 下的所有应用类/依赖类,问题解决 |
8.2 场景二:Spring Boot DevTools 热部署打破双亲委派
1. 先理解:为什么需要打破双亲委派?
正常的双亲委派模型下,一个类一旦被某个类加载器加载,就会缓存起来 → 即使你修改了类文件,JVM 也不会重新加载(因为 findLoadedClass 会返回缓存的 Class 对象)。
而「热部署」的核心需求是:修改类文件后,不重启应用,让新的类生效 → 必须打破双亲委派,绕过类缓存机制。
2. Spring Boot DevTools 的实现原理(打破双亲委派)
DevTools 用了「类加载器隔离」的方案,核心是两个类加载器:
| 类加载器 | 作用 | 加载范围 | 是否打破双亲委派 |
|---|---|---|---|
BaseClassLoader(基础类加载器) |
加载不常变的类 | 第三方依赖(spring-core、mybatis 等) | 遵循双亲委派 |
RestartClassLoader(重启类加载器) |
加载频繁变动的类 | 业务代码(com.example.xxx) | 打破双亲委派 |
原理拆解(通俗版)
应用启动时:
| 步骤 | 说明 |
|---|---|
| 1 | BaseClassLoader 加载所有第三方依赖(这些类基本不会改) |
| 2 | RestartClassLoader 加载你的业务代码(这些类会频繁改) |
| 3 | RestartClassLoader 故意不遵循双亲委派:优先加载自己路径下的类,而不是委托父加载器 |
修改类文件后:
| 步骤 | 说明 |
|---|---|
| 1 | DevTools 检测到类文件变化 → 销毁旧的 RestartClassLoader(连带它加载的所有业务类一起被 GC 回收) |
| 2 | 创建新的 RestartClassLoader → 重新加载修改后的业务类 |
| 3 | BaseClassLoader 不变(因为第三方依赖没改),所以不用重新加载,启动速度快 |
代码层面验证(DevTools 打破双亲委派的关键)
DevTools 的 RestartClassLoader 重写了 loadClass 方法,核心逻辑如下(简化版):
java
public class RestartClassLoader extends ClassLoader {
// 要优先加载的包(业务代码包)
private static final String[] PRIORITY_PACKAGES = {"com.example", "org.myproject"};
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 关键:打破双亲委派 → 先自己加载,再委托父加载器
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
// 2. 优先加载业务包下的类(自己加载)
if (isPriorityPackage(name)) {
try {
clazz = findClass(name); // 自己加载,不委托父加载器
} catch (ClassNotFoundException e) {
// 自己加载失败,再委托父加载器
clazz = super.loadClass(name, resolve);
}
} else {
// 非业务包,遵循双亲委派
clazz = super.loadClass(name, resolve);
}
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
}
// 判断是否是业务包
private boolean isPriorityPackage(String className) {
for (String pkg : PRIORITY_PACKAGES) {
if (className.startsWith(pkg)) {
return true;
}
}
return false;
}
}
为什么这是「打破双亲委派」?
| 对比项 | 标准双亲委派 | DevTools 的 RestartClassLoader |
|---|---|---|
| 加载顺序 | 先委托父加载器 → 自己加载 | 先自己加载业务类 → 委托父加载器 |
| 目的 | 保证核心类安全 | 确保修改后的业务类能被新的类加载器优先加载 |
| 效果 | 类缓存,无法热更新 | 可销毁重建,实现热部署 |
这种「反向加载」就是典型的打破双亲委派,目的是:确保修改后的业务类能被新的类加载器优先加载,而不是复用父加载器缓存的旧类。
3. 实际体验(你可以自己验证)
| 场景 | 表现 |
|---|---|
| 开启 DevTools 后 | 修改一个 Controller 类 → 保存后,应用会自动重启(实际是重启 RestartClassLoader) |
| 重启速度 | 极快(几百毫秒),因为第三方依赖类不用重新加载 |
| 关闭 DevTools | 修改类后必须重启整个应用(所有类加载器都要重新加载),速度很慢 |
8.3 总结:核心知识点回顾
| 知识点 | 说明 |
|---|---|
| 自定义类加载器父加载器错误 | 错误:把父加载器设为 Bootstrap/Extension(层级太高),导致应用类/依赖类加载失败;解决:将父加载器设为 Application ClassLoader,利用它加载 classpath 下的所有应用类 |
| Spring Boot DevTools 打破双亲委派 | 目的:实现热部署,让修改后的类快速生效;方案:用 RestartClassLoader 优先加载业务类(打破双亲委派),BaseClassLoader 加载依赖类(遵循双亲委派);效果:修改业务类后,只销毁/重建 RestartClassLoader,不用重启整个应用 |
| 核心规律 | 遵循双亲委派:为了「安全 + 避免重复加载」(比如核心类不被篡改);打破双亲委派:为了「灵活 + 定制化」(比如热部署、容器隔离、SPI 加载) |
结语:类加载机制,框架原理的基石
类加载机制是 JVM 知识体系的核心模块。理解类加载过程、双亲委派模型、打破场景,不仅能帮你顺利通过面试,更能让你:
- 理解框架原理(Spring、Tomcat、OSGi)
- 解决 ClassNotFound 异常(类加载器冲突)
- 实现高级功能(热部署、插件系统、模块隔离)
"知其然,知其所以然"
理解类加载机制的设计初衷,才能真正掌握 Java 生态的精髓。
互动话题 :
你在项目中遇到过类加载相关问题吗?是如何定位和解决的?欢迎在评论区分享你的排查经验!