前言

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程称作类加载机制

与那些在编译时进行连接的语言不同,Java 类型的加载、连接和初始化都是在程序运行期间完成的。这种设计虽然会带来一些额外的性能开销,但也为 Java 提供了高度的灵活性——类加载器 + 双亲委派模型就是这种灵活性的基石。

类加载的生命周期

一个类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括七个阶段:

flowchart LR
    A["加载
(Loading)"] --> B["验证
(Verification)"] B --> C["准备
(Preparation)"] C --> D["解析
(Resolution)"] D --> E["初始化
(Initialization)"] E --> F["使用
(Using)"] F --> G["卸载
(Unloading)"] style A fill:#e1f5fe style B fill:#e1f5fe style C fill:#e1f5fe style D fill:#e1f5fe style E fill:#ffe0b2 style F fill:#e8f5e9 style G fill:#fce4ec

验证、准备、解析三个阶段统称为连接(Linking)。其中解析阶段在某些情况下可以在初始化之后再开始(为了支持 Java 的运行时绑定)。

加载

加载阶段主要完成三件事:

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成代表该类的 java.lang.Class 对象,作为方法区这个类各种数据的访问入口

验证

验证是连接阶段的第一步,目的是确保 Class 文件的字节流中包含的信息符合 JVM 规范的所有约束,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。主要包括:

  • 文件格式验证:是否以魔数 0xCAFEBABE 开头、主次版本号是否在当前虚拟机接受范围
  • 元数据验证:是否有父类、是否继承了被 final 修饰的类
  • 字节码验证:通过数据流和控制流分析,确保语义合法
  • 符号引用验证:对类自身以外的信息进行匹配性校验

准备

准备阶段正式为类变量分配内存并设置初始值。这里需要注意:

  • 类变量(static 修饰的字段)的内存分配在方法区(JDK 7 之前)或堆中(JDK 8+)
  • 初始值是数据类型的零值,不是代码中赋的值
1
2
3
public static int value = 123;
// 准备阶段后,value = 0,而非 123
// 赋值动作要到初始化阶段才会执行

特例是 static final 常量:

1
2
public static final int VALUE = 123;
// 准备阶段就会直接赋值为 123(编译时已知的常量)

解析

解析阶段将常量池内的符号引用替换为直接引用。符号引用在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Fieldref_info 等常量形式存在,而直接引用是指向目标的指针、相对偏移量或能间接定位到目标的句柄。

初始化

初始化阶段是类加载的最后一个步骤,到了这里才真正开始执行类中定义的 Java 代码。初始化会执行 <clinit>() 方法,它由编译器自动收集类中所有类变量赋值动作和静态语句块合并产生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Parent {
static {
System.out.println("Parent init");
}
public static int value = 123;
}

public class Child extends Parent {
static {
System.out.println("Child init");
}
}

// 调用 Child.value
// 输出: "Parent init"(触发的是父类的初始化,不会输出 "Child init")

类加载器

类加载器用来实现”通过一个类的全限定名来获取该类的二进制字节流”这个动作。对于任意一个类,加载它的类加载器和这个类本身一起确立了其在 JVM 中的唯一性——比较两个类是否”相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。

三层类加载器

flowchart BT
    subgraph 第三层[第三层 - 应用程序类加载器]
        AppCL["Application ClassLoader
加载 classpath 下的类"] end subgraph 第二层[第二层 - 扩展类加载器] ExtCL["Extension ClassLoader
加载 jre/lib/ext 下的类"] end subgraph 第一层[第一层 - 启动类加载器] BootCL["Bootstrap ClassLoader
C++实现
加载 rt.jar 等核心库"] end AppCL -->|父加载器| ExtCL ExtCL -->|父加载器| BootCL
  • 启动类加载器(Bootstrap ClassLoader):C++ 实现,是 JVM 的一部分,没有对应的 Java 对象。负责加载 <JAVA_HOME>/lib 目录下的核心类库(如 rt.jar)。
  • 扩展类加载器(Extension ClassLoader):由 sun.misc.Launcher$ExtClassLoader 实现,负责加载 <JAVA_HOME>/lib/ext 目录下的类库。JDK 9 之后被平台类加载器取代。
  • 应用程序类加载器(Application ClassLoader):由 sun.misc.Launcher$AppClassLoader 实现,负责加载 classpath(即用户类路径)上指定的类库。如果不自定义类加载器,默认就是使用它。

双亲委派模型

工作流程

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它不会自己先去加载,而是把这个请求委托给父加载器去执行,每一层都是如此,因此所有的加载请求最终都应该传到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。

flowchart TD
    A[收到类加载请求] --> B{AppClassLoader
先检查类是否已加载?} B -->|未加载| C[委托给父加载器] C --> D{Extension ClassLoader
先检查类是否已加载?} D -->|未加载| E[委托给父加载器] E --> F{Bootstrap ClassLoader
先检查类是否已加载?} F -->|未加载| G[尝试自己加载] G --> H{加载成功?} H -->|成功| I[返回 Class 对象] H -->|失败| J[回到 Extension 尝试加载] J --> K{加载成功?} K -->|成功| I K -->|失败| L[回到 Application 尝试加载] L --> M{加载成功?} M -->|成功| I M -->|失败| N[抛出 ClassNotFoundException] style F fill:#e1f5fe style G fill:#e1f5fe style J fill:#fff3e0 style L fill:#fff3e0

为什么需要双亲委派

  1. 安全性:防止核心 API 被篡改。比如用户自定义了一个 java.lang.String 类,如果不由启动类加载器加载核心 String,而是由 AppClassLoader 加载了用户自定义的 String,那整个 Java 运行环境就会出大问题。双亲委派保证了所有 java.* 下的类都优先由 Bootstrap ClassLoader 加载,用户自定义的同名类永远不会被加载。

  2. 避免重复加载:父加载器已经加载过的类,子加载器不需要再加载一次。

实现方式

双亲委派的实现非常简单,在 java.lang.ClassLoaderloadClass 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 父加载器不为 null,委托给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 父加载器为 null,使用 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 4. 父加载器无法加载
}
if (c == null) {
// 5. 自己尝试加载
c = findClass(name);
}
}
return c;
}
}

