跳到主要内容

类加载机制

问题

Java 的类加载过程是怎样的?什么是双亲委派模型?如何打破双亲委派?

答案

类的生命周期

一个类从被加载到 JVM 到卸载出内存,经历以下阶段:

其中验证、准备、解析统称为连接(Linking) 阶段。

1. 加载(Loading)

加载阶段完成三件事:

  1. 通过类的全限定名获取二进制字节流(可以来自 class 文件、JAR、网络、动态生成等)
  2. 将字节流的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个 Class 对象,作为方法区中该类数据的访问入口
CustomClassSource.java
// 类的字节码来源非常灵活
// 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 修饰的变量)分配内存并设置零值(不是代码中的初始值):

PreparationPhase.java
public class MyClass {
// 准备阶段:value = 0(零值)
// 初始化阶段:value = 123(真正赋值)
public static int value = 123;

// 特例:final static 常量在准备阶段就赋值
// 准备阶段:CONSTANT = "hello"(直接赋值)
public static final String CONSTANT = "hello";
}
类型零值
int0
long0L
float0.0f
double0.0d
booleanfalse
char'\u0000'
引用类型null

4. 解析(Resolution)

将常量池中的符号引用替换为直接引用

概念说明
符号引用用一组符号(类全名、字段名、方法签名)描述引用目标
直接引用直接指向目标的内存指针、偏移量或间接定位的句柄

解析主要针对:类/接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符。

5. 初始化(Initialization)

执行类构造器 <clinit>() 方法,这是真正执行 Java 代码的阶段:

InitializationOrder.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 启动时初始化

不会触发初始化的情况(被动引用):

PassiveReference.java
// 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/extJDK 9 后改名为 Platform ClassLoader
Applicationclasspath / -cp 指定的路径加载用户代码,ClassLoader.getSystemClassLoader()
自定义用户指定继承 ClassLoader,重写 findClass()

双亲委派模型

工作流程:

  1. 收到类加载请求时,先委派给父加载器
  2. 父加载器继续向上委派,直到 Bootstrap ClassLoader
  3. 如果父加载器无法加载(在其搜索范围内找不到),子加载器才自己尝试加载
ClassLoaderDelegation.java
// 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;
}

双亲委派的好处:

  1. 避免类的重复加载:父加载器加载过的类不会被子加载器重复加载
  2. 保护核心 API:防止用户自定义 java.lang.String 等核心类(会被 Bootstrap 优先加载)
  3. 保证类的唯一性:同一个类被不同类加载器加载会被视为不同的类

打破双亲委派

有三种主要场景需要打破双亲委派:

1. SPI 机制(ServiceLoader)

父加载器(Bootstrap)加载的接口需要调用子加载器(Application)加载的实现类:

SPIExample.java
// 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 文件等需要自定义类加载:

CustomClassLoader.java
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 的特殊行为

  1. 先在本地 WEB-INF/classesWEB-INF/lib 中查找
  2. 找不到才委派给父加载器
  3. 这样不同 Web 应用可以使用不同版本的相同库

常见面试问题

Q1: 类加载的过程有哪些阶段?

答案

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。

其中验证、准备、解析合称连接准备阶段为类变量赋零值,初始化阶段才执行真正的赋值(<clinit>() 方法)。需要注意 static final 常量在准备阶段就会赋最终值。

Q2: 什么是双亲委派模型?为什么需要它?

答案

类加载请求先委派给父加载器处理,父加载器无法处理时才由子加载器自己加载。

好处:

  1. 避免重复加载:同一个类只会被一个加载器加载一次
  2. 保护核心类库:用户无法通过自定义 java.lang.String 来替换核心类
  3. 保证类型安全:确保同名类在 JVM 中是唯一的

Q3: 如何打破双亲委派?举例说明。

答案

三种方式:

  1. 重写 loadClass() 方法:不委派父加载器,直接自己加载(如 Tomcat 的 WebAppClassLoader)
  2. 线程上下文类加载器:SPI 机制中,Bootstrap 加载的接口通过 Thread.currentThread().getContextClassLoader() 调用子加载器来加载实现类(如 JDBC、JNDI)
  3. OSGi 的网状委派:Bundle 之间根据包名互相委派

典型案例:

  • JDBCDriverManager(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: 类的卸载条件是什么?

答案

类被卸载需要同时满足三个条件(非常苛刻):

  1. 该类的所有实例都已被 GC
  2. 加载该类的 ClassLoader 已被 GC
  3. 该类的 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 代码块,编译器不会生成它。

相关链接