一、前置认知:类生命周期的整体框架

在正式深入前,先明确 Java 类的完整生命周期:加载 → 连接(验证→准备→解析) → 初始化 → 使用 → 卸载

其中,加载、验证、准备、初始化的执行顺序是确定的(必须按此顺序执行),而解析阶段则具有灵活性 —— 为了支持 Java 的 “动态绑定”(多态),解析可能在初始化前执行,也可能延迟到初始化后(第一次使用时)执行。

接下来,我们逐个拆解核心阶段。

二、第一阶段:加载(Loading)—— 将类 “读入” JVM

加载是类生命周期的起点,核心目标是:根据类的 “全限定名”(如java.lang.String),将类的二进制字节流(Class 文件)转换为 JVM 可识别的运行时数据结构,并生成访问入口

简单来说,这一步就是把 “硬盘上的.class 文件” 变成 “JVM 内存里的类信息”。具体要完成 3 件事:

1. 获取二进制字节流

JVM 会通过全限定名,从各种来源读取类的二进制字节流(不一定是本地文件),常见来源包括:

  • 本地文件系统:最常见的方式,如target/classes目录下的.class 文件;
  • 压缩包:如 JAR、WAR、EAR 包中的.class 文件(比如 Spring Boot 的 JAR 包);
  • 网络传输:如 Applet 技术(虽已过时,但原理仍适用)、远程调用获取字节流;
  • 动态生成:如 CGLib 动态代理、ASM 框架动态生成 Class 字节流;
  • 数据库 / 缓存:从数据库或缓存中读取预先存储的字节流。

2. 转换为运行时数据结构

JVM 会将读取到的字节流(符合 Class 文件格式规范),转换为方法区中的 “类元数据”—— 包括类的版本、常量池、字段信息、方法信息、访问权限等。

方法区是 JVM 的 “元数据区”,专门存储类的结构信息,与堆(存储对象实例)、栈(存储方法调用栈)区分开。

3. 生成 Class 对象

堆内存中生成一个代表该类的java.lang.Class对象,这个对象是 “类元数据的访问入口”—— 后续我们通过反射操作类(如Class.forName())、创建对象实例(如new Object()),本质上都是通过这个Class对象获取类的信息。

举个例子:当我们执行Class.forName("com.example.User")时,JVM 就会触发加载阶段,生成User.class对应的Class对象,并返回给我们。

三、第二阶段:连接(Linking)—— 确保类 “合法可用”

加载完成后,类的元数据已存入方法区,但此时还不能直接使用 ——JVM 需要通过 “连接” 阶段,对类的合法性、内存分配、引用关系进行处理,确保类能安全运行。

连接阶段分为 3 个步骤:验证、准备、解析。

1. 验证(Verification)—— 防 “恶意字节流” 攻击

验证是连接的第一步,也是最严格的一步,核心目标是:确保 Class 文件的字节流符合 JVM 规范,避免恶意字节流(如篡改的.class 文件)危害 JVM 安全

验证会从 4 个层面逐层校验,缺一不可:

(1)文件格式验证(字节流层面)

先校验字节流是否符合 Class 文件的 “物理格式”,这是后续所有验证的基础。如果这一步失败,类直接加载失败。常见校验项:

  • 字节流开头的 “魔数” 必须是0xCAFEBABE(Java 的标志性魔数,用于识别 Class 文件);
  • 类的主 / 次版本号(如 Java 8 对应版本号 52.0)必须在当前 JVM 支持范围内(比如 Java 8 的 JVM 无法加载 Java 17 编译的类);
  • 常量池中的常量类型合法(如没有 JVM 不支持的常量标签);
  • 字节流没有被截断或附加非法数据(如文件末尾多了无效字节)。
(2)元数据验证(语义层面)

校验类的 “逻辑结构” 是否符合 Java 语言规范,确保类的定义合法。常见校验项:

  • 除了java.lang.Object,所有类必须有父类(Java 单继承特性);
  • 父类不能是final修饰的类(final 类不能被继承);
  • 类中的字段 / 方法不能与父类冲突(如子类覆盖父类的final方法、子类定义与父类private方法同名的方法不允许);
  • 抽象类必须实现所有抽象方法(或自身也是抽象类)。
(3)字节码验证(执行逻辑层面)

