瑞_JVM虚拟机_类的生命周期
本文章为瑞_系列专栏之《JVM虚拟机》的类的生命周期篇,本篇章主要介绍类的加载阶段、连接阶段、初始化阶段、使用阶段以及卸载阶段。
文章目录
🙊前言:本文章为瑞_系列专栏之《JVM虚拟机》的类的生命周期篇,本篇章主要介绍类的加载阶段、连接阶段、初始化阶段、使用阶段以及卸载阶段。由于博主是从B站黑马程序员的《JVM虚拟机》学习其相关知识,所以本系列专栏主要是针对该课程进行笔记总结和拓展,文中的部分原理及图解等也是来源于黑马提供的资料,特此注明。本文仅供大家交流、学习及研究使用,禁止用于商业用途,违者必究!
1 JVM虚拟机概述
瑞:请参考《瑞_JVM虚拟机_概述》
2 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程
类的生命周期一般分为五个阶段:加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
瑞:初始化阶段最重要,因为程序员可以干涉
由于连接阶段操作很多,所以,又可以分为七个阶段:加载 ➡️ 验证 ➡️ 准备 ➡️ 解析 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
2.1 加载阶段
加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
瑞:一句话概括:类加载器将类的信息加载到内存中,Java虚拟机在方法区(InstanceKlass)和堆区(java.lang.Class)中各分配一个对象去保存类的信息,程序员一般用到的是java.lang.Class
2.1.1 加载过程
1️⃣ 加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。
程序员可以使用Java代码拓展的不同的渠道
2️⃣ 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。
瑞:注意和3️⃣区分,此处2️⃣的方法区是虚拟概念,方法区是Java虚拟机的规范的一部分,它是一个虚拟的概念,代表的是JVM内存中的一个区域,用于存储类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码等。在不同的JVM实现中,方法区可能有不同的物理表现。例如,在Oracle HotSpot JVM中,方法区在Java 8之前通常由被称为永久代(PermGen space)的内存区域实现,而在Java 8及以后的版本中,它被元空间(Metaspace)所替代。
3️⃣ 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的【方法区】中。
生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息
瑞:注意区分
InstanceKlass
和Class
。
InstanceKlass 是 JVM 内部的一个数据结构,用于存储和管理Java类的元数据信息(如方法表、接口表、字段表等)InstanceKlass是Klass
模型的一部分,主要用于JVM内部操作。
Class 是一个 Java 类,它代表 Java 类在运行时的动态类型信息。每个加载到 JVM 中的 Java 类都有一个对应的 Class 对象,用于提供关于类的类型信息的反射访问。
4️⃣ 同时,Java虚拟机还会在【堆】中生成一份与方法区中数据类似的java.lang.Class对象。
作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)
- 对于开发者来说,只需要访问堆中的Class对象而不需要访问方法区中所有信息。
这样Java虚拟机就能很好地控制开发者访问数据的范围
2.1.2 查看内存中的对象(hsdb工具)
推荐使用 JDK 自带的 hsdb 工具查看Java虚拟机内存信息。工具位于JDK安装目录下lib文件夹中的sa-jdi.jar
中
启动命令:java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
(要进入到jdk中的lib目录)
1️⃣ Hsdb工具测试类
import java.io.IOException;
/**
* Hsdb 测试类
*
* @author LiaoYuXing-Ray
**/
public class HsdbDemo {
public static final int i = 486;
public static void main(String[] args) throws IOException {
HsdbDemo hsdbDemo = new HsdbDemo();
System.in.read(); // 为了测试,不让程序终止
}
}
2️⃣ 运行步骤1 的代码后,打开cmd(管理员权限)窗口,输入jps
命令查看进程的pid(博主的PID为49068,HsdbDemo为Java进程的类名)。进入jdk中的lib目录,执行java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
命令打开hsdb工具。
3️⃣ 选择 File 的 Attach to HosPort Process 选项,输入PID
4️⃣ 选择 Tools 中的 Object Histogram 选项
5️⃣ 找到 HsdbDemo 对象
6️⃣ 可以查看 HsdbDemo 的方法区的 InstaceKlass 以及堆中的 Class 对象以及静态变量的相关信息。
瑞:一句话概括:类加载器将类的信息加载到内存中,Java虚拟机在方法区(InstanceKlass)和堆区(java.lang.Class)中各分配一个对象去保存类的信息,程序员一般用到的是java.lang.Class
2.2 连接阶段
加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
2.2.1 验证
加载 ➡️ 验证 ➡️ 准备 ➡️ 解析 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
- 连接(Linking)阶段的第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。
主要包含如下四部分,具体详见《Java虚拟机规范》:
1️⃣ 文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
瑞:Java字节码文件中,将文件头
CAFEBABE
称为magic魔数
。具体请参考《瑞_JVM虚拟机_概述》
我们以 .class
的 Java 字节码文件为例,通过 NotePad++ 使用十六进制插件HexEditor
查看任意class文件,会发现,头四个字节都是 cafebabe ,如下图所示:
2️⃣ 元信息验证,例如类必须有父类(super不能为空)。
基本信息包含:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口
3️⃣ 验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。
4️⃣ 符号引用验证,例如是否访问了其他类中private的方法等。
Hotspot JDK8中虚拟机源码对版本号检测的代码如下:
瑞:主副版本号都校验,先校验主版本号,再校验副版本号(副版本号是JDK12以后才开始运用)
主副版本号指的是编译字节码文件的JDK版本号,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
瑞:1.2之后大版本号计算方法:主版本号 – 44。比如主版本号52(52 - 44 = 8)就是JDK8
2.2.2 准备(final特殊)★
加载 ➡️ 验证 ➡️ 准备 ➡️ 解析 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
准备阶段为静态变量(static)分配内存并设置初始值
注意:本章涉及到的内存结构只讨论JDK8及之后的版本
瑞:如上,value的初始默认值是0而不是1,因为int的默认值是0,赋值1是在初始化阶段。为什么要赋予初始默认值,因为如果内存有残留的情况下,并且程序员没有给改变量赋值,那直接使用或者打印出来的结果是之前的残留值,这样程序员会很懵逼,所以要先赋予初始默认值,再初始化
- 准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值。
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用数据类型 | null |
特殊情况:final 修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值
2.2.3 解析
加载 ➡️ 验证 ➡️ 准备 ➡️ 解析 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
解析阶段主要是将常量池中的符号引用替换为直接引用
符号引用就是在字节码文件中使用编号来访问常量池中的内容
直接引用不再使用编号,而是使用内存中地址进行访问具体的数据
瑞:直接引用方便而且性能高,由地址直接指向存储值
2.3 初始化阶段<client> ★★★★★
加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
瑞:初始化阶段就是执行static代码块赋值。类的初始化包括执行类的<clinit>()方法,该方法由静态变量显式赋值代码和静态代码块组成,且只执行一次。如果有父类,先加载和初始化父类,再执行子类的<clinit>()方法。一个已经被初始化的类通常不会被再次初始化,这是因为Java虚拟机的设计旨在保证类加载和初始化的效率,避免重复的操作。
连接阶段中非 final 修饰的静态变量(static)保存的是初始值(上图中 int 数据类型的初始值为 0,点我查看数据类型的初始值表),而且是保存在堆区里面的class对象中。但是最终这个值应该是1(如上图),当这个变量存储的值为1的时候,我们才能拿去使用。而这个赋值为1的操作就是在初始化阶段完成的。
初始化阶段会执行静态代码块中的代码,并为静态变量赋值
初始化阶段会执行字节码文件中 clinit 部分的字节码指令
瑞:clinit可以拆解为: cl —— class 代表类,init代表初始化。即类的初始化
2.3.1 案例一
2.3.1.1 案例描述
【案例】静态变量赋值
在下面的案例代码中,声明了一个静态变量 value 赋值为 1 ,在静态代码块中将 value 值赋为 2 ,如下:
public class RayTest {
public static int value = 1;
static {
value = 2;
}
public static void main(String[] args) {
System.out.println("value = " + value);
}
}
执行后运行结果如下
value = 2
使用 jclasslib 工具查看字节码文件的方法信息,见下图
瑞:jclasslib 工具的安装可以参考《瑞_JVM虚拟机_概述》
1️⃣ 其中 [0] <init>虽然我们没有写构造方法,但是编译器会帮我们自动生成默认无参构造方法
2️⃣ 其中 [1] main 主方法
3️⃣ 其中 [2] <clinit> 初始化阶段执行
<clinit>方法中的字节码指令如下:
0 iconst_1
1 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
4 iconst_2
5 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
8 return
《Java虚拟机规范》中putstatic
指令是给类中的静态字段赋值,值从操作数栈中获取。iconst_常量值
指令:将常量值放到操作数栈中(临时存放),生成常量。
putstatic
指令说明原文见下图⬇️
瑞:《Java虚拟机规范》官网地址:https://docs.oracle.com/javase/specs/index.html
2.3.1.2 解析字节码指令
所以字节码指令执行步骤如下
0 iconst_1
1 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
4 iconst_2
5 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
8 return
1️⃣ 编号0行:将常量1放入操作数栈中
2️⃣ 编号1行:将操作数栈中的 1 赋值给常量池中编号为#7(你不一定是#7)的变量即RayTest.value
,由于在连接阶段(准备阶段)中已经对静态变量RayTest.value
分配内存并设置初始值 0 ,所以RayTest.value
由 0 变为了 1
3️⃣ 编号4行:将常量2放入操作数栈中
4️⃣ 编号5行:将操作数栈中的 2 赋值给常量池中编号为#7(你不一定是#7)的变量即RayTest.value
,所以RayTest.value
由 1 变为了 2
2.3.2 案例二
将案例一的两句静态代码对调顺序
public class RayTest {
static {
value = 2;
}
public static int value = 1;
public static void main(String[] args) {
System.out.println("value = " + value);
}
}
执行后运行结果如下
value = 1
<clinit>方法中的字节码指令如下:
0 iconst_2
1 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
4 iconst_1
5 putstatic #7 <com/ray/onlytest/at2024/t03/t15/RayTest.value : I>
8 return
连接阶段 value 默认值设为了 0 ,然后在初始化阶段将 2 赋值给 value,再将 1 赋值给 value
2.3.3 小结
clinit方法中字节码指令的执行顺序与Java中编写的顺序是一致的
2.3.4 代码中触发类的初始化的方式
2.3.4.0 设置打印出加载并初始化的类
添加
-XX:+TraceClassLoading
参数可以打印出加载并初始化的类
2.3.4.1 方式一
1️⃣ 访问一个类的静态变量或者静态方法,注意如果这个变量是 final 修饰的并且等号右边是常量则不会触发类的初始化阶段(连接阶段就会直接给 final 修饰的常量赋值)。
public class RayTest {
public static void main(String[] args) {
int i = RayTest2.i;
System.out.println(i);
}
}
class RayTest2{
static {
System.out.println("init...");
i = 486;
}
public static int i = 0;
}
运行后发现,RayTest2初始化了(打印了init…)
将上面代码中RayTest2.i
修改为 final 修饰
public class RayTest {
public static void main(String[] args) {
int i = RayTest2.i;
System.out.println(i);
}
}
class RayTest2{
static {
System.out.println("init...");
// i = 486;
}
public static final int i = 486;
}
运行后发现,RayTest2并没有初始化(未打印init…)修改后RayTest2.i
这个变量是 final 修饰的并且等号右边是常量486,则不会触发类的初始化阶段
2.3.4.2 方式二
2️⃣ 调用Class.forName(String className)。
public class RayTest {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> clazz = Class.forName("com.ray.onlytest.at2024.t03.t16.RayTest2");
}
}
class RayTest2{
static {
System.out.println("init...");
}
}
执行代码后,发现无论是否使用了RayTest2
对象,只要调用了Class.forName(String className)
方法,都会执行类的初始化过程
2.3.4.3 方式三
3️⃣ new一个该类的对象时。
public class RayTest {
static {
System.out.println("RayTest初始化了...");
}
public static void main(String[] args) {
new RayTest2();
}
}
class RayTest2{
static {
System.out.println("RayTest2初始化了...");
}
}
执行代码后,发现先初始化main方法的当前类,即RayTest
,然后初始化 new 的类RayTest2
2.3.4.4 方式四
4️⃣ 执行Main方法的当前类。
验证请见方法三,一样的代码和结论
2.3.5 大厂面试题
2.3.5.1 题目
某大型互联网公司2019笔试题:以下代码的运行结果是DACBCB(换行省略)
public class Test1 {
// DACBCB
public static void main(String[] args) {
System.out.println("A");
new Test1();
new Test1();
}
public Test1(){
System.out.println("B");
}
{
System.out.println("C");
}
static {
System.out.println("D");
}
}
2.3.5.2 分析
clinit方法字节码指令如下:
0 getstatic #1 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #9 <D>
5 invokevirtual #3 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return
3 ldc #9 <D>
执行的是:从常量池中将字符串D加载到操作数栈
5 invokevirtual #3 <java/io/PrintStream.println : (Ljava/lang/String;)V>
执行的是:调用 println 方法打印操作数栈上的内容
所以在执行main方法前,先初始化Test1的初始化方法,输出D,然后再执行main方法中的第一行System.out.println("A");
输出A
下一步执行main方法中的第一个new Test1();
由于Test1类已经被初始化过了,所以Test1不会被再次加载初始化(不会执行static),new对象时会执行构造方法,构造方法的字节码指令init方法如下
0 aload_0
1 invokespecial #6 <java/lang/Object.<init> : ()V>
4 getstatic #1 <java/lang/System.out : Ljava/io/PrintStream;>
7 ldc #7 <C>
9 invokevirtual #3 <java/io/PrintStream.println : (Ljava/lang/String;)V>
12 getstatic #1 <java/lang/System.out : Ljava/io/PrintStream;>
15 ldc #8 <B>
17 invokevirtual #3 <java/io/PrintStream.println : (Ljava/lang/String;)V>
20 return
可以看到System.out.println("C");
在编译之后,会在构造方法中执行❗️ 且顺序在System.out.println("B");
执行之前。4、7、9执行的是打印C,而12、15、17执行的是打印B,所以当前输出:DACB
瑞:{},没有static的{}代码块是实例代码块(instance block),用于初始化对象的属性,在编译后会在构造方法内首先被执行
同理,下一步执行main方法中的第二个new Test1();
,会继续打印CB,所以最终结果是输出DACBCB
2.3.5.3 结论
1️⃣ 因为main属于类的初始化阶段,首先执行static{},输出D。
2️⃣ 然后main的第一行输出A
3️⃣ new属于类的初始化,原本应该要执行static{},但是由于static{}只执行一次(Test1已经在步骤1️⃣被初始化了),所以执行{}和构造方法,{}没有static的{}代码块是实例代码块(instance block),用于初始化对象的属性。当创建类的对象时,实例代码块会被执行一次,输出C,然后执行构造方法中的语句,输出B,然后重复这个过程,输出CB。所以最终输出:DACBCB
通过这个案例我们得出结论:一个类被加载并初始化一般只会执行 1 次,而构造方法由于可以创建多个对象,每一次创建该对象都会执行一次构造方法。
2.3.6 <clinit>指令的特殊情况
clinit指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行
瑞:初始化阶段不一定存在。如果虚拟机认为初始化阶段可以什么都不用做(以下三种情况),则不会执行初始化指令
2.3.6.1 情况一
1️⃣ 无静态代码块且无静态变量赋值语句
public class RayTest {
public static void main(String[] args) {
}
}
使用 jclasslib 工具可以看到,直接就没有<clinit>方法,说明类的初始化阶段什么操作都没有进行
2.3.6.2 情况二
2️⃣ 有静态变量的声明,但是没有赋值语句
public class RayTest {
public static int i;
public static void main(String[] args) {
}
}
使用 jclasslib 工具可以看到,情况2仍然没有<clinit>方法
2.3.6.3 情况三
3️⃣ 静态变量的定义使用final关键字且右边是常量,这类变量会在准备阶段直接进行初始化
public class RayTest {
public final static int i = 10;
public static void main(String[] args) {
}
}
使用 jclasslib 工具可以看到,情况3仍然没有<clinit>方法
2.3.7 继承关系下的初始化情况
1️⃣ 直接访问父类的静态变量,不会触发子类的初始化
2️⃣ 子类的初始化clinit调用之前,会先调用父类的clinit初始化方法
来看一个案例:某大型互联网公司2021笔试题,以下代码的输出结果是 2
public class RayTest {
public static void main(String[] args) {
new B02();
System.out.println(B02.a);
}
}
class A02 {
static int a = 0;
static {
a = 1;
}
}
class B02 extends A02 {
static {
a = 2;
}
}
分析:
1️⃣ 调用new创建对象,需要初始化B02,但由于B02继承于A02,所以要优先初始化父类
2️⃣ 初始化父类后,a = 1;
3️⃣ 初始化之类后,a = 2;
所以最后输出2
案例修改
将new B02();
注释后,输出结果为1,成功验证情况1️⃣ 访问父类的静态变量,只初始化父类
public class RayTest {
public static void main(String[] args) {
// new B02();
System.out.println(B02.a);
}
}
class A02 {
static int a = 0;
static {
a = 1;
}
}
class B02 extends A02 {
static {
a = 2;
}
}
2.3.8 数组的创建与初始化
数组的创建不会导致数组中元素的类进行初始化
如以下代码运行结果为空,说明RayTest_A类没有执行初始化
public class RayTest {
public static void main(String[] args) {
RayTest_A[] arr = new RayTest_A[10];
}
}
class RayTest_A {
static {
System.out.println("RayTest_A的静态代码块运行了...");
}
}
2.3.9 final修饰静态变量赋值非常量下的初始化(特殊情况)
final修饰的静态变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化
瑞:如果一个静态变量使用了 final 关键词修饰,并且等号的右边是常量,则这个变量会在准备阶段直接进行初始化。但如果 final 修饰的静态变量的右边不是常量,是需要执行指令才能得出结果情况下(执行方法),由于赋值内容需要执行指令,所以该变量会在执行 clinit 初始化阶段方法中才进行初始化
public class RayTest {
public static void main(String[] args) {
System.out.println(RayTest_A.a);
}
}
class RayTest_A {
public static final int a = Integer.valueOf(1);
static {
System.out.println("RayTest_A的静态代码块运行了...");
}
}
运行代码后输出结果如下
RayTest_A的静态代码块运行了...
1
2.4 使用阶段
加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
当类被加载、连接和初始化后,它就可以被Java程序使用了。在这个阶段,类被实际应用,比如类的实例可以被创建,类的方法可以被调用,类的字段可以被访问等。
2.5 卸载阶段
加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
当类不再被需要时,它会被从方法区中卸载。类的卸载是指类的Class对象被垃圾回收器回收,Java堆中也不存在该类的任何实例。
当满足以下条件时,类可以被卸载:
1️⃣ 该类所有的实例都已经被垃圾回收,也就是说Java堆中不存在该类的任何实例。
2️⃣ 加载该类的ClassLoader已经被回收。
3️⃣ 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
附:JDK1.8运行时数据区
附:数据类型的初始值表
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用数据类型 | null |
如果觉得这篇文章对您有所帮助的话,请动动小手点波关注💗,你的点赞👍收藏⭐️转发🔗评论📝都是对博主最好的支持~
更多推荐
所有评论(0)