JVM类加载机制与双亲委派模型
为什么必须理解类加载机制
JVM 并不是在程序启动时一次性把所有 .class 文件都加载进内存,而是采用“按需加载”的方式:当某个类第一次被主动使用时,JVM 才会尝试把它加载到运行时环境中。这个过程看似透明,实际却直接影响以下问题:
- 为什么同名类在不同类加载器下可以共存
- 为什么自定义类加载器常常会出现
ClassNotFoundException或ClassCastException - 为什么 JDBC、SPI、Servlet 容器、热部署框架都离不开类加载机制
- 为什么双亲委派模型能提升安全性,却又不是绝对不能被打破
理解类加载机制,本质上是在理解 JVM 如何把磁盘上的字节码,变成可执行、可验证、可隔离的运行时类对象。
类从字节码到可用对象的完整过程
JVM 中一个类的生命周期,通常可以概括为以下几个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
很多文章只强调“加载”和“双亲委派”,但真正容易混淆的是:类加载器主要参与的是加载阶段,而类真正变得可用,还要经过链接与初始化。
1. 加载:把字节流变成 Class 对象
加载阶段主要完成三件事:
- 通过类的全限定名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类各种数据的访问入口
这里的“二进制字节流”来源并不一定是本地 .class 文件,也可能来自:
- JAR 包
- 网络传输
- 动态生成(如代理类、字节码增强)
- 数据库或加密文件
这也是自定义类加载器存在的基础:类并不一定非要从磁盘加载。
2. 验证:确保字节码合法且安全
验证阶段用于保证 Class 文件格式和语义符合 JVM 规范,避免恶意或损坏的字节码破坏虚拟机。
常见验证内容包括:
- 文件格式是否合法
- 元数据语义是否正确
- 字节码指令是否可能危害 JVM
- 符号引用是否能正确解析
这一步是 JVM 安全体系的重要组成部分。即使拿到了 .class 文件,也不代表一定能成功运行。
3. 准备:为类变量分配内存并设置默认值
准备阶段只处理 类变量(static 变量),不会处理实例变量。
例如:
public class Demo {
static int value = 10;
static final int CONST = 100;
}
准备阶段之后:
value会先被赋默认值0CONST如果是编译期常量,通常会直接进入常量池,表现上类似直接赋值为100
注意:value = 10 这个动作并不是准备阶段完成的,而是初始化阶段执行类构造器 <clinit>() 时才真正赋值。
4. 解析:把符号引用替换为直接引用
Class 文件中很多依赖关系最初都是“符号引用”,例如:
- 引用某个类
- 调用某个方法
- 访问某个字段
解析阶段会把这些符号引用转换为可以直接定位目标的引用方式。
简单理解:
- 符号引用:字符串描述,例如“某包下某类某方法”
- 直接引用:内存地址、偏移量或可直接定位目标的信息
5. 初始化:执行类变量赋值和静态代码块
初始化阶段是类加载过程中最关键、最具“副作用”的阶段。它会执行类构造器 <clinit>() 方法,而 <clinit>() 是由以下内容合并产生的:
- 静态变量显示赋值语句
- 静态代码块
例如:
public class Demo {
static int a = 1;
static {
a = 2;
System.out.println("Demo 初始化");
}
}
初始化时会执行这些逻辑,因此真正的 a 值是 2。
什么情况下会触发类初始化
并不是“加载了类”就一定会“初始化类”。JVM 对初始化触发条件是比较严格的。
会触发初始化的典型场景
- 使用
new创建类实例 - 访问类的静态变量(非编译期常量)
- 调用类的静态方法
- 使用反射对类进行主动使用
- 初始化子类时,若父类未初始化,则先初始化父类
- JVM 启动时指定的主类
- 某些动态语言支持场景下的
MethodHandle解析结果首次调用
不会触发初始化的场景
访问编译期常量
public class ConstDemo {
public static final int VALUE = 100;
}
System.out.println(ConstDemo.VALUE);
如果 VALUE 是编译期常量,调用方在编译时就可能把值直接写入自己的常量池,因此不会触发 ConstDemo 初始化。
通过数组定义引用类
ConstDemo[] arr = new ConstDemo[10];
这里只是创建数组类型,不会触发 ConstDemo 初始化。
仅引用父类静态字段
class Parent {
static int value = 10;
static {
System.out.println("Parent init");
}
}
class Child extends Parent {
static {
System.out.println("Child init");
}
}
System.out.println(Child.value);
只会初始化 Parent,不会初始化 Child。因为字段实际定义在父类中。
类加载器的层次结构
JVM 并不是只有一个类加载器,而是存在多层加载器协作。
1. 启动类加载器(Bootstrap ClassLoader)
负责加载 JVM 核心类库,例如:
java.lang.*java.util.*java.io.*
它由 C/C++ 实现,是 JVM 的一部分,通常无法在 Java 代码中直接拿到其对象引用。
例如:
System.out.println(String.class.getClassLoader());
输出通常是:
null
这里的 null 不代表没有类加载器,而是表示它由启动类加载器加载。
2. 扩展类加载器(Extension ClassLoader)
在较早版本 JDK 中,它负责加载扩展目录中的类库。 在现代 JDK 中,这一层实现名称和职责有调整,但在理解双亲委派时,仍可把它视为“平台类库加载器”这一层。
3. 应用程序类加载器(Application ClassLoader)
也叫系统类加载器,负责加载应用 ClassPath 下的类。 平时编写的大多数业务类,通常都由它加载。
System.out.println(Demo.class.getClassLoader());
一般会看到类似:
jdk.internal.loader.ClassLoaders$AppClassLoader@xxx
4. 自定义类加载器
开发者可以继承 ClassLoader,实现自己的类加载逻辑,例如:
- 热部署
- 模块隔离
- 插件化
- 脚本执行引擎
- 字节码加密与解密
- 从远程仓库加载类
类的唯一性由什么决定
JVM 中判断两个类是否“相同”,并不只看类的全限定名,而是要同时看:
- 类的全限定名
- 加载它的类加载器
也就是说:
同一个
.class文件,被不同的类加载器加载后,在 JVM 看来就是两个不同的类。
这也是很多 ClassCastException 的根源。
例如,假设同一个接口 com.demo.Service 被两个不同类加载器分别加载,那么即使字节码内容完全一致,JVM 也会认为它们不是同一个类型,强制转换时就可能失败。
双亲委派模型到底是什么
双亲委派模型并不是“父类继承关系”,而是类加载请求的委派关系。
当一个类加载器收到加载某个类的请求时,它不会先自己尝试加载,而是先把请求交给父加载器处理。父加载器再继续向上委派,直到最顶层的启动类加载器。只有当父加载器无法完成加载时,子加载器才会尝试自己加载。
工作流程
假设应用程序类加载器要加载 com.example.UserService:
- AppClassLoader 收到加载请求
- 先委托给父加载器
- 父加载器继续向上委托
- Bootstrap ClassLoader 先尝试加载
- 如果 Bootstrap 找不到
- 再由下层加载器逐层回退处理
- 最终由能够找到该类字节码的加载器完成定义
这个过程可以概括为:
先问父亲能不能加载,父亲不行我再自己来。
loadClass() 的核心逻辑
ClassLoader 的典型实现思路如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载时忽略,继续由自己处理
}
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
这里最重要的三个点是:
findLoadedClass(name):先检查是否已经加载过,避免重复加载parent.loadClass(...):优先委派给父加载器findClass(name):父加载器失败后,自己再尝试定义类
双亲委派模型的核心价值
1. 避免类重复加载
如果没有双亲委派,同一个类可能被多个加载器反复加载,造成类型混乱和资源浪费。
2. 保证核心类库安全
例如 java.lang.String 必须由 Bootstrap ClassLoader 加载。 如果应用程序自己能随意加载一个同名 java.lang.String,整个 Java 运行环境都会失去安全基础。
双亲委派保证了:越基础、越核心的类,越优先由上层加载器统一加载。
3. 保证类加载结果的一致性
对于 JDK 核心类、公共框架类,通过上层统一加载,可以避免系统中出现多个版本的“逻辑相同但类型不同”的类。
用一个例子理解双亲委派的安全性
假设你自己写了一个类:
package java.lang;
public class String {
public String() {
System.out.println("fake string");
}
}
你希望在程序中替换 JDK 自带的 String。理论上如果“谁先加载谁生效”,那 Java 世界会立刻混乱。
但在双亲委派下:
- 应用类加载器接到
java.lang.String加载请求 - 它先委派给父加载器
- 最顶层 Bootstrap 已经加载了真正的
java.lang.String - 所以下层加载器根本没有机会定义这个类
这就是双亲委派最直观的安全意义。
自定义类加载器应该重写哪个方法
很多面试题会问:自定义类加载器时,应该重写 loadClass() 还是 findClass()?
通常答案是:
优先重写
findClass(),而不是直接破坏loadClass()的双亲委派逻辑。
推荐方式
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadBytesFromFile(name);
if (bytes == null) {
throw new ClassNotFoundException(name);
}
return defineClass(name, bytes, 0, bytes.length);
}
private byte[] loadBytesFromFile(String name) {
// 根据类名读取字节码
return null;
}
}
这种做法保留了父类 loadClass() 中的双亲委派流程,只在“父加载器找不到类”时由自己定义。
不推荐直接重写 loadClass()
因为一旦你自己接管 loadClass(),很容易出现:
- JDK 核心类被错误处理
- 重复加载
- 类型隔离异常
- 类冲突难以排查
除非你非常明确地需要打破双亲委派,否则不要轻易这么做。
双亲委派模型是不是绝对不能打破
不是。
双亲委派是默认推荐模型,但在某些框架和容器场景中,必须有选择地打破。
为什么要打破双亲委派
1. SPI 机制需要“父加载器调用子加载器中的实现类”
以 JDBC 为例:
java.sql.Driver接口由 Bootstrap 或平台相关加载器加载- MySQL 驱动实现类通常在应用 ClassPath 中,由 AppClassLoader 加载
问题来了:
- 按双亲委派,父加载器看不到子加载器中的类
- 但 Java 需要让核心 API 找到业务侧提供的实现类
这时就引入了 线程上下文类加载器(Thread Context ClassLoader)。 它允许父层逻辑在某些场景下反向借用子加载器完成类加载。
这并不是完全废弃双亲委派,而是在特定机制下绕开其可见性限制。
2. Web 容器需要应用隔离
例如 Tomcat 需要同时运行多个 Web 应用:
- 每个应用都有自己的
WEB-INF/lib - 不同应用可能依赖不同版本的同名类库
如果所有类都由同一个应用类加载器加载,版本冲突几乎不可避免。 因此容器会为每个应用创建独立的类加载器,形成更复杂的加载层次,从而实现:
- 应用之间隔离
- 公共类共享
- 热部署和卸载
3. OSGi、插件化框架需要模块隔离
插件系统要求:
- 模块之间部分共享
- 部分隔离
- 支持动态加载与卸载
这类需求很难用严格的单向双亲委派完全满足,因此常常会采用更灵活的类加载拓扑。
线程上下文类加载器的作用
线程上下文类加载器本质上是一个“补充机制”,用于解决双亲委派下父加载器无法访问子加载器资源的问题。
典型用法:
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> clazz = cl.loadClass("com.mysql.cj.jdbc.Driver");
常见应用场景:
- SPI
- JDBC 驱动发现
- JNDI
- 服务发现框架
- 各类容器中间件
可以把它理解为:
双亲委派保证了核心安全边界,而上下文类加载器提供了必要的运行时灵活性。
Tomcat 为什么要设计多层类加载器
Tomcat 是理解类加载器设计的典型案例。
它既要做到:
- JDK 类库统一共享
- Tomcat 自身类库统一共享
- Web 应用彼此隔离
- Web 应用可热部署、可卸载
所以它不会只依赖默认的 AppClassLoader,而是会构建自己的多层类加载器体系。常见设计目标包括:
common目录下类库可共享- 每个 WebApp 的
WEB-INF/classes和WEB-INF/lib独立加载 - 避免不同应用之间依赖冲突
- 卸载应用时尽可能回收相关类加载器和类元数据
从这个角度看,类加载器不仅仅是“加载类”的工具,更是 Java 平台中的模块隔离边界。
常见面试误区
误区一:双亲委派就是继承关系
不是。 它描述的是加载请求的委派顺序,不是 Java 继承语义。
误区二:类加载和类初始化是同一件事
不是。 加载只是把类字节码读进来,初始化才会执行静态变量赋值和静态代码块。
误区三:Class.forName() 和 ClassLoader.loadClass() 没区别
区别很大:
Class.forName("xxx")默认会触发类初始化ClassLoader.loadClass("xxx")默认只是加载,不一定初始化
例如:
Class.forName("com.demo.Test"); // 通常会初始化
ClassLoader.getSystemClassLoader().loadClass("com.demo.Test"); // 通常不会立即初始化
误区四:同名类一定是同一个类
不是。 同一个全限定名,如果由不同类加载器加载,JVM 会认为它们是不同类型。
误区五:自定义类加载器一定要重写 loadClass()
通常不需要。 大多数情况下重写 findClass() 就够了。
一段代码看懂初始化顺序
class Parent {
static int a = 1;
static {
System.out.println("Parent init");
}
}
class Child extends Parent {
static int b = 2;
static {
System.out.println("Child init");
}
}
public class Test {
public static void main(String[] args) {
System.out.println(Child.a);
}
}
输出结果:
Parent init
1
原因是:
- 访问的是
a a定义在Parent- 因此只会触发
Parent初始化 Child不会初始化
如果改成:
System.out.println(Child.b);
则输出大概率是:
Parent init
Child init
2
因为初始化子类前,必须先初始化父类。
如何排查类加载问题
线上排查类加载问题时,最有价值的思路通常不是“类文件有没有”,而是以下四件事:
1. 看是谁加载了这个类
System.out.println(SomeClass.class.getClassLoader());
2. 看类是从哪里加载的
System.out.println(
SomeClass.class.getProtectionDomain().getCodeSource().getLocation()
);
3. 看是否存在多个版本同名类
尤其在以下场景中常见:
- fat jar
- 容器部署
- 插件系统
- 依赖冲突
- shading 处理不当
4. 看线程上下文类加载器是否正确
System.out.println(Thread.currentThread().getContextClassLoader());
很多 SPI 失效、驱动找不到、框架扩展点加载失败,本质上都和上下文类加载器有关。
双亲委派与现代 Java 开发的关系
在日常 CRUD 项目里,开发者很少直接写类加载器代码,但并不意味着类加载机制不重要。以下技术都和它密切相关:
- Spring Boot 可执行 Jar 的运行方式
- Tomcat / Jetty 容器部署
- JDBC 驱动自动加载
- Dubbo / SPI 扩展机制
- 热部署工具
- 字节码增强框架,如 ASM、CGLIB、ByteBuddy
- APM 探针、Java Agent、类重定义
当你理解了类加载机制,会更容易看懂:
- 框架为什么这样设计
- 某些类冲突为什么如此隐蔽
- 为什么“明明类名一样”却无法转换
- 为什么模块隔离和热更新总是绕不开 ClassLoader
总结
JVM 类加载机制可以概括为两条主线:
第一条是类生命周期主线:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
第二条是类加载器协作主线:
- Bootstrap ClassLoader 负责核心类库
- AppClassLoader 负责业务类
- 自定义类加载器负责特殊场景
- 双亲委派负责统一、稳定、安全的加载秩序
而双亲委派模型的本质,不是“死板的规则”,而是 JVM 为了避免重复加载、保护核心类库、维持类型一致性而建立的一套默认加载策略。
掌握它之后,再去理解 SPI、Tomcat、插件化、热部署、自定义类加载器,很多原本零散的知识点就会自然串起来。真正难的从来不是背出“先委派父加载器”,而是明白:
类是如何被加载的,为什么要这样加载,以及在什么场景下必须改变这种加载方式。