这是验证阶段最复杂的一步,通过 “数据流分析” 和 “控制流分析”,确保类的方法体字节码在运行时不会出错。常见校验项:

  • 操作数栈的数据类型与指令要求一致(如iadd指令只能对 int 类型运算,不能对 String 运算);
  • 跳转指令不会跳转到方法体以外的字节码(避免执行非法指令);
  • 类型转换合法(如不能将int直接强转为List,只能通过包装类转换)。
(4)符号引用验证(引用合法性层面)

校验类中引用的其他类 / 字段 / 方法是否存在且可访问,为后续 “解析” 阶段做准备。常见校验项:

  • 符号引用中描述的类(如com.example.Order)是否存在;
  • 类中引用的字段(如Order.id)、方法(如Order.getPrice())是否存在于目标类中;
  • 访问权限合法(如不能访问其他类的private字段、不能调用protected方法(非子类 / 同包))。

2. 准备(Preparation)—— 为类变量 “分配内存 + 设零值”

准备阶段的核心目标是:为类变量(static 修饰的变量)分配内存,并设置 “初始零值”,内存分配的位置是方法区。

这里有 3 个关键细节,必须牢记:

(1)只处理 “类变量”,不处理 “实例变量”

类变量属于 “类级别的属性”,随类加载分配内存;而实例变量属于 “对象级别的属性”,随对象实例化(new操作)分配在堆内存中,与类加载无关。

比如:

java

public class User {
    static int classVar; // 类变量,准备阶段分配内存
    int instanceVar;     // 实例变量,准备阶段不处理
}
(2)初始值是 “零值”,不是代码中的赋值

准备阶段为类变量设置的是 “对应数据类型的零值”,而非我们在代码中显式赋值的值。比如:

java

public class Test {
    // 准备阶段:value被设为0(int的零值),123的赋值在初始化阶段执行
    static int value = 123; 
}

常见数据类型的零值:

数据类型零值数据类型零值
int0booleanfalse
long0Lchar'\u0000'
float0.0freferencenull
double0.0dbyte(byte)0
(3)特殊情况:final 修饰的类变量

如果类变量被final修饰(即 “常量”),准备阶段会直接设置为代码中的显式值,而非零值。这是因为final常量在编译期就已确定值(存入 Class 文件的常量池),JVM 在准备阶段会直接读取该值并赋值。

比如:

java

public class Test {
    // 准备阶段:VALUE直接被设为123(而非0)
    static final int VALUE = 123; 
}

3. 解析(Resolution)—— 将 “符号引用” 转为 “直接引用”

解析阶段的核心目标是:将方法区常量池中的 “符号引用”,替换为 “直接引用”,让 JVM 能直接定位到目标资源。

首先要明确两个概念:

  • 符号引用:用 “符号描述” 目标的引用,与 JVM 内存布局无关。比如 Class 文件中,引用java.lang.String时,存储的是 “java/lang/String” 这个全限定名字符串(符号),而非内存地址。
  • 直接引用:能直接指向目标的 “内存地址” 或 “句柄”,与 JVM 内存布局相关。比如方法在方法区中的实际内存地址、字段在对象实例中的偏移量。
解析的对象

解析阶段会处理常量池中所有类型的符号引用,主要包括 7 类:

  1. 类或接口的符号引用(如Lcom/example/User;);
  2. 字段的符号引用(如com/example/User.id:I,表示 User 类的 int 类型 id 字段);
  3. 类方法的符号引用(如com/example/User.getName:()Ljava/lang/String;);
  4. 接口方法的符号引用;
  5. 方法类型的符号引用;
  6. 方法句柄的符号引用;
  7. 调用点限定符的符号引用。
解析的时机:灵活延迟

与加载、验证、准备不同,解析阶段的执行时机不固定:

  • 早期解析:部分 JVM 会在类加载时立即解析所有符号引用;
  • 惰性解析:多数 JVM 会延迟到 “第一次使用符号引用时” 才解析(如第一次调用某个方法时)。

这种灵活性是为了支持 Java 的 “动态绑定”(多态)—— 比如调用一个子类重写的父类方法时,只有在运行时才能确定具体调用的是子类方法,此时才需要解析子类方法的直接引用。

四、第三阶段:初始化(Initialization)—— 执行静态代码逻辑

初始化是类加载过程的最后一步,也是真正执行类中静态代码逻辑的阶段。核心是执行编译器自动生成的<clinit>()方法。

