文章目录
- 1 JVM虚拟机概述
- 2 类的生命周期
- 2.1 加载阶段
- 2.2 连接阶段
- 2.3 初始化阶段\<client> ★★★★★
- 2.3.1 案例一
- 2.3.1.1 案例描述
- 2.3.1.2 解析字节码指令
- 2.3.2 案例二
- 2.3.3 小结
- 2.3.4 代码中触发类的初始化的方式
- 2.3.4.0 设置打印出加载并初始化的类
- 2.3.4.1 方式一
- 2.3.4.2 方式二
- 2.3.4.3 方式三
- 2.3.4.4 方式四
- 2.3.5 大厂面试题
- 2.3.5.1 题目
- 2.3.5.2 分析
- 2.3.5.3 结论
- 2.3.6 \<clinit>指令的特殊情况
- 2.3.6.1 情况一
- 2.3.6.2 情况二
- 2.3.6.3 情况三
- 2.3.7 继承关系下的初始化情况
- 2.3.8 数组的创建与初始化
- 2.3.9 final修饰静态变量赋值非常量下的初始化(特殊情况)
- 2.4 使用阶段
- 2.5 卸载阶段
- 附:JDK1.8运行时数据区
- 附:数据类型的初始值表
🙊前言:本文章为瑞_系列专栏之《JVM虚拟机》的类的生命周期篇的初始化阶段小节。由于博主是从B站黑马程序员的《JVM虚拟机》学习其相关知识,所以本系列专栏主要是针对该课程进行笔记总结和拓展,文中的部分原理及图解等也是来源于黑马提供的资料,特此注明。本文仅供大家交流、学习及研究使用,禁止用于商业用途,违者必究!
1 JVM虚拟机概述
瑞:请参考《瑞_JVM虚拟机_概述》
2 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程
类的生命周期一般分为五个阶段:加载 ➡️ 连接 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
瑞:初始化阶段最重要,因为程序员可以干涉
由于连接阶段操作很多,所以,又可以分为七个阶段:加载 ➡️ 验证 ➡️ 准备 ➡️ 解析 ➡️ 初始化 ➡️ 使用 ➡️ 卸载
2.1 加载阶段
瑞:请参考《瑞_JVM虚拟机_类的生命周期》
2.2 连接阶段
瑞:请参考《瑞_JVM虚拟机_类的生命周期》
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 使用阶段
瑞:请参考《瑞_JVM虚拟机_类的生命周期》
2.5 卸载阶段
瑞:请参考《瑞_JVM虚拟机_类的生命周期》
附:JDK1.8运行时数据区
附:数据类型的初始值表
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用数据类型 | null |
如果觉得这篇文章对您有所帮助的话,请动动小手点波关注💗,你的点赞👍收藏⭐️转发🔗评论📝都是对博主最好的支持~