如何打破双亲委派

双亲委派模型虽然好,但并不是万能的。有三个著名的场景需要打破它:

场景一:SPI(Service Provider Interface)

JDBC 4.0 之后,驱动程序可以通过 SPI 机制自动加载。但当 JVM 加载 java.sql.DriverManager 时,这个类是由 Bootstrap ClassLoader 加载的。Bootstrap ClassLoader 并不认识 classpath 下的 MySQL 驱动包。

解决方案是使用线程上下文类加载器(Thread Context ClassLoader),它本质上是一个”破坏者”——父类加载器通过它请求子类加载器去完成类的加载。

1
2
3
4
5
// ServiceLoader 的核心逻辑
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}

场景二:Tomcat 等 Web 容器

一个 Web 容器中可能部署了多个应用,每个应用可能依赖不同版本的同一个库。比如应用 A 依赖 Spring 4,应用 B 依赖 Spring 5。Tomcat 需要保证:

  • 应用之间的类隔离(同一个类不同版本不冲突)
  • 应用可以使用自己依赖的库,而非容器的库
  • JSP 修改后热加载

Tomcat 通过自定义的 WebAppClassLoader 来解决,它打破了双亲委派——优先加载自己 WEB-INF/lib 下的类,找不到才委托给父加载器。

场景三:OSGi

OSGi 实现了模块化热部署,每个模块(Bundle)有自己的类加载器,模块之间通过 Export-Package / Import-Package 声明依赖关系。类加载不再是树形的委派结构,而是网状的。

总结

flowchart TD
    subgraph 双亲委派["✅ 默认: 双亲委派模型"]
        A["向上委托
优先让父加载器加载"] B["保证核心API安全
避免重复加载"] end subgraph 打破委派["❌ 打破: 自定义类加载器"] C["重写 loadClass 方法
改变加载顺序"] D["SPI / Tomcat / OSGi
各有自己的打破方式"] end 双亲委派 -->|大多数场景| E["使用默认即可"] 打破委派 -->|特殊场景| F["理解后再自定义"]

双亲委派模型是 JVM 体系中的一个精妙设计:

  • 它保证了 Java 核心类库的安全性,防止用户自定义的类篡改核心 API
  • 它通过层级关系实现了类的唯一性和有序性
  • 在需要打破它的时候,通过 SPI、线程上下文类加载器、自定义 ClassLoader 等方式,JVM 也提供了足够的灵活性