返回文章列表
JVM虚拟机
JVM类加载类生命周期

03类的生命周期

类的生命周期描述了一个类加载、使用、卸载的整个过程,共分为五个阶段:加载(Loading)、连接(Linking)、初始化(Initialization)、使用(Using)、卸载(Unloading)。其中连接阶段又细分为验证、准备、解析三个子阶段。类的生命周期本身就是一个高频面试题,生命周期中的初始化阶段更是频繁出现在大厂的笔试中,同时也是运行时常量池、多态原理、类加载器等大量后续知识点的基础。本文按照「加载 → 连接 → 初始化 → 使用与卸载」的顺序展开,类的卸载会在后续垃圾回收篇中讲解。

1.加载

加载阶段的第一步,是类加载器根据类的全限定名,通过不同渠道以二进制流的方式获取字节码信息。常见的渠道有:

  • 磁盘上的字节码文件(最常见,就是 javac 编译后的 class 文件);
  • 程序运行时动态代理生成的内容(SpringBoot 等框架大量使用);
  • 通过网络传输的类(早期的 Applet 技术);
  • 本地文件。

程序员可以通过 Java 代码拓展这些渠道,比如有些公司需要把类保存到数据库中持久化,就是通过对加载阶段进行拓展实现的。

类加载器在加载完类之后,Java 虚拟机会将字节码中的信息保存到内存的方法区中,生成一个 InstanceKlass 对象,保存类的所有信息,里面包括基本信息、常量池、字段、方法,以及虚方法表等用于实现多态的信息。

同时,Java 虚拟机还会在堆中生成一份与方法区中数据类似的 java.lang.Class 对象。因为 InstanceKlass 对象是 C++ 编写的,无法通过 Java 代码直接获取到,所以在堆中创建了这个 Class 对象,方便程序员通过 Java 代码获取类的信息,以及存储静态字段的数据(JDK 8 及之后)。对于开发者来说,只需要访问堆中的 Class 对象而不需要访问方法区中的全部信息,这样 Java 虚拟机就能很好地控制开发者访问数据的范围。

加载阶段:方法区的 InstanceKlass 与堆中的 Class 对象

推荐使用 JDK 自带的 hsdb 工具查看 Java 虚拟机内存信息,可以直观看到方法区中的 InstanceKlass 和堆中的 Class 对象。该工具位于 JDK 安装目录下 lib 文件夹中的 sa-jdi.jar,启动命令为:

java -cp sa-jdi.jar sun.jvm.hotspot.HSDB

不同版本的启动方法略有差异,高版本(JDK 17)可以直接到 JDK 的 bin 目录,通过 cmd 命令行输入 jhsdb hsdb 来启动。

使用 hsdb 工具查看 Java 虚拟机内存信息

2.连接

连接分为验证、准备、解析三个阶段。

2.1 验证

连接阶段的第一个环节是验证,主要目的是检测 Java 字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。 主要包含如下四部分,具体详见《Java虚拟机规范》:

  1. 文件格式验证,比如文件是否以 0xCAFEBABE 开头,主次版本号是否满足当前 Java 虚拟机版本要求。
  2. 元信息验证,例如类必须有父类(super 不能为空)。
  3. 字节码指令语义验证,比如方法内的指令执行到一半不能强行跳转到其他方法中去。
  4. 符号引用验证,例如是否访问了其他类中 private 的方法等。

以 Hotspot JDK 8 中虚拟机源码对版本号检测的代码为例:主版本号不能高于运行环境的主版本号;如果主版本号相等,副版本号也不能超过。JDK 8 中支持的主版本号范围是 45–52(45 代表 JDK 1.0,52 代表 JDK 8),副版本号 JDK 未使用、固定为 0。

Hotspot JDK 8 中版本号检测的源码逻辑

2.2 准备

准备阶段为静态变量(static 修饰的变量)分配内存并设置初始值。本章涉及到的内存结构只讨论 JDK 8 及之后的版本,8 之前的版本后续章节详述。

public class Student {
    public static int value = 1;
}

例如上面的 Student 类,在准备阶段只会为 value 分配内存并赋初始值 0,而不会执行 = 1 这部分赋值——这部分要等到初始化阶段才会执行。

准备阶段为静态变量分配内存并赋初始值

准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值:

数据类型 初始值
int 0
long 0L
short 0
char '\u0000'
byte 0
boolean false
double 0.0
引用数据类型 null

各基本数据类型和引用数据类型的初始值

需要注意的是,final 修饰的基本数据类型的静态变量,准备阶段会直接将代码中的值进行赋值,而不会使用上述默认初始值。

