类的加载过程是 Java 虚拟机(JVM)运行时机制中极为关键的一环。它不仅关系到程序能否正确执行,还直接影响性能、安全性和扩展性。
本文将对 Java 类加载全过程详细解析。
一、概述
类加载是指 JVM 将 .class 文件(或其他来源的字节码)加载到内存中,并对其进行校验、准备、解析和初始化,最终生成一个可被 JVM 直接使用的 java.lang.Class 对象的过程。

✅“类加载”在广义上包含五个阶段:
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization)
其中JVM 规范将“连接(Linking)”分为验证、准备、解析三个部分。
图中的使用和卸载是为了能看到 class 在虚拟机中的生命周期全貌而画出来的,本身已经不属于加载类加载阶段。
二、详细阶段分解
1. 加载(Loading)
目标
- 获取类的二进制数据;
- 在方法区(Metaspace / 永久代)中创建该类的运行时表明;
- 在堆中创建对应的 java.lang.Class 对象。
具体任务
- 通过类的全限定名(Fully Qualified Name)获取其二进制字节流
来源可以是: - 本地文件系统(.class 文件)
- JAR、WAR、ZIP 等归档文件
- 网络(如 Applet、RMI)
- 数据库(少见)
- 动态生成(如 ASM、Javassist、CGLIB、Lambda 表达式内部类)
- 加密后的字节码(需自定义 ClassLoader 解密)
- 将字节流解析为 JVM 内部的数据结构
- 存储在 方法区(Method Area) 中(JDK 8+ 为 Metaspace,使用本地内存);
- 包含:类名、父类、接口列表、字段表、方法表、常量池、访问标志等。
- 在 Java 堆中创建一个 java.lang.Class 实例
- 该对象是程序访问类元数据的唯一入口;
- 所有反射操作都基于此对象;
- 同一个类在同一个类加载器下只会有一个 Class 对象。
特点
- 非必须顺序执行:加载阶段可能与后续的验证阶段交叉进行(如边加载边验证);
- 用户可控:可通过自定义 ClassLoader 重写 findClass() 或 loadClass() 控制加载逻辑。
2. 验证(Verification)
目标
确保 Class 文件的字节流不会危害 JVM 的安全性和稳定性。这是 JVM 安全体系的第一道防线。
⚠️ 若跳过验证(如 -Xverify:none),可能导致 JVM 崩溃或安全漏洞。
四个子阶段(按顺序)
(1) 文件格式验证(File Format Verification)
- 检查字节流是否符合 Class 文件规范;
- 示例检查项:
- 魔数是否为 0xCAFEBABE;
- 主/次版本号是否在当前 JVM 支持范围内;
- 常量池中的常量类型是否合法;
- access_flags 是否合理(如不能同时为 final 和 abstract)。
此阶段保证输入是“看起来像 Class 文件”的数据。
(2) 元数据验证(Metadata Verification)
- 对字节码描述的语义进行分析;
- 示例检查项:
- 类是否有父类(除 Object 外);
- 是否继承了 final 类;
- 是否实现了所有抽象方法;
- 字段/方法是否重载冲突;
- 接口方法是否为 public abstract。
此阶段确保类的结构符合 Java 语言规范。
(3) 字节码验证(Bytecode Verification)
- 最复杂、最耗时的阶段;
- 通过数据流分析和控制流分析验证字节码逻辑;
- 示例检查项:
- 操作数栈是否会溢出或下溢;
- 局部变量表访问是否越界;
- 方法调用参数类型是否匹配;
- 类型转换是否安全(如 (String) obj 前是否做过 instanceof 检查?JVM 不强制,但会验证类型兼容性);
- return 语句是否返回正确类型。
自 JDK 6 起引入 类型推断验证器(Type Checker),提升效率。
(4) 符号引用验证(Symbolic Reference Verification)
- 发生在解析阶段之前;
- 确保解析能正确完成;
- 示例检查项:
- 引用的类是否存在;
- 引用的方法/字段是否可访问(思考包私有、protected 等);
- 是否违反继承规则(如调用父类 private 方法)。
若验证失败,抛出 java.lang.VerifyError。
3. 准备(Preparation)
目标
为类变量(static 字段)分配内存并设置初始值(零值)。
关键点
- 仅针对 static 字段(实例字段在对象实例化时分配);
- 内存分配在方法区(JDK 8+ 在 Metaspace);
- 赋值为“零值”,不是代码中定义的值!
示例说明
public class Example {
public static int a = 100;
public static final int b = 200;
public static String c = "hello";
public static Object d;
}
|
字段 |
准备阶段值 |
初始化阶段值 |
|
a |
0 |
100 |
|
b |
200 |
200(不变) |
|
c |
null |
“hello” |
|
d |
null |
null |
✅ 为什么 b 是 200?
由于 static final 且是编译期常量(ConstantValue 属性),编译器会将其值直接写入 Class 文件的 ConstantValue 属性,JVM 在准备阶段就赋值。
❌ 若 static final 字段在运行时计算(如 new Object()),则仍按零值处理,初始化阶段赋值。
4. 解析(Resolution)
目标
将常量池中的 符号引用(Symbolic Reference) 转换为 直接引用(Direct Reference)。
什么是符号引用?
- 一组符号描述目标,与内存布局无关;
- 如:com/example/Foo.bar:()V;
- 存在于 Class 文件的常量池中(CONSTANT_Class、CONSTANT_Fieldref、CONSTANT_Methodref 等)。
什么是直接引用?
- 指向目标的指针、偏移量或句柄;
- 与 JVM 内存布局强相关;
- 可直接定位到方法或字段。
解析类型
|
引用类型 |
解析内容 |
|
类/接口解析 |
将类符号引用解析为方法区中的类对象 |
|
字段解析 |
找到字段在类中的内存偏移量 |
|
类方法解析 |
找到方法在虚方法表(vtable)中的位置 |
|
接口方法解析 |
找到接口方法表(itable)中的条目 |
|
方法类型解析 |
用于 invokedynamic(如 Lambda) |
解析时机
- 静态解析:在类加载期间完成(如 invokestatic, getstatic);
- 动态解析:在运行时完成(如 invokevirtual, invokeinterface);
- JVM 允许“懒解析”(Lazy Resolution):首次使用时才解析。
注意:解析失败抛出 java.lang.IncompatibleClassChangeError 或其子类(如 NoSuchFieldError)。
5. 初始化(Initialization)
目标
执行类的 类构造器 <clinit>() 方法,完成 static 字段的显式赋值和 static 块的执行。
<clinit>()方法详解
- 由编译器自动收集 所有 static 变量赋值语句 和 static 代码块,按源码顺序合并生成;
- 无参数、无返回值、不可被直接调用;
- 线程安全:JVM 保证 <clinit> 只被执行一次;
- 若类无 static 赋值或 static 块,则不生成 <clinit>。
示例
public class InitDemo {
static {
System.out.println("Static block 1");
}
public static int x = initX();
static {
System.out.println("Static block 2");
}
private static int initX() {
System.out.println("initX called");
return 42;
}
}
<clinit> 等价于:
static {
System.out.println("Static block 1");
x = initX(); // 输出 "initX called"
System.out.println("Static block 2");
}
初始化顺序规则
- 父类先于子类初始化(除非父类已初始化);
- 接口初始化独立:实现接口不会触发接口初始化,只有真正使用接口的 static 字段时才初始化;
- 多个线程同时初始化:JVM 会阻塞其他线程,直到第一个线程完成 <clinit>。
触发初始化的 7 种“主动引用”场景(JVM 规范规定)
- new 创建实例;
- 访问 非编译期常量 的 static 字段(如 Example.a);
- 调用 static 方法;
- 反射调用(Class.forName(“…”) 默认初始化);
- 初始化子类时,若父类未初始化,则先初始化父类;
- 虚拟机启动时指定的主类(含 main 方法);
- JDK 7+:MethodHandle 或 invokedynamic 首次解析。
不触发初始化的“被动引用”示例
class Parent {
static {
System.out.println("Parent initialized");
}
public static final int VALUE = 100; // 编译期常量
}
class Child extends Parent {
static {
System.out.println("Child initialized");
}
}
// 测试
System.out.println(Child.VALUE); // 仅输出 100,不触发 Parent 或 Child 初始化!
由于 VALUE 是编译期常量,已被内联到调用类的常量池中。
三、类加载器(ClassLoader)体系
1. 内置类加载器
|
类加载器 |
加载路径 |
父加载器 |
实现语言 |
|
Bootstrap ClassLoader |
<JAVA_HOME>/lib |
无(null) |
C++ |
|
Extension ClassLoader |
<JAVA_HOME>/lib/ext |
Bootstrap |
Java |
|
Application ClassLoader |
-classpath 或 -cp 指定路径 |
Extension |
Java |
2. 双亲委派模型(Parent Delegation Model)
- 工作流程:
- 收到类加载请求;
- 先委托父加载器尝试加载;
- 父加载器无法加载(未找到),自己才尝试加载。
- 优点:
- 避免重复加载;
- 保证核心类库安全(如无法自定义 java.lang.String);
- 维护类的唯一性和一致性。
3. 打破双亲委派
- 场景:SPI(Service Provider Interface)机制,如 JDBC、JNDI;
- 方式:使用 线程上下文类加载器(Thread Context ClassLoader);ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass(“com.mysql.cj.jdbc.Driver”); - ✅在作者前面的文章中有讲到类加载的双亲委派,可以作为参考。
四、类的卸载(Unloading)
- 类卸载条件极其严格:
- 该类的所有实例已被回收;
- 加载该类的 ClassLoader 已被回收;
- 该类的 java.lang.Class 对象没有在任何地方被引用。
- 一般只在 自定义 ClassLoader + 动态加载 场景下发生(如热部署、OSGi);
- 卸载后,下次使用会重新走完整加载流程。
五、常见QA
- Q1:类加载和类初始化有什么区别?
A:加载是把字节码读入内存并创建 Class 对象;初始化是执行 static 代码。 - Q2:为什么 static final 常量在准备阶段就赋值?
A:由于其值在编译期确定,存入 Class 文件的 ConstantValue 属性,JVM 直接读取。 - Q3:数组类是如何加载的?
A:由 JVM 自动生成,其类加载器与元素类型一样;无 <clinit> 方法。 - Q4:如何查看类加载过程?
A:使用 JVM 参数 -verbose:class 或 -XX:+TraceClassLoading。


