作者:没有四次元口袋的蓝胖
日期:2026-08-01
标签:Java, JVM, 类加载, 双亲委派
JVM知识梳理(3)
类加载决定"类怎么进入内存",是 JVM 面试的另一大核心。掌握类加载过程、双亲委派原理、如何打破双亲委派。
一、类加载过程
1.1 类的生命周期
加载 → 连接(验证 → 准备 → 解析) → 初始化 → 使用 → 卸载
1.2 加载(Loading)
任务:
- 通过类的全限定名获取二进制字节流
- 将字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在堆中生成
java.lang.Class对象,作为方法区数据的访问入口
来源:
- 本地文件系统(
.class文件) - JAR 包
- 网络(Applet)
- 运行时计算(动态代理)
1.3 连接(Linking)
连接分为三个阶段:验证、准备、解析。
验证(Verification)
确保字节码符合 JVM 规范,不会危害虚拟机。
验证内容:
- 文件格式验证(是否以 CAFEBABE 开头)
- 元数据验证(是否有父类、是否继承了 final 类)
- 字节码验证(字节码指令是否正确)
- 符号引用验证(类名、方法名是否存在)
准备(Preparation)
为类的静态变量分配内存并设置默认值(不是代码中的赋值)。
java
public class Test {
public static int a = 10;
public static final int b = 20;
}
// 准备阶段:a = 0(int 默认值),b = 20(final 常量直接赋值)
// 初始化阶段:a = 10(执行 <clinit>() 方法)
关键区分:准备阶段设默认值(int 是 0),初始化阶段才赋代码中的值(10)。
解析(Resolution)
将常量池中的符号引用替换为直接引用(内存地址)。
符号引用:java/lang/String(类名)
直接引用:0x7f8b2c00(内存地址)
1.4 初始化(Initialization)
执行类构造器 <clinit>() 方法,初始化静态变量和静态代码块。
java
public class Test {
public static int a = 10;
static {
b = 30;
}
public static int b = 20;
}
// 初始化后:a = 10, b = 20(静态代码块和静态变量按顺序执行)
注意 :
<clinit>()方法是编译器自动收集的静态变量赋值和静态代码块合并而成的。
二、类加载器
2.1 类加载器层次
Bootstrap ClassLoader(启动类加载器)
↑
Extension ClassLoader(扩展类加载器)
↑
Application ClassLoader(应用程序类加载器)
↑
Custom ClassLoader(自定义类加载器)
| 类加载器 | 加载范围 | 实现者 |
|---|---|---|
| Bootstrap | JAVA_HOME/lib(如 java.lang.*) |
JVM 实现(C++) |
| Extension | JAVA_HOME/lib/ext |
Java 代码 |
| Application | 用户类路径(classpath) |
Java 代码 |
| Custom | 自定义路径 | 用户代码 |
三、双亲委派模型
3.1 工作原理
加载请求 → Application ClassLoader
↓(先委派给父加载器)
Extension ClassLoader
↓(再委派给父加载器)
Bootstrap ClassLoader
↓(父加载器无法加载,才自己加载)
尝试加载 → 失败则抛出 ClassNotFoundException
核心逻辑:
java
// ClassLoader.loadClass() 简化版
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 先检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name);
} else {
// 3. 父加载器为空(Bootstrap),自己尝试加载
c = findClass(name);
}
}
return c;
}
3.2 为什么需要双亲委派?
好处:
- 避免类重复加载:父加载器已加载的类,子加载器不会再加载
- 保护核心类库 :用户自定义的
java.lang.String不会被加载,防止核心类被篡改
java
// 用户代码
package java.lang;
public class String { } // 自定义 String
// 双亲委派保证:Bootstrap 加载的 java.lang.String 不会被覆盖
// 用户代码中的 String 会被委派给 Bootstrap 加载,最终加载的是 JDK 的 String
面试回答模板:双亲委派的好处有两个:一是避免类重复加载,父加载器加载过的类子加载器不会再加载;二是保护核心类库,用户自定义的 java.lang.String 不会被加载,防止核心类被篡改。
四、打破双亲委派
4.1 什么场景需要打破?
| 场景 | 原因 |
|---|---|
| Tomcat | 每个 Web 应用有独立的类加载器,互不干扰 |
| OSGi | 模块化框架,需要灵活的类加载策略 |
| SPI 机制 | JDBC、JNDI 等需要加载第三方实现 |
4.2 如何打破?
方法 :重写 loadClass() 方法。
java
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 打破双亲委派:自己先尝试加载,失败再委派给父加载器
try {
Class<?> c = findClass(name);
return c;
} catch (ClassNotFoundException e) {
return super.loadClass(name, resolve);
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 自定义加载逻辑(如从网络、数据库加载)
byte[] data = loadClassData(name);
return defineClass(name, data, 0, data.length);
}
}
4.3 SPI 机制打破双亲委派
JDBC 的 SPI 场景:
java
// JDBC 核心接口在 Bootstrap 加载(java.sql.*)
// 但实现类(如 MySQL Driver)在 classpath 中,由 Application 加载
// 解决方案:Thread Context ClassLoader
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
// 父加载器通过线程上下文加载器调用子加载器加载实现类
流程:
Bootstrap 加载 java.sql.DriverManager
↓
DriverManager 需要加载 com.mysql.cj.jdbc.Driver
↓
Bootstrap 无法加载(在 classpath 中)
↓
通过 Thread.currentThread().getContextClassLoader() 获取 Application 加载器
↓
Application 加载器加载 MySQL Driver
五、类加载器隔离与 Tomcat
5.1 Tomcat 的类加载器
Common ClassLoader(加载 Tomcat 通用类)
↑
Catalina ClassLoader(加载 Tomcat 内部类)
↑
Shared ClassLoader(加载共享类)
↑
WebApp ClassLoader(每个 Web 应用独立)
├── /webapp1 → WebAppClassLoader1
└── /webapp2 → WebAppClassLoader2
设计目的:
- 不同 Web 应用的类互相隔离
- 同一份第三方库可以被多个应用共享
🗺️ 思维导图速览
JVM 类加载
├── 类加载过程
│ ├── 加载(获取字节流 → 方法区 → Class 对象)
│ ├── 连接
│ │ ├── 验证(字节码合法性)
│ │ ├── 准备(静态变量设默认值)
│ │ └── 解析(符号引用 → 直接引用)
│ └── 初始化(执行 <clinit>(),赋实际值)
├── 类加载器层次
│ ├── Bootstrap(JDK 核心类)
│ ├── Extension(扩展类)
│ ├── Application(classpath)
│ └── Custom(自定义)
├── 双亲委派
│ ├── 原理:先委派父加载器,父加载器无法加载才自己加载
│ ├── 好处:避免重复加载、保护核心类库
│ └── 打破:重写 loadClass()
└── SPI 机制
└── Thread Context ClassLoader 反向调用子加载器
📝 写在最后
学习建议
- 类加载过程要记顺序:加载→连接(验证→准备→解析)→初始化,准备阶段设默认值,初始化阶段赋实际值
- 双亲委派要理解为什么:不是死记硬背,理解"避免重复加载"和"保护核心类库"两个核心好处
- 打破双亲委派要知道场景 :Tomcat 的 WebAppClassLoader、SPI 的
Thread.currentThread().getContextClassLoader() - 准备 vs 初始化 :面试经常问"静态变量
int a = 10什么时候赋值"------准备阶段赋 0,初始化阶段赋 10
面试回答模板
Q:说说类的加载过程?
类的加载过程分为加载、连接、初始化三个阶段。加载阶段获取字节流并在方法区创建运行时数据结构,在堆中生成 Class 对象。连接阶段分为验证(字节码合法性)、准备(静态变量设默认值,如 int 是 0)、解析(符号引用转直接引用)。初始化阶段执行
<clinit>()方法,为静态变量赋代码中的实际值。
Q:什么是双亲委派?为什么需要?
双亲委派是类加载器之间的委派机制。类加载请求先委派给父加载器处理,父加载器无法加载才自己加载。层次是 Bootstrap → Extension → Application → Custom。好处有两个:一是避免类重复加载,父加载器加载过的类子加载器不会再加载;二是保护核心类库,用户自定义的 java.lang.String 不会被加载,防止核心类被篡改。
Q:如何打破双亲委派?
通过重写 ClassLoader 的
loadClass()方法,自己先尝试加载,失败再委派给父加载器。典型场景有:Tomcat 的每个 Web 应用有独立的 WebAppClassLoader,保证应用之间类隔离;JDBC 的 SPI 机制通过Thread.currentThread().getContextClassLoader()让父加载器调用子加载器加载第三方实现。