类加载机制
问题
Java 的类加载过程是怎样的?什么是双亲委派模型?如何打破双亲委派?
答案
类的生命周期
一个类从被加载到 JVM 到卸载出内存,经历以下阶段:
其中验证、准备、解析统称为连接(Linking) 阶段。
1. 加载(Loading)
加载阶段完成三件事:
- 通过类的全限定名获取二进制字节流(可以来自 class 文件、JAR、网络、动态生成等)
- 将字节流的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个
Class对象,作为方法区中该类数据的访问入口
// 类的字节码来源非常灵活
// 1. 本地 class 文件
// 2. JAR/WAR 包中
// 3. 网络(Applet)
// 4. 动态代理生成(java.lang.reflect.Proxy)
// 5. JSP 生成的 Servlet 类
// 6. 数据库中读取(少见)
2. 验证(Verification)
确保 class 文件的字节流符合 JVM 规范,不会危害虚拟机安全:
| 验证阶段 | 验证内容 |
|---|---|
| 文件格式验证 | 魔数(0xCAFEBABE)、版本号、常量池常量类型 |
| 元数据验证 | 语义分析:是否有父类、是否继承了 final 类、是否实现了接口方法 |
| 字节码验证 | 方法体的合法性:栈映射帧、类型转换合法性、跳转指令目标 |
| 符号引用验证 | 解析阶段执行,验证引用的类/字段/方法是否存在、访问权限是否合法 |
可以使用 -Xverify:none 跳过验证以加快启动速度,但仅建议在已充分测试的环境中使用。
3. 准备(Preparation)
为类变量(static 修饰的变量)分配内存并设置零值(不是代码中的初始值):
public class MyClass {
// 准备阶段:value = 0(零值)
// 初始化阶段:value = 123(真正赋值)
public static int value = 123;
// 特例:final static 常量在准备阶段就赋值
// 准备阶段:CONSTANT = "hello"(直接赋值)
public static final String CONSTANT = "hello";
}
| 类型 | 零值 |
|---|---|
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
boolean | false |
char | '\u0000' |
| 引用类型 | null |
4. 解析(Resolution)
将常量池中的符号引用替换为直接引用:
| 概念 | 说明 |
|---|---|
| 符号引用 | 用一组符号(类全名、字段名、方法签名)描述引用目标 |
| 直接引用 | 直接指向目标的内存指针、偏移量或间接定位的句柄 |
解析主要针对:类/接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符。
5. 初始化(Initialization)
执行类构造器 <clinit>() 方法,这是真正执行 Java 代码的阶段:
public class Parent {
static int a = 1; // 步骤1
static { System.out.println("Parent static block"); } // 步骤2
public Parent() {
System.out.println("Parent constructor");
}
}
public class Child extends Parent {
static int b = 2; // 步骤3
static { System.out.println("Child static block"); } // 步骤4
public Child() {
System.out.println("Child constructor");
}
}
// new Child() 的输出顺序:
// Parent static block → 父类 <clinit>
// Child static block → 子类 <clinit>
// Parent constructor → 父类 <init>
// Child constructor → 子类 <init>
<clinit>() 方法的特点:
- 由静态变量赋值和 static 代码块合并产生,按出现顺序执行
- JVM 保证父类的
<clinit>()先于子类执行 - JVM 保证
<clinit>()在多线程环境下的线程安全(只执行一次) - 接口不会调用父接口的
<clinit>(),只在使用时才初始化
触发类初始化的 6 种情况(主动引用):
| 场景 | 示例 |
|---|---|
new 创建实例 | new MyClass() |
| 读写静态字段(非 final 常量) | MyClass.value |
| 调用静态方法 | MyClass.method() |
| 反射调用 | Class.forName("MyClass") |
| 初始化子类时父类未初始化 | 先触发父类初始化 |
| main 方法所在的类 | JVM 启动时初始化 |
不会触发初始化的情况(被动引用):
// 1. 通过子类引用父类的静态字段,不会初始化子类
System.out.println(Child.parentStaticField);
// 2. 定义类数组不会触发初始化
Parent[] arr = new Parent[10];
// 3. 引用 final static 常量不会触发初始化(常量在编译期已放入调用方常量池)
System.out.println(MyClass.CONSTANT);
类加载器
Java 提供了三种内置类加载器,加上用户自定义类加载器:
| 类加载器 | 加载路径 | 说明 |
|---|---|---|
| Bootstrap | $JAVA_HOME/lib(rt.jar 等) | C++ 实现,Java 中获取为 null |
| Extension/Platform | $JAVA_HOME/lib/ext | JDK 9 后改名为 Platform ClassLoader |
| Application | classpath / -cp 指定的路径 | 加载用户代码,ClassLoader.getSystemClassLoader() |
| 自定义 | 用户指定 | 继承 ClassLoader,重写 findClass() |
双亲委派模型
工作流程:
- 收到类加载请求时,先委派给父加载器
- 父加载器继续向上委派,直到 Bootstrap ClassLoader
- 如果父加载器无法加载(在其搜索范围内找不到),子加载器才自己尝试加载
// java.lang.ClassLoader#loadClass 核心逻辑(简化版)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 检查该类是否已被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 委派给父加载器
c = parent.loadClass(name, false);
} else {
// 3. 父加载器为 null,使用 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载
}
if (c == null) {
// 4. 父加载器加载失败,自己尝试加载
c = findClass(name);
}
}
return c;
}
双亲委派的好处:
- 避免类的重复加载:父加载器加载过的类不会被子加载器重复加载
- 保护核心 API:防止用户自定义
java.lang.String等核心类(会被 Bootstrap 优先加载) - 保证类的唯一性:同一个类被不同类加载器加载会被视为不同的类
打破双亲委派
有三种主要场景需要打破双亲委派:
1. SPI 机制(ServiceLoader)
父加载器(Bootstrap)加载的接口需要调用子加载器(Application)加载的实现类:
// JDBC 的 DriverManager 在 rt.jar 中(Bootstrap 加载)
// 但具体驱动(如 MySQL Driver)在 classpath(Application 加载)
// 怎么办?使用线程上下文类加载器(Thread Context ClassLoader)
// DriverManager 通过以下方式获取 Application ClassLoader 来加载驱动
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);
2. 自定义 ClassLoader
热部署、类隔离、加密 class 文件等需要自定义类加载:
public class HotDeployClassLoader extends ClassLoader {
private String classPath;
public HotDeployClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从指定路径读取 class 文件字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException(name);
}
// defineClass 将字节数组转为 Class 对象
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String name) {
String fileName = classPath + "/" + name.replace('.', '/') + ".class";
try (FileInputStream fis = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
}
3. OSGi / 模块化
OSGi 使用网状委派模型,每个 Bundle 有自己的类加载器,根据包名规则决定委派给哪个 Bundle 的类加载器。
JDK 9 的**模块化系统(JPMS)**也修改了类加载的委派方式,但核心的双亲委派逻辑仍然保留。
Tomcat 的类加载器
Tomcat 打破双亲委派实现 Web 应用隔离:
WebAppClassLoader 的特殊行为:
- 先在本地
WEB-INF/classes和WEB-INF/lib中查找 - 找不到才委派给父加载器
- 这样不同 Web 应用可以使用不同版本的相同库
常见面试问题
Q1: 类加载的过程有哪些阶段?
答案:
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。
其中验证、准备、解析合称连接。准备阶段为类变量赋零值,初始化阶段才执行真正的赋值(<clinit>() 方法)。需要注意 static final 常量在准备阶段就会赋最终值。
Q2: 什么是双亲委派模型?为什么需要它?
答案:
类加载请求先委派给父加载器处理,父加载器无法处理时才由子加载器自己加载。
好处:
- 避免重复加载:同一个类只会被一个加载器加载一次
- 保护核心类库:用户无法通过自定义
java.lang.String来替换核心类 - 保证类型安全:确保同名类在 JVM 中是唯一的
Q3: 如何打破双亲委派?举例说明。
答案:
三种方式:
- 重写
loadClass()方法:不委派父加载器,直接自己加载(如 Tomcat 的 WebAppClassLoader) - 线程上下文类加载器:SPI 机制中,Bootstrap 加载的接口通过
Thread.currentThread().getContextClassLoader()调用子加载器来加载实现类(如 JDBC、JNDI) - OSGi 的网状委派:Bundle 之间根据包名互相委派
典型案例:
- JDBC:
DriverManager(Bootstrap 加载)需要加载com.mysql.cj.jdbc.Driver(classpath 中) - Tomcat:每个 Web 应用有独立的 WebAppClassLoader,优先加载自己的类,实现应用隔离
Q4: Class.forName() 和 ClassLoader.loadClass() 有什么区别?
答案:
| 方法 | 是否初始化 | 说明 |
|---|---|---|
Class.forName("com.Xxx") | 会触发初始化 | 执行 <clinit>(),运行 static 代码块 |
Class.forName("com.Xxx", false, cl) | 可选 | 第二个参数控制是否初始化 |
ClassLoader.loadClass("com.Xxx") | 不会初始化 | 只执行加载阶段,不执行初始化 |
应用场景:JDBC 中 Class.forName("com.mysql.cj.jdbc.Driver") 需要触发初始化来执行驱动注册的 static 代码块。
Q5: 类的卸载条件是什么?
答案:
类被卸载需要同时满足三个条件(非常苛刻):
- 该类的所有实例都已被 GC
- 加载该类的 ClassLoader 已被 GC
- 该类的
Class对象没有被引用(无法通过反射访问)
由 Bootstrap、Extension、Application 加载器加载的类基本不会被卸载(这些加载器不会被 GC)。只有自定义类加载器加载的类才可能被卸载。
Q6: 同一个类被不同 ClassLoader 加载会怎样?
答案:
JVM 中类的唯一性由「类的全限定名 + 加载它的类加载器」共同确定。同一个 class 文件被不同 ClassLoader 加载后,会被视为两个不同的类:
ClassLoader cl1 = new CustomClassLoader("/path1");
ClassLoader cl2 = new CustomClassLoader("/path2");
Class<?> clazz1 = cl1.loadClass("com.example.Foo");
Class<?> clazz2 = cl2.loadClass("com.example.Foo");
System.out.println(clazz1 == clazz2); // false
System.out.println(clazz1.equals(clazz2)); // false
// clazz1 的实例 instanceof clazz2 也会返回 false
这是 Tomcat 实现 Web 应用隔离、OSGi 实现模块隔离的基础。
Q7: 什么是 SPI?为什么它需要打破双亲委派?
答案:
SPI(Service Provider Interface)是 Java 提供的服务发现机制,在 META-INF/services/ 下声明接口实现类。
打破双亲委派的原因:SPI 接口(如 java.sql.Driver)由 Bootstrap ClassLoader 加载,但实现类(如 MySQL Driver)在 classpath 中需要 Application ClassLoader 加载。Bootstrap 无法"看到"子加载器的类,所以通过线程上下文类加载器让父加载器可以"借用"子加载器来加载实现类。
Q8: <clinit>() 和 <init>() 有什么区别?
答案:
| 方法 | 说明 | 执行时机 |
|---|---|---|
<clinit>() | 类构造器,由 static 变量赋值 + static 代码块组成 | 类初始化阶段(只执行一次,线程安全) |
<init>() | 实例构造器,由实例变量赋值 + 实例初始化块 + 构造方法组成 | 每次 new 对象时执行 |
<clinit>() 是可选的,如果类没有静态变量和 static 代码块,编译器不会生成它。