public class Student {
    public static final int value = 1;
}

此时 value 在准备阶段就会被赋值为 1。

final 修饰的静态变量在准备阶段直接赋值

2.3 解析

解析阶段主要是将常量池中的符号引用替换为直接引用。

  • 符号引用就是在字节码文件中使用编号来访问常量池中的内容。这种引用与内存布局无关,在字节码文件中是固定的。

常量池中的符号引用

  • 直接引用不再使用编号,而是使用内存中的地址进行访问,指向具体的数据。

解析阶段将符号引用替换为直接引用

3.初始化阶段

初始化阶段会执行静态代码块中的代码,并为静态变量赋值。初始化阶段会执行字节码文件中的 clinit 部分的字节码指令。clinit 方法是由编译器自动收集类中所有静态变量的赋值语句和静态代码块的语句合并产生的,clinit 方法中的执行顺序与 Java 中编写的顺序是一致的。

初始化阶段执行 clinit 部分的字节码指令

以 public static int value = 1; 为例,准备阶段 value 被赋值为 0,到了初始化阶段,clinit 中的字节码指令会把常量 1 放入操作数栈,再把栈中的值设置到静态变量 value 中,最终 value 变成 1。如果在源码中连续对 value 赋了两次值(比如先 = 1 再 = 2),clinit 会按编写顺序依次执行,最终 value 为 2;把这两句话对调顺序,最终结果也会随之改变。

以下几种方式会导致类的初始化:

  • 访问一个类的静态变量或者静态方法(注意:变量是 final 修饰的并且等号右边是常量,不会触发初始化)。
  • 调用 Class.forName(String className) 方法。
  • new 一个类的对象时。
  • 执行 Main 方法中的当前类。

可以通过添加 -XX:+TraceClassLoading 参数打印出加载并初始化的类。

触发类初始化的几种方式

大厂笔试题案例

某大型互联网公司 2019 年笔试题:分析下面代码的运行结果。

进入 main 函数,触发初始化,打印 D,然后正常执行打印 A,然后 new 了 Test1,触发 Test1 的初始化,打印 C,再打印 B,第二次 new,再次打印 CB,最终结果是 DACBCB。这道题的关键在于:main 方法所在的类会先初始化(输出 D),随后每次 new 对象都会触发该类的初始化(如果之前未初始化过)。

clinit 不会出现的情况

clinit 指令在特定情况下不会出现,以下几种情况是不会进行初始化指令执行的:

  1. 无静态代码块且无静态变量赋值语句。
  2. 有静态变量的声明,但是没有赋值语句。
  3. 静态变量的定义使用 final 关键字,这类变量会在准备阶段直接进行初始化(除非赋值的内容需要执行方法才能得出结果,此时仍会执行 clinit)。

另外两个重要的细节:

  • 直接访问父类的静态变量,不会触发子类的初始化。
  • 子类的初始化 clinit 调用之前,会先调用父类的 clinit 初始化方法。

某大型互联网公司 2021 年笔试题:调用 new B02(),需要初始化 B02,但 B02 继承自 A02,所以会先调用 A02 的初始化,再调用 B02 的初始化,最终 a 等于 2。如果把 new B02() 去掉、改为只访问父类的静态变量,那么只会初始化父类 A02,a 等于 1。

父类 clinit 优先于子类 clinit 执行

还有一些容易踩坑的边界情况:

  • 数组的创建不会导致数组中元素的类进行初始化,比如 Test1[] arr = new Test1[10]; 不会触发 Test1 的初始化。
  • final 修饰的变量如果赋值的内容需要执行指令才能得出结果(比如方法返回值),会执行 clinit 方法进行初始化;只有等号右边是常量时才不会触发。

4.使用和卸载

使用阶段不用细讲,就是在 Java 代码运行中使用了这些类。卸载阶段会在后续的垃圾回收部分讲解。

回顾类的生命周期,几个要点值得牢记:

  1. 加载:根据类的全限定名把字节码文件的内容加载并转换成合适的数据放入内存中,存放在方法区(InstanceKlass)和堆(java.lang.Class)上。
  2. 连接-验证:魔数、版本号等验证,一般不需要程序员关注。
  3. 连接-准备:为静态变量分配内存并设置初始值(final 修饰的基本类型静态变量直接赋值)。
  4. 连接-解析:将常量池中的符号引用(编号)替换为直接引用(内存地址)。
  5. 初始化:执行静态代码块和静态变量的赋值,触发时机有访问静态成员、Class.forName、new 对象、main 方法当前类等;直接访问父类静态变量不会触发子类初始化,子类 clinit 之前会先调用父类 clinit。