13 · 类加载机制与双亲委派模型
对应学习计划:第一月第3周「JVM 虚拟机(面试高频压轴)」
面试定位:类加载是 JVM 三连问之一(内存、GC、类加载)。双亲委派「为什么这样设计」「怎么打破」是进阶面试最爱追的。
一、本文能帮你回答的面试题
- 类加载的生命周期有哪几个阶段?什么时候触发「加载」?
- 什么是双亲委派模型?加载过程是怎么样的?
- 为什么要双亲委派?好处是什么?
- 怎么打破双亲委派?有哪些经典案例?
Class.forName和loadClass的区别?(与反射篇呼应)- 什么是类加载器?有哪几类?各自加载什么?
- 什么情况会抛
NoClassDefFoundError?和ClassNotFoundException区别?
二、概念介绍
类加载器(ClassLoader)的作用 :把 .class 文件(字节码)加载进 JVM,生成 Class 对象。类加载 = 类从字节码到「能 new 的对象」的过程。
三层基本加载器(JDK8):
Bootstrap ClassLoader(启动类加载器,C++ 实现,无父)
└── 加载 JDK 核心:rt.jar / java.lang.* / java.util.*
(parent = null)
Extension ClassLoader(扩展类加载器)
└── 加载 JDK/ext 目录下的扩展类(jre/lib/ext)
(parent = Bootstrap)
AppClassLoader(应用/系统类加载器)
└── 加载 classpath(你写的业务代码)
(parent = Extension)
所有加载器都有 parent 引用,形成「树状 + 委派」结构。自定义扩展是通过继承 ClassLoader 再插入这个层级。
三、原理深入
3.1 类加载的生命周期(7 个阶段)
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
【链接阶段(Linking)】
| 阶段 | 干什么 | 例子 |
|---|---|---|
| 加载 | 读字节码到内存,生成 Class 对象 | 从 classpath/jar/网络读 .class |
| 验证 | 检查字节码合法性(防恶意代码) | 格式、符号引用校验 |
| 准备 | 静态变量分配内存并给默认零值(注意不是赋值) | static int a = 1 此时 a=0 |
| 解析 | 符号引用 → 直接引用(常量池解析) | 方法调用可解析了 |
| 初始化 | 执行<clinit>:静态变量赋初值 + 静态代码块 |
a = 1 真正发生在这 |
| 使用 | new 对象调用方法 | 业务代码 |
| 卸载 | 类被 GC(条件苛刻:无实例+无引用+加载器可回收) | 常用于动态类库 |
初始化时机(主动使用触发,面试爱问):
new、getstatic/putstatic、invokestatic(new 一个对象、读写静态变量、调静态方法)- 反射
Class.forName(默认初始化)、Class.getDeclaredField等 - 初始化子类会先初始化父类
- JVM 启动时初始化的主类(main)
被动使用不初始化 :调用父类静态变量(只初始化父类)、数组声明、CONSTANT_MethodHandle 等。
3.2 双亲委派模型(核心)
自定义想加载 com.demo.X
↓ 委派给 AppClassLoader
↓ 它先问 Extension:你能加载吗?
↓ 再问 Bootstrap:你能加载吗?
↓ Bootstrap 没有 → 回到 Extension → 没有再回到 App → 自己加载
加载过的类:JVM 有缓存(class → Class 对象),先查缓存
java
protected Class<?> loadClass(String name) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // ① 已加载?直接返回
if (c == null) {
if (parent != null)
c = parent.loadClass(name); // ② 委派给父加载器
else
c = findBootstrapClassOrNull(name);
if (c == null)
c = findClass(name); // ③ 父都没有才自己 load
}
return c;
}
}
为什么这么设计(三个理由,背):
- 避免重复加载:同一个类只加载一次。
- 安全性 :核心类(java.lang.String)永远由 Bootstrap 加载,防止你写个恶意的 String 替换 JDK 的(类名重复也不冲突,因为加载器不同=不同类)。
- 一致性:核心库被信任,优先可信来源。
3.3 打破双亲委派(进阶题,必会 3 个案例)
| 案例 | 怎么破的 | 为什么 |
|---|---|---|
| JDBC(SPI 经典) | DriverManager(Bootstrap 加载)要加载 MySQL 等驱动(业务 classpath)→ 父加载器找不到 → 用线程上下文类加载器(AppClassLoader)绕道加载 |
核心库需要调用「与厂商相关的实现」 |
| Tomcat(Web 隔离) | 每个 WebApp 一个 WebAppClassLoader,先自己找,找不到才委派父(web 优先) | 多个应用部署一起,各自 lib 可能同名不同版本,需隔离 |
| OSGi / 热部署 ClassLoader | 每个 bundle 独立加载器 | 模块化、动态卸载 |
怎么自己写打破 :重写 loadClass(不调 super.loadClass,改 findClass)或重写 findClass。
3.4 两个经典异常区别(爱考)
| 异常 | 时间 | 含义 |
|---|---|---|
ClassNotFoundException |
在 loadClass/findClass 主动抛 | 想加载但找不到字节码 |
NoClassDefFoundError |
运行到代码处 JVM 抛 | 「原来加载过但现在坏了/初始化失败」或版本冲突(编译有、运行没有) |
实战案例 :idea 里删了一个依赖,编译期没问题(本地缓存),运行期抛 NoClassDefFoundError------依赖缺失的常见诊断。
3.5 类初始化失败:ExceptionInInitializerError
静态块抛异常 → 类初始化失败 → 之后所有用到该类的操作都抛 NoClassDefFoundError。常见于静态代码块依赖全局配置没就绪。
四、实战例子
实战 1:打印类加载器链
java
public class ClassLoaderDemo {
public static void main(String[] args) {
ClassLoader app = ClassLoaderDemo.class.getClassLoader();
System.out.println("业务类加载器: " + app); // AppClassLoader
System.out.println("父: " + app.getParent()); // Extension
System.out.println("祖父: " + app.getParent().getParent()); // null(Bootstrap)
// String 由 Bootstrap 加载(getClassLoader() 返回 null)
System.out.println("String 加载器: " + String.class.getClassLoader());
}
}
实战 2:证明「同名类 ≠ 同一类」(加载器隔离)
java
// 用两个不同的自定义 ClassLoader 分别 load 同一个 .class 文件
// 得到两个不同的 Class 对象,互相 instanceof 为 false
public class LoaderIsolationDemo {
public static void main(String[] args) throws Exception {
// 读字节码(省略文件读取细节)
byte[] bytes = Files.readAllBytes(Path.of("target/classes/demo/Target.class"));
ClassLoader l1 = new ByteClassLoader("loader1", bytes, ClassLoaderDemo.class.getClassLoader());
ClassLoader l2 = new ByteClassLoader("loader2", bytes, ClassLoaderDemo.class.getClassLoader());
Class<?> c1 = l1.loadClass("demo.Target");
Class<?> c2 = l2.loadClass("demo.Target");
System.out.println("c1 == c2 ? " + (c1 == c2)); // false!
Object o1 = c1.getDeclaredConstructor().newInstance();
// o1 instanceof demo.Target → 这里用 AppClassLoader 的 Target 判断 → false
}
static class ByteClassLoader extends ClassLoader {
private final byte[] bytes;
ByteClassLoader(String name, byte[] bytes, ClassLoader parent) {
super(name, parent);
this.bytes = bytes;
}
@Override protected Class<?> findClass(String name) {
return defineClass(name, bytes, 0, bytes.length);
}
}
}
实战 3:测试「加载目录不在 classpath 的类」
java
// 自定义 ClassLoader 把类文件从磁盘流读进来,动态加载(热部署原理)
public class HotDeployLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = "/tmp/classes/" + name.replace('.', '/') + ".class";
try {
byte[] bytes = Files.readAllBytes(Path.of(path));
return defineClass(name, bytes, 0, bytes.length); // 核心方法
} catch (Exception e) {
throw new ClassNotFoundException(name, e);
}
}
}
五、面试话术(背下来)
Q:类加载过程?
「加载、验证、准备、解析、初始化。加载是读字节码生成 Class;验证是查安全;准备工作是给静态变量分配内存并给零值;解析是把符号引用变直接引用;初始化是执行静态块和静态变量赋值。只有主动使用才会触发初始化。」
Q:双亲委派?
「类加载请求先委派给父加载器,父加载不了才自己加载。好处三点:避免重复加载;防止核心库(java.lang.String)被偷梁换柱,安全;保证核心类一致。一个类只要加载器不同就不是同一个类。」
Q:怎么打破双亲委派?
「重写 loadClass 不走父委派,改用 findClass 自己找。生产案例:JDBC 用线程上下文类加载器反转委派让核心库能找到厂商驱动;Tomcat 每个 WebApp 独立加载器做版本隔离;OSGi 做模块热部署。这三个案例是面试官要听的标准答案。」
Q:Class.forName 和 loadClass?
「Class.forName 默认会执行初始化(触发静态块执行),JDBC 注册驱动就靠它;loadClass 只加载不初始化,即不触发静态块,更适合只拿 Class 对象或延迟初始化场景。」
六、踩坑总结(易错点)
| # | 坑 | 说明 |
|---|---|---|
| 1 | 混淆「准备」和「初始化」 | 准备阶段静态变量只是零值;初始化才赋值 |
| 2 | getParent() 是 null 就以为没有父 |
Bootstrap 用 C++ 实现,Java 层看不到,所以是 null,不代表无父 |
| 3 | 认为同名类会覆盖 | 不同加载器加载的同名类是不同类,互不干扰(依赖冲突问题由此而来) |
| 4 | Class.forName 不带 initialize=false 参数 |
触发初始化副作用,某些工具类加载时别这样 |
| 5 | 花栗鼠式依赖冲突 | NoClassDefFoundError/ClassCastException(不同加载器同接口)要查 jar 版本和加载器来源 |
| 6 | 静态块抛异常 | 变成 ExceptionInInitializerError,之后该类所有使用都 NoClassDefFoundError |
七、课后作业
- 手写一个「翻转委派」的 ClassLoader(先自己 findClass 再找父),加载两个不同目录的同名类验证隔离。
- 复现
ExceptionInInitializerError→ 观察之后抛NoClassDefFoundError的行为。 - 阅读
DriverManager源码,找到ServiceLoader和线程上下文类加载器的用法(JDBC SPI)。 - 画一张「双亲委派 + 打破(Tomcat/JDBC)」的原理图,讲给面试官听。