JVM - 类的加载过程详解
# 1. 引言:类的生命周期概述
Java 程序运行的基础是类。与 C++ 等语言在编译时就将所有代码和数据链接到一个可执行文件不同,Java 采用了动态加载和链接的方式。这意味着类的加载、链接和初始化过程是在程序运行时按需进行的。
# 1.1 为何需要类加载?
Java 的数据类型分为基本数据类型和引用数据类型。基本类型(如 int
, float
等)由 JVM 预先定义。而引用类型(类、接口、数组等)则需要通过类加载机制 (Class Loading) 将其对应的字节码文件 (.class
文件) 从磁盘、网络或其他来源加载到内存中,并转换为 JVM 可以直接使用的内部表示形式。这个过程是 Java 动态性、平台无关性和热部署等特性的基础。
# 1.2 类的完整生命周期
根据《Java 虚拟机规范》,一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期可以划分为以下 7 个阶段:
- 加载 (Loading)
- 链接 (Linking)
- 验证 (Verification)
- 准备 (Preparation)
- 解析 (Resolution)
- 初始化 (Initialization)
- 使用 (Using)
- 卸载 (Unloading)
其中,验证、准备、解析 这三个阶段统称为 链接 (Linking) 阶段。加载、链接、初始化这三个阶段构成了类加载过程的核心。
注意: 这些阶段的开始顺序通常是固定的,但它们并非严格按顺序完成。例如,加载阶段可能尚未完成,链接阶段可能已经开始。解析阶段也可能在初始化阶段之后才开始(动态绑定/晚期绑定)。
从类的实际使用角度看,流程大致如下:
# 1.3 类加载机制的重要性 (面试视角)
理解 JVM 类加载机制是 Java 开发者的核心技能之一,也是大厂面试中的高频考点。例如:
- 蚂蚁金服: 描述一下 JVM 加载 Class 文件的原理机制?类加载过程?
- 百度: 类加载的机制?Java 类加载过程?简述 Java 类加载机制?
- 腾讯: JVM 中类加载机制,类加载过程?
- 滴滴: JVM 类加载机制?
- 美团: Java 类加载过程?描述一下 JVM 加载 Class 文件的原理机制?
接下来,我们将详细探讨类加载过程的各个阶段。
# 2. 加载 (Loading) 阶段
加载是类加载过程的第一个阶段。
# 2.1 加载阶段的目标
简单来说,加载阶段的目标是:查找并加载类的二进制字节流 (.class
文件),将其转换为 JVM 内部特定的数据结构,并在堆内存中创建一个代表该类的 java.lang.Class
对象实例。
这个 Class
对象就像是原始类在 JVM 内存中的一个“快照”或“模板”,JVM 后续对类的所有访问(如获取字段、调用方法、反射操作)都是通过这个 Class
对象来进行的。
# 2.2 加载阶段完成的三件事
虚拟机在此阶段必须完成以下三项任务:
- 获取类的二进制流: 通过类的全限定名 (包名 + 类名,例如
java.lang.String
) 定位并获取对应的.class
文件的二进制字节流。 - 转换内部数据结构: 将字节流所代表的静态存储结构(如常量池、字段定义、方法定义等)转化为方法区(JDK 8 前的永久代,JDK 8 及以后的元空间)内的运行时数据结构(可以理解为 JVM 内部的类模型)。
- 创建
Class
对象: 在 Java 堆内存中生成一个java.lang.Class
类的实例,这个实例封装了方法区中该类的数据结构,并作为程序访问方法区类数据的入口。
# 2.3 获取二进制流的途径
JVM 规范并没有限制从何处获取类的二进制流,这使得 Java 类加载机制非常灵活。常见的来源包括:
- 从本地文件系统加载: 最常见的方式,读取
.class
文件 (例如javac
编译后)。 - 从归档文件加载: 从
.jar
,.war
,.zip
等压缩包中读取并提取.class
文件。 - 从网络加载: 通过网络协议(如 HTTP)下载
.class
文件,典型应用是 Applet 或 Web Start。 - 从数据库加载: 读取存储在数据库中的类的二进制数据。
- 动态生成: 在运行时通过计算直接生成类的二进制字节流,例如动态代理技术 (使用
Proxy
类或 CGLIB 等库)、JSP 文件编译成的 Servlet 类。 - 从其他文件生成: 如从 .java 源文件动态编译加载。
JVM 只关心获取到的字节流是否符合 Class 文件格式规范。如果字节流的起始魔数不是 0xCAFEBABE
或格式不正确,加载阶段就会抛出 ClassFormatError
。
# 2.4 类模型与 Class
对象的位置
- 运行时数据结构 (类模型): 存储在 方法区 (Metaspace 或 PermGen) 中。包含了类的所有静态信息,如常量池、字段信息、方法信息、构造器信息、访问修饰符等。
java.lang.Class
对象: 存储在 Java 堆 中。它是方法区类数据的访问入口。每个加载到 JVM 的类都对应一个唯一的Class
对象。
关键点:
Class
对象的构造方法是私有的,只能由 JVM 在加载类时创建。Class
对象是 Java 反射机制的基础。通过它可以获取类的完整结构信息。
代码示例:通过 Class
对象访问类信息 (反射)
// 文件名: LoadingTest.java
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
public class LoadingTest {
public static void main(String[] args) {
try {
// 1. 通过 Class.forName 获取 String 类的 Class 对象 (会触发类加载)
Class<?> clazz = Class.forName("java.lang.String");
// 2. 通过 Class 对象获取类的方法信息
Method[] ms = clazz.getDeclaredMethods(); // 获取所有声明的方法
for (Method m : ms) {
// 获取修饰符 (public, static, etc.)
String mod = Modifier.toString(m.getModifiers());
System.out.print(mod + " ");
// 获取返回值类型
String returnType = m.getReturnType().getSimpleName();
System.out.print(returnType + " ");
// 获取方法名
System.out.print(m.getName() + "(");
// 获取参数类型列表
Class<?>[] ps = m.getParameterTypes();
if (ps.length == 0) System.out.print(')');
for (int i = 0; i < ps.length; i++) {
char end = (i == ps.length - 1) ? ')' : ',';
System.out.print(ps[i].getSimpleName() + end);
}
System.out.println();
}
} catch (ClassNotFoundException e) {
// 如果类找不到,会抛出此异常
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
(运行此代码会打印出 java.lang.String
类中定义的所有方法的签名)
# 2.5 数组类的加载
数组类的加载比较特殊:
- 数组类本身不是由类加载器(ClassLoader)创建的,而是由 JVM 在运行时直接创建。例如,
String[]
这个类是由 JVM 动态生成的。 - 但是,数组的元素类型(Component Type)仍然需要依靠类加载器去加载。
- 如果元素类型是引用类型 (如
String[]
的元素类型是String
),JVM 会先确保该元素类型已被加载,然后才创建数组类。 - 如果元素类型是基本类型 (如
int[]
的元素类型是int
),则不需要类加载器介入元素类型的加载。
- 如果元素类型是引用类型 (如
- 数组类的可见性:
- 如果元素类型是引用类型,则数组类的可见性与元素类型的可见性一致。
- 如果元素类型是基本类型,则数组类的可见性默认为
public
。
# 3. 链接 (Linking) 阶段
链接阶段是将加载到内存的类的二进制数据合并到 JVM 运行时状态中的过程,包括验证、准备和解析三个子阶段。
# 3.1 验证 (Verification)
验证是链接阶段的第一步,也是确保 JVM 安全的关键环节。
目标: 确保被加载的类(.class
文件字节流)符合 JVM 规范,并且不会危害虚拟机自身的安全。防止加载恶意或损坏的字节码。
验证阶段大致会进行 四个层面 的检查:
文件格式验证 (Format Verification):
- 时机: 主要在加载阶段进行,但逻辑上属于验证的一部分。
- 内容: 检查字节流是否符合 Class 文件格式规范。
- 是否以魔数
0xCAFEBABE
开头? - 主、次版本号是否在当前 JVM 处理范围之内?
- 常量池中的常量类型是否不被支持?
- 指向常量的各种索引值是否指向了不存在的常量或不符合类型的常量?
- ...等等。
- 是否以魔数
- 目的: 保证输入的字节流能被正确解析并存储于方法区。只有通过了格式验证,字节流才会进入内存的方法区进行存储。后续的验证都基于方法区的存储结构。
元数据验证 (Metadata Verification):
- 时机: 在类的数据结构加载到方法区后进行。
- 内容: 对字节码描述的信息进行语义分析,确保其符合 Java 语言规范。
- 这个类是否有父类(除了
java.lang.Object
)? - 这个类是否继承了不允许被继承的类(如
final
类)? - 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法?
- 类中的字段、方法是否与父类产生矛盾(例如,不兼容的方法签名重载,
final
方法被重写等)? - ...等等。
- 这个类是否有父类(除了
- 目的: 保证不存在不符合 Java 语言规范的元数据信息。
字节码验证 (Bytecode Verification):
- 最复杂的阶段,主要进行数据流和控制流分析。
- 内容: 确保类的方法体没有进行危害虚拟机安全的行为。
- 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作(例如,
iadd
指令执行前,栈顶必须是两个int
)。 - 保证跳转指令不会跳转到方法体之外的字节码指令上。
- 保证方法体中的类型转换是有效的(例如,不能把一个对象实例赋值给其父类型数据,却赋给了毫无继承关系的其他类型)。
- ...等等。
- 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作(例如,
- 技术: 使用栈映射帧 (StackMapTable)(JDK 6 引入)来辅助验证。栈映射帧记录了方法在特定字节码偏移量处,其操作数栈和局部变量表中的类型状态。这使得验证器不必从头推导类型状态,提高了验证效率。
- 局限性: 字节码验证试图发现所有潜在问题,但理论上无法 100% 保证安全(停机问题)。它主要检查明显的、可预知的问题。
符号引用验证 (Symbolic Reference Verification):
- 时机: 通常发生在解析 (Resolution) 阶段。即在 JVM 将符号引用转化为直接引用的时候进行。
- 内容: 确保符号引用所指向的目标(类、字段、方法)是存在的、可访问的。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类? (
NoClassDefFoundError
) - 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段? (
NoSuchFieldError
,NoSuchMethodError
) - 符号引用中的类、字段、方法的访问性(
private
,protected
,public
,package
) 是否可被当前类访问? (IllegalAccessError
) - ...等等。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类? (
- 目的: 保证解析行为能正常执行。如果无法解析,程序将在运行时抛出相应的错误。
验证的重要性与性能: 验证阶段虽然可能影响类加载速度,但它至关重要,因为它将许多运行时的检查提前到加载时完成,避免了运行时大量的类型和权限检查,反而提升了运行效率。可以通过 -Xverify:none
参数关闭大部分验证(不推荐在生产环境使用)。
# 3.2 准备 (Preparation)
准备阶段是为类变量 (static fields) 分配内存并设置其初始默认值的过程。
分配内存: 仅为类变量(被
static
修饰的变量)分配内存,这些变量使用的内存都将在方法区中进行分配。实例变量(非static
)将在对象实例化时随着对象一起分配在堆内存中。设置默认值: 为类变量设置 Java 虚拟机规定的初始零值,而不是程序员在代码中显式赋予的值。
数据类型 默认值 int
0
long
0L
short
(short)0
char
'\u0000'
byte
(byte)0
boolean
false
float
0.0f
double
0.0d
reference
null
static final
常量的特殊情况: 如果类变量是static final
修饰的常量,并且其类型是基本类型或java.lang.String
,那么在准备阶段就会被直接赋予代码中指定的初始值。这是因为这些常量的值在编译时就已经确定,并存储在 Class 文件的常量池中。- 例如:
public static final int VALUE = 10;
在准备阶段VALUE
的值就是10
。 - 例如:
public static final String S = "hello";
在准备阶段S
就指向"hello"
字符串对象。 - 例外: 如果
final
常量的赋值需要调用方法或构造器(如public static final String S2 = new String("world");
或public static final int R = new Random().nextInt();
),那么赋值操作将在后续的初始化 (<clinit>
) 阶段进行。
- 例如:
不执行代码: 准备阶段不执行任何 Java 代码。程序员定义的赋值操作是在初始化阶段才执行。
代码示例:
// 文件名: PreparationTest.java
public class PreparationTest {
// 在准备阶段:
private static long id; // 分配内存, id = 0L (默认值)
private static final int NUM = 1; // 分配内存, NUM = 1 (常量, 直接赋值)
public static final String CONST_STR = "CONST"; // 分配内存, CONST_STR 指向 "CONST" (常量, 直接赋值)
public static final String CONST_STR1 = new String("CONST1"); // 分配内存, CONST_STR1 = null (引用类型默认值, new 操作在初始化阶段)
private static int count = 5; // 分配内存, count = 0 (默认值)
// 在初始化阶段 (<clinit>):
// id = 实际赋的值 (如果代码中有赋值)
// count = 5 (执行代码中的赋值)
// CONST_STR1 指向 new String("CONST1") 对象
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.3 解析 (Resolution)
解析阶段是将常量池内的符号引用 (Symbolic References) 替换为直接引用 (Direct References) 的过程。
- 符号引用: 以一组符号来描述所引用的目标(如类的全限定名、字段名和描述符、方法名和描述符)。与虚拟机内存布局无关。
- 直接引用: 可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用与虚拟机内存布局相关。如果有了直接引用,意味着目标必定已存在于内存中。
- 解析内容: 主要涉及对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等 7 类符号引用的解析。
- 时机: JVM 规范并未规定解析发生的具体时间。虚拟机实现可以根据需要,选择在类加载时就解析所有符号引用(静态解析),也可以在符号引用第一次被使用时才进行解析(动态解析/晚期绑定)。HotSpot VM 通常采用动态解析。
- 目的: 确保程序真正执行时,能够找到并访问到它所引用的类、字段和方法在内存中的实际位置。
- 与验证的关系: 符号引用验证在此阶段进行,确保引用的目标存在且可访问。
示例:
代码中调用 System.out.println()
。
- 符号引用: 常量池中可能包含
java/lang/System
(类引用),out Ljava/io/PrintStream;
(字段引用),java/io/PrintStream
(类引用),println (Ljava/lang/String;)V
(方法引用)。 - 解析过程:
- 解析类
java/lang/System
,找到其在方法区的运行时数据结构。 - 解析字段
out
,找到它在System
类数据结构中的偏移量或指针,并确认其类型是java/io/PrintStream
。 - 解析类
java/io/PrintStream
。 - 解析方法
println(String)
,找到它在PrintStream
类方法表中的索引或内存地址。
- 解析类
- 直接引用: 最终得到
System
类、out
字段、PrintStream
类以及println
方法在内存中的具体地址或偏移量。
字符串常量池与解析:
- Class 文件中的字符串字面量以
CONSTANT_String_info
形式存储在常量池,它引用一个CONSTANT_Utf8_info
来表示字符串内容。 - JVM 在内部维护一个字符串常量池 (String Pool / String Table)(通常在堆中),存储所有出现过的字符串字面量及通过
intern()
方法加入的字符串,且无重复项。 - 解析
CONSTANT_String_info
时,JVM 会检查字符串常量池。如果字符串已存在,则返回池中对象的引用;如果不存在,则在池中创建该字符串对象并返回其引用。 String.intern()
方法可以主动将一个 String 对象尝试放入常量池,并返回池中对象的引用。
# 4. 初始化 (Initialization) 阶段
初始化是类加载过程的最后一步。在此阶段,JVM 才真正开始执行类中定义的 Java 程序代码(特指类的初始化部分)。
# 4.1 初始化阶段的目标
主要是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法:- 不是程序员编写的,而是 Java 编译器自动收集类中的所有类变量的赋值动作和静态代码块 (
static {}
block) 中的语句合并产生的。 - 编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态代码块只能访问到定义在它之前的类变量;定义在它之后的类变量,在前面的静态代码块中可以赋值,但不能访问。
- 不需要显式调用父类的
<clinit>()
方法。JVM 会保证在子类的<clinit>()
执行前,其父类的<clinit>()
已经执行完毕。因此,JVM 中第一个被执行的<clinit>()
方法的类肯定是java.lang.Object
。 - 接口中也可以有
<clinit>()
方法(用于初始化接口中的静态变量),但与类不同,执行接口的<clinit>()
不要求其父接口全部执行了<clinit>()
。只有在真正使用到父接口的静态变量时(继承下来的不算),才会触发父接口的初始化。 - 接口的实现类在初始化时,不会执行接口的
<clinit>()
方法。 <clinit>()
方法的执行在多线程环境中是线程安全的。JVM 会进行同步处理,确保只有一个线程执行该方法,其他线程阻塞等待。
- 不是程序员编写的,而是 Java 编译器自动收集类中的所有类变量的赋值动作和静态代码块 (
# 4.2 何时生成 <clinit>()
方法?
编译器并非为所有类都生成 <clinit>()
方法。以下情况不会生成 <clinit>()
:
- 类中既没有静态变量赋值操作,也没有静态代码块。
- 类中有静态变量,但没有使用静态代码块或显式赋值语句对其进行初始化(即它们将保持准备阶段设置的默认零值)。
- 类中仅包含
static final
修饰的基本类型或 String 类型的常量,并且其赋值是编译期常量表达式(直接量赋值,不涉及方法调用)。这些常量在准备阶段就已经被赋值了。
代码示例 1: <clinit>
执行顺序
// 文件名: ClinitOrderTest.java
public class ClinitOrderTest {
public static int id = 1; // 赋值操作,在 <clinit> 中执行 (ldc 1, putstatic id)
public static int number; // 准备阶段为 0
static { // 静态代码块,在 <clinit> 中执行
number = 2; // 赋值操作 (iconst_2, putstatic number)
System.out.println("father static{}");
}
// <clinit> 方法内容大致等价于:
// id = 1;
// number = 2;
// System.out.println("father static{}");
// (按源码顺序合并)
public static void main(String[] args) {
// main 方法执行前,ClinitOrderTest 类会被初始化,<clinit> 执行
System.out.println(number);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
输出:
father static{}
2
2
代码示例 2: 不生成 <clinit>
的情况
// 文件名: NoClinitTest.java
public class NoClinitTest {
// 实例变量,不在 <clinit>
public int num = 1;
// 静态变量,无显式赋值或静态块,使用准备阶段默认值 0,不生成 <clinit>
public static int num1;
// static final 基本类型常量,编译期赋值,不生成 <clinit>
public static final int NUM2 = 1;
// static final String 字面量常量,编译期赋值,不生成 <clinit>
public static final String S = "Hello";
}
// NoClinitTest.class 文件中将没有 <clinit> 方法。
2
3
4
5
6
7
8
9
10
11
12
static final
赋值时机总结:
- 在链接准备阶段赋值:
static final
修饰的基本类型或String
类型字段。- 赋值操作是编译期常量表达式 (直接字面量,或简单常量运算,不含方法调用)。
- 例子:
static final int C1 = 10;
,static final String S1 = "abc";
,static final int C2 = C1 * 2;
- 在初始化
<clinit>
阶段赋值:static
字段(非final
或final
但赋值不是编译期常量)。static final
字段,但赋值涉及方法调用或构造器调用。- 例子:
static int a = 1;
,static final Integer C3 = Integer.valueOf(100);
,static final String S2 = new String("def");
,static final int R = new Random().nextInt();
# 4.3 <clinit>()
的线程安全性
JVM 内部保证了 <clinit>()
方法的线程安全。
- 同步机制: 当多个线程同时尝试初始化同一个类时,只有一个线程能获取到锁并执行该类的
<clinit>()
方法。其他线程必须阻塞等待,直到活动线程执行完毕。 - 仅执行一次: 一旦一个线程成功执行完
<clinit>()
,后续任何线程再次尝试初始化该类时,都会直接使用已初始化的结果,不会重复执行<clinit>()
。 - 死锁风险: 如果一个类的
<clinit>()
方法中直接或间接调用了另一个类的<clinit>()
,并且后者也反过来依赖前者的初始化,就可能导致死锁。这种死锁通常难以排查。
代码示例:<clinit>
死锁
// 文件名: StaticDeadLock.java
class StaticA {
static { // A 的 <clinit>
try { Thread.sleep(1000); } catch (InterruptedException e) {}
try {
System.out.println("StaticA trying to load StaticB");
// 尝试加载并初始化 B,会触发 B 的 <clinit>
Class.forName("StaticB");
} catch (ClassNotFoundException e) { e.printStackTrace(); }
System.out.println("StaticA init OK");
}
}
class StaticB {
static { // B 的 <clinit>
try { Thread.sleep(1000); } catch (InterruptedException e) {}
try {
System.out.println("StaticB trying to load StaticA");
// 尝试加载并初始化 A,会触发 A 的 <clinit>
Class.forName("StaticA");
} catch (ClassNotFoundException e) { e.printStackTrace(); }
System.out.println("StaticB init OK");
}
}
public class StaticDeadLockMain {
public static void main(String[] args) {
// 线程 1 初始化 A
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " start A");
try { Class.forName("StaticA"); } catch (ClassNotFoundException e) {}
System.out.println(Thread.currentThread().getName() + " end A");
}, "Thread-A").start();
// 线程 2 初始化 B
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " start B");
try { Class.forName("StaticB"); } catch (ClassNotFoundException e) {}
System.out.println(Thread.currentThread().getName() + " end B");
}, "Thread-B").start();
}
}
// 可能的输出:
// Thread-A start A
// Thread-B start B
// StaticA trying to load StaticB (线程 A 持有 A 的锁,等待 B 的锁)
// StaticB trying to load StaticA (线程 B 持有 B 的锁,等待 A 的锁)
// --> 死锁发生,程序挂起
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 4.4 何时执行实例变量初始化 (<init>
)?
类的初始化 (<clinit>
) 只处理静态变量和静态代码块。实例变量的初始化和实例代码块的执行是在创建对象实例时,由构造器方法 <init>()
负责的。
<init>()
方法的执行时机是在 new
指令创建对象之后,invokespecial
指令调用构造器时。其大致执行顺序(考虑继承):
- 父类实例变量初始化和实例代码块(按源码顺序)。
- 父类构造器代码。
- 子类实例变量初始化和实例代码块(按源码顺序)。
- 子类构造器代码。
这个过程与 <clinit>
不同,<init>
是在对象实例化时执行的。
# 4.5 类初始化的触发时机:主动使用 vs. 被动使用
JVM 规范严格规定了有且只有 6 种情况必须立即对类进行“初始化”(这自然要求加载、验证、准备都已经完成),称为对类的主动使用 (Active Use)。
- 创建类的实例: 使用
new
关键字、反射 (Class.newInstance()
,Constructor.newInstance()
)、克隆 (clone()
)、反序列化。 - 调用类的静态方法: 使用
invokestatic
指令。 - 访问类或接口的静态字段 (读取或赋值): 使用
getstatic
或putstatic
指令。- 例外: 如果该字段是
final
常量,并且在编译期就能确定其值(即已在准备阶段赋值),则访问该常量不会触发类的初始化。
- 例外: 如果该字段是
- 反射调用: 使用
java.lang.reflect
包的方法对类进行反射调用时(如Class.forName("MyClass")
)。 - 初始化子类: 当初始化一个类时,如果其父类尚未初始化,则需要先触发其父类的初始化。
- 包含
main
方法的启动类: 当虚拟机启动时,需要指定一个主类,虚拟机会首先初始化这个主类。 invokedynamic
相关: JDK 7 后,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getStatic
,REF_putStatic
,REF_invokeStatic
的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。- 接口的
default
方法 (JDK 8+): 如果一个接口定义了default
方法,那么直接实现或间接实现该接口的类在初始化时,该接口必须在其之前被初始化。
除以上情况外,所有其他引用类的方式都不会触发初始化,称为被动使用 (Passive Use)。
常见的被动使用场景:
- 通过子类引用父类的静态字段: 只会触发父类的初始化,不会触发子类的初始化。
- 通过数组定义引用类: 例如
MyClass[] array = new MyClass[10];
,这不会触发MyClass
的初始化。这只是创建了一个数组对象,数组类由 JVM 动态创建。 - 引用类的常量 (编译期常量): 访问类中定义为
static final
且在编译期就能确定值的常量(已在准备阶段赋值),不会触发该类的初始化。常量值直接从调用类的常量池中获取。 - 调用
ClassLoader.loadClass()
方法:loadClass()
方法只执行加载、链接(可能包含部分解析),但不会执行初始化阶段。只有Class.forName()
才包含初始化。
代码示例 1: 主动使用 (new, static method)
// 文件名: ActiveUse1.java
import java.io.*;
class Order implements Serializable { // 假设 Order 类在此定义
static { System.out.println("Order类的初始化过程"); }
public static void method() { System.out.println("Order method()...."); }
}
public class ActiveUse1 {
// 1. 使用 new 创建实例 (触发初始化)
@org.junit.Test public void testNew() { Order o = new Order(); }
// 输出: Order类的初始化过程
// 2. 反序列化 (间接创建实例,触发初始化)
@org.junit.Test public void testDeserialization() throws Exception {
// (假设 order.dat 是 Order 对象的序列化文件)
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("order.dat"));
Order order = (Order) ois.readObject();
ois.close();
}
// 输出: Order类的初始化过程 (如果 order.dat 存在且有效)
// 3. 调用静态方法 (触发初始化)
@org.junit.Test public void testStaticMethod() { Order.method(); }
// 输出:
// Order类的初始化过程
// Order method()....
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
代码示例 2: 主动使用 (static field) vs 被动使用 (常量)
// 文件名: ActiveUse2.java
import java.util.Random;
class User {
static { System.out.println("User类的初始化过程"); }
public static int num = 1; // 访问 num 会触发初始化
public static final int NUM_CONST = 1; // 编译期常量, 访问不触发初始化
public static final int NUM_RUNTIME_CONST = new Random().nextInt(10); // 运行时常量, 访问会触发初始化
}
interface CompareA {
// 接口静态字段初始化会触发接口初始化
public static final Thread T = new Thread(() -> System.out.println("CompareA 接口初始化"));
public static final int NUM1 = 1; // 编译期常量
public static final int NUM2 = new Random().nextInt(10); // 运行时常量
}
public class ActiveUse2 {
@org.junit.Test public void testUserAccess() {
System.out.println(User.num); // 主动使用 User.num, 触发 User 初始化
// System.out.println(User.NUM_CONST); // 被动使用, 不触发 User 初始化
// System.out.println(User.NUM_RUNTIME_CONST); // 主动使用, 触发 User 初始化
}
// 输出: User类的初始化过程 \n 1 (或其他值)
@org.junit.Test public void testInterfaceAccess() {
// System.out.println(CompareA.NUM1); // 被动使用, 不触发 CompareA 初始化
System.out.println(CompareA.NUM2); // 主动使用 NUM2, 触发 CompareA 初始化
}
// 输出: CompareA 接口初始化 \n (随机数)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
代码示例 3: 主动使用 (反射, 子类初始化)
// 文件名: ActiveUse3.java
import java.util.Random;
class Father { static { System.out.println("Father类的初始化过程"); } }
interface CompareB { Thread T = new Thread(() -> System.out.println("CompareB 接口初始化")); }
class Son extends Father implements CompareB {
static { System.out.println("Son类的初始化过程"); }
public static int num = 1;
}
interface CompareC extends CompareB {
Thread T2 = new Thread(() -> System.out.println("CompareC 接口初始化"));
int NUM1 = new Random().nextInt();
}
public class ActiveUse3 {
static { System.out.println("ActiveUse3的初始化过程"); } // 主类初始化
// 4. 反射 Class.forName (触发初始化)
@org.junit.Test public void testReflect() throws Exception { Class<?> clazz = Class.forName("Son"); }
// 输出:
// ActiveUse3的初始化过程
// Father类的初始化过程 (父类先初始化)
// Son类的初始化过程
// 5. 初始化子类 (触发父类初始化, 但不触发接口初始化)
@org.junit.Test public void testSonInit() { System.out.println(Son.num); }
// 输出:
// ActiveUse3的初始化过程
// Father类的初始化过程
// Son类的初始化过程
// 1
// 访问子接口的运行时常量 (触发子接口初始化, 但不一定触发父接口初始化)
@org.junit.Test public void testInterfaceInheritance() { System.out.println(CompareC.NUM1); }
// 输出:
// ActiveUse3的初始化过程
// CompareC 接口初始化 (只初始化用到的 CompareC)
// (随机数)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
代码示例 4: 被动使用 (父类静态字段, 数组定义, 常量, loadClass)
// 文件名: PassiveUse.java
import java.util.Random;
class ParentP {
static { System.out.println("ParentP 初始化"); }
public static int parentNum = 1;
public static final int PARENT_CONST = 10;
}
class ChildC extends ParentP { static { System.out.println("ChildC 初始化"); } }
interface SerialA { int ID = 1; /* 编译期常量 */ }
public class PassiveUse {
@org.junit.Test public void testParentField() {
// 1. 通过子类访问父类的静态字段 -> 只初始化父类
System.out.println(ChildC.parentNum);
}
// 输出: ParentP 初始化 \n 1
@org.junit.Test public void testArrayDef() {
// 2. 通过数组定义引用类 -> 不初始化类
ParentP[] parents = new ParentP[10];
System.out.println("Array created");
}
// 输出: Array created (无 ParentP 初始化)
@org.junit.Test public void testConstField() {
// 3. 访问类的编译期常量 -> 不初始化类
System.out.println(ParentP.PARENT_CONST);
// 访问接口的编译期常量 -> 不初始化接口
System.out.println(SerialA.ID);
}
// 输出: 10 \n 1 (无任何初始化打印)
@org.junit.Test public void testLoadClass() throws Exception {
// 4. ClassLoader.loadClass() -> 只加载链接, 不初始化
ClassLoader cl = ClassLoader.getSystemClassLoader();
Class<?> clazz = cl.loadClass("ParentP");
System.out.println("Class loaded");
}
// 输出: Class loaded (无 ParentP 初始化)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
使用 -XX:+TraceClassLoading
JVM 参数可以观察类的加载过程。
# 5. 使用 (Using) 阶段
一旦类成功完成了加载、链接和初始化这三个步骤,它就进入了可使用的状态。程序可以通过以下方式使用这个类:
- 访问类的静态字段 (非
final
常量)。 - 调用类的静态方法。
- 创建类的对象实例 (使用
new
或其他方式)。 - 通过对象实例访问实例字段、调用实例方法。
- 进行反射操作。
这个阶段是类生命周期中最长的阶段,覆盖了程序执行期间对该类的所有操作。
# 6. 卸载 (Unloading) 阶段
当一个类不再被需要时,JVM 可能会将其从方法区中卸载,释放其占用的内存。类的卸载需要满足非常苛刻的条件。
# 6.1 类、类加载器、实例的引用关系
理解卸载需要先了解它们之间的引用关系:
- 类加载器持有类的引用: 类加载器内部通常有一个集合(如 Map)维护着它所加载的所有类的
Class
对象的引用。 Class
对象持有类加载器的引用: 可以通过clazz.getClassLoader()
获取加载该类的加载器。- 类的实例持有
Class
对象的引用: 可以通过obj.getClass()
获取对象对应的Class
对象。 Class
对象本身也是对象:java.lang.Class
的实例存在于堆中。
(图中 Sample 类的实例
obj
引用 Sample.class
对象,Sample.class
对象引用其加载器 MyClassLoader
,同时 MyClassLoader
也引用 Sample.class
对象)
# 6.2 类的卸载条件
一个类型(类或接口)要被 JVM 卸载,必须同时满足以下三个条件:
- 该类的所有实例都已经被垃圾回收: Java 堆中不再存在该类及其任何子类的任何实例。
- 加载该类的类加载器 (ClassLoader) 已经被垃圾回收: 这个条件通常很难达成,特别是对于 JVM 自带的启动类加载器、扩展类加载器和系统类加载器加载的类。只有自定义的类加载器才有可能被回收,但这通常需要精心的设计(如 OSGi、JSP 热部署等场景)。
- 该类对应的
java.lang.Class
对象在任何地方都没有被引用: 程序中无法通过任何路径(包括反射)访问到该类的Class
对象。
# 6.3 卸载的可能性
- 启动类加载器 (Bootstrap) 加载的类(如
java.lang.String
)永远不会被卸载。 - 扩展类加载器 (Extension) 和系统类加载器 (System/Application) 加载的类,在程序运行期间几乎不可能被卸载,因为这些类加载器通常会存活到 JVM 关闭。
- 自定义类加载器 加载的类有可能被卸载,但必须满足上述三个条件,且通常需要显式触发 GC。在复杂的应用中,由于缓存、引用关系等原因,卸载也往往难以发生或时机不确定。
结论: 类的卸载是一个非常低频且难以预测的事件。开发者在编程时不应依赖类的卸载来管理资源或实现特定逻辑。
# 6.4 方法区的垃圾回收回顾
方法区(元空间/永久代)的垃圾回收主要包括两部分:
- 废弃常量回收: 回收常量池中不再被任何地方引用的常量(如字符串字面量)。相对容易判断。
- 无用类型回收: 回收不再使用的类。判断条件苛刻(见 6.2 节),即使满足条件,JVM 也不保证一定会回收该类,只是“被允许”回收。回收效果和频率远低于堆内存回收。