1. <clinit>() 方法是什么?

<clinit>()(class initialize)方法不是我们手动定义的,而是编译器在编译时,自动收集类中的 “类变量赋值语句” 和 “静态语句块(static {})”,按代码在源文件中的出现顺序合并生成的。

举个例子:

java

public class InitDemo {
    // 类变量赋值1
    static int a = 1;

    // 静态语句块1
    static {
        a = 2;
        b = 3; // 允许在静态块中给后续定义的类变量赋值(但不能访问)
    }

    // 类变量赋值2
    static int b = 4;

    // 静态语句块2
    static {
        b = 5;
    }
}

编译器生成的<clinit>()方法逻辑如下(按代码顺序):

java

a = 1 → a = 2 → b = 3 → b = 4 → b = 5

最终初始化完成后,a=2b=5

2. <clinit>() 方法的执行规则

JVM 对<clinit>()方法的执行有严格规定,这些规则直接影响类的初始化逻辑:

(1)父类优先执行

JVM 会保证:子类的<clinit>()方法执行前,父类的<clinit>()方法已执行完毕。这是因为父类的静态代码可能影响子类(如父类的静态变量被子类引用)。

比如:

java

class Parent {
    static {
        System.out.println("父类初始化");
    }
}

class Child extends Parent {
    static {
        System.out.println("子类初始化");
    }
}

// 执行new Child()时,输出顺序:父类初始化 → 子类初始化

特别注意:java.lang.Object是所有类的父类,因此它的<clinit>()方法是第一个执行的。

(2)接口的特殊性

接口的<clinit>()方法不需要先执行父接口的<clinit>()方法 —— 只有当父接口的静态变量被子类接口或实现类使用时,父接口才会初始化。

这是因为接口不能定义静态语句块,且接口的静态变量默认是public static final(常量),在准备阶段已赋值,无需依赖父接口。

(3)可能不生成<clinit>()

如果类中没有任何 “类变量赋值语句” 和 “静态语句块”,编译器会跳过<clinit>()方法的生成 —— 此时初始化阶段没有代码可执行。

(4)多线程安全

多个线程同时初始化同一个类时,JVM 会保证只有一个线程能执行<clinit>()方法,其他线程会阻塞等待,且仅执行一次。

这意味着:静态语句块是线程安全的,无需额外加锁。但要注意:如果<clinit>()方法中存在耗时操作(如循环),会导致其他线程长时间阻塞。

3. 什么时候触发初始化?

初始化阶段不会随意执行,只有当 JVM 触发 “主动引用” 时,才会执行类的初始化。常见的主动引用场景包括:

  1. 当使用new关键字创建类的实例时(如new User());
  2. 当调用类的静态方法时(如User.getName());
  3. 当访问类的静态变量(非 final)时(如int v = User.classVar);
  4. 当通过反射调用类时(如Class.forName("com.example.User"));
  5. 当执行子类时,父类未初始化(会先触发父类初始化);
  6. 当 JVM 启动时,执行的主类(含main()方法的类)会被初始化。

反之,“被动引用” 不会触发初始化,比如:

  • 引用父类的静态变量(只会初始化父类,不会初始化子类);
  • 引用类的final静态常量(准备阶段已赋值,无需初始化);
  • 通过数组引用类(如User[] users = new User[10],只会创建数组对象,不会初始化 User 类)。

五、总结:类生命周期的核心逻辑

看到这里,我们已经梳理完类生命周期的核心阶段。最后用一张图总结整个流程:

plaintext

加载(读字节流→转元数据→生成Class对象)
    ↓
验证(文件格式→元数据→字节码→符号引用)
    ↓
准备(类变量分配内存→设零值,final直接赋值)
    ↓
解析(符号引用→直接引用,可延迟)
    ↓
初始化(执行<clinit>():静态赋值+静态块,父类优先)
    ↓
使用(创建实例、调用方法等)
    ↓
卸载(类从方法区移除,生命周期结束)

理解类的生命周期,不仅能帮我们搞懂 JVM 的运行机制,更能在实际开发中解决问题:比如排查 “类加载失败” 异常(可能是验证阶段不通过)、理解 “静态变量初始化顺序”(依赖<clinit>()的执行顺序)、优化 “类加载性能”(避免不必要的主动引用)。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