类的生命周期与加载过程
类加载的 5 个阶段:逐阶段拆解
一、加载(Loading):找到 class 文件,变成内存中的 “类模板”
加载是类加载过程的第一步,核心是将类的二进制数据(class 文件)加载到内存,并生成一个代表该类的Class
对象(这个对象是类的 “模板”,存放在方法区)。
- 具体操作:
- 定位 class 文件:JVM 通过类的 “全限定名”(比如
com.test.User
)找到对应的 class 文件。来源可能有:
- 定位 class 文件:JVM 通过类的 “全限定名”(比如
- 本地硬盘(最常见,比如项目的
target/classes
目录); - 网络(比如从远程服务器下载);
- 动态生成(比如通过
ASM
框架在运行时生成); - 数据库或压缩包(比如从
jar
包中读取)。
- 本地硬盘(最常见,比如项目的
- 读取二进制数据:把 class 文件的二进制字节流读入内存。
- 生成 Class 对象:将字节流转换成方法区中的 “运行时数据结构”(类的元数据,比如类的属性、方法、父类等信息),并在堆中生成一个对应这个类的
Class
对象(作为方法区中元数据的访问入口)。
- 例子:
当我们第一次执行User u = new User();
时,JVM 会先委托类加载器(比如应用类加载器)去查找com/test/User.class
文件,找到后读入内存,生成Class<User>
对象。这个Class
对象就像 “用户模板”,后续创建User
对象都要基于它。
- 谁来执行?:由类加载器(ClassLoader)完成,JVM 中有三种类加载器(双亲委派模型):
- 启动类加载器(Bootstrap ClassLoader):加载 JDK 核心类(比如
java.lang.String
); - 扩展类加载器(Extension ClassLoader):加载 JDK 扩展目录的类;
- 应用类加载器(Application ClassLoader):加载我们自己写的类。
- 启动类加载器(Bootstrap ClassLoader):加载 JDK 核心类(比如
二、验证(Verification):确保 class 文件 “合法安全”
验证是为了防止恶意或无效的 class 文件破坏 JVM,相当于给 class 文件做 “全面体检”,主要包括 4 种验证:
- 文件格式验证:
检查 class 文件的二进制格式是否正确,比如:
- 开头是否有 “魔数”(
0xCAFEBABE
,class 文件的标识,就像身份证的防伪标志); - 版本号是否符合当前 JVM 的要求(比如高版本 JDK 编译的 class 文件不能在低版本 JVM 上运行);
- 常量池的格式是否正确等。
- 开头是否有 “魔数”(
- 元数据验证:
检查类的元数据(比如类的结构信息)是否符合 Java 语言规范,比如:
- 类是否有父类(除了
java.lang.Object
,所有类都必须有父类); - 类是否继承了不允许被继承的类(比如被
final
修饰的类); - 方法参数是否合法等。
- 类是否有父类(除了
- 字节码验证:
最复杂的一步,检查方法体的字节码是否安全,比如:
- 确保指令不会跳转到方法体外;
- 操作栈的数据类型和指令匹配(比如不能用
int
类型的指令操作long
类型的数据); - 不会出现空指针访问等。
- 符号引用验证:
检查类中引用的其他类、方法、字段是否存在且可访问,比如:
- 引用的类是否存在;
- 调用的方法是否在该类中存在,参数是否匹配;
- 是否有权限访问(比如不能访问
private
方法)。
- 例子:
如果有人手动修改 class 文件,把开头的 “魔数” 改成其他值,文件格式验证就会失败,JVM 会直接抛出异常,拒绝加载这个类。
三、准备(Preparation):给静态变量 “分配内存并设默认值”
准备阶段的核心是为类的静态变量(类变量)分配内存,并设置 “默认初始值”(不是代码中定义的初始值)。
- 关键细节:
- 只处理静态变量:实例变量(非静态变量)的内存分配在创建对象时进行,这里不处理。
- 默认初始值:根据变量类型设置 JVM 默认值,比如:
int
→ 0;long
→ 0L;float
→ 0.0f;boolean
→ false;- 引用类型(比如
String
)→ null。
- 内存位置:静态变量的内存分配在方法区(JDK8 及以后是元空间)。
- 例子:
代码中定义:public static int count = 100;
准备阶段会为count
分配内存,设置默认值 0(不是 100);100
这个值会在后面的 “初始化” 阶段设置。
- 特殊情况:
如果静态变量被final
修饰(常量),比如public static final int MAX = 200;
,准备阶段会直接设置为 200(而不是默认值 0),因为final
常量在编译时就已确定值,会被放入常量池。
四、解析(Resolution):把 “符号引用” 换成 “直接引用”
解析阶段的作用是将常量池中的 “符号引用” 替换为 “直接引用”。
- 先理解两个概念:
- 符号引用:用字符串描述的引用,比如代码中写
User.name
,编译后在 class 文件中存的是 “User
类的name
字段” 这个字符串标识,不涉及具体内存地址。 - 直接引用:指向内存中实际对象的地址(比如指针、偏移量),JVM 可以通过直接引用直接找到目标。
- 符号引用:用字符串描述的引用,比如代码中写
- 解析的内容:
主要对类、接口、字段、方法、接口方法的符号引用进行解析,比如:
- 把 “
com.test.User
” 这个类的符号引用,换成方法区中User
类元数据的实际地址; - 把 “
User.name
” 这个字段的符号引用,换成User
类中name
字段在方法区的实际内存偏移量。
- 把 “
- 例子:
当代码中调用User.sayHello()
时,编译后 class 文件中存的是 “User
类的sayHello
方法” 这个符号引用;解析阶段会把它换成sayHello
方法在内存中的实际地址,这样 JVM 执行时就能直接找到该方法的字节码。
- 注意:解析阶段不一定在准备阶段后马上执行,也可能在初始化阶段之后(动态解析),比如当遇到动态绑定(多态)时,会延迟到运行时再解析。
五、初始化(Initialization):执行静态代码,给静态变量赋 “实际值”
初始化是类加载过程的最后一步,也是真正执行类中定义的 Java 代码的阶段,核心是执行 “类构造器<clinit>()
方法”。
<clinit>()
方法是什么?:
它是 JVM 自动生成的,由类中所有静态变量的赋值语句和静态代码块按顺序合并而成。比如:java运行
public class User {public static int a = 1; // 静态变量赋值static { // 静态代码块a = 2;System.out.println("静态代码块执行");}public static int b = 3; // 静态变量赋值
}
JVM 会生成<clinit>()
方法,执行顺序是:a=1
→ 执行静态代码块(a=2
)→ b=3
。
- 初始化的触发时机(主动使用):
只有当类被 “主动使用” 时,才会触发初始化(JVM 规范严格规定),包括:
- 创建类的实例(
new User()
); - 调用类的静态方法(
User.method()
); - 访问类的静态变量(
User.a
,但被final
修饰的常量除外,因为它在准备阶段已赋值); - 反射调用(
Class.forName("com.test.User")
); - 初始化子类时,父类未初始化则先初始化父类;
- 启动类(包含
main
方法的类)会被初始化。
- 创建类的实例(
- 不触发初始化的情况(被动使用):
- 访问类的
static final
常量(比如User.MAX
,因为准备阶段已赋值); - 通过子类访问父类的静态变量(只会初始化父类,不初始化子类);
- 创建数组(
User[] users = new User[10]
,这只是创建数组对象,不会初始化User
类)。
- 访问类的
- 例子:
执行User u = new User();
时,会触发User
类的初始化:
- 执行
<clinit>()
方法,给a
赋 1→2,b
赋 3,打印 “静态代码块执行”; - 之后才会创建
User
实例(执行实例构造器<init>()
方法)。
- 执行
总结:用 “做饭” 类比类加载过程
- 加载:把菜谱(class 文件)从书架(硬盘)拿到厨房(内存);
- 验证:检查菜谱是否完整、有没有错误(比如步骤是否矛盾);
- 准备:给厨房的调料罐(静态变量)贴上标签,先装默认的水(默认值);
- 解析:把菜谱中 “隔壁老王的酱油”(符号引用)换成具体的位置(比如 “厨房第三层左数第一个瓶子”);
- 初始化:按照菜谱的开头步骤(静态代码块和静态变量赋值),把水倒掉,装上真正的酱油、醋(赋实际值)。