🙊前言:本文章为瑞_系列专栏之《JVM虚拟机》的类的生命周期篇的初始化阶段小节。由于博主是从B站黑马程序员的《JVM虚拟机》学习其相关知识,所以本系列专栏主要是针对该课程进行笔记总结和拓展,文中的部分原理及图解等也是来源于黑马提供的资料,特此注明。本文仅供大家交流、学习及研究使用,禁止用于商业用途,违者必究!

瑞&3l

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运行时数据区

在这里插入图片描述

附:数据类型的初始值表


每一种基本数据类型和引用数据类型都有其初始值(见下表)

数据类型初始值
int0
long0L
short0
char‘\u0000’
byte0
booleanfalse
double0.0
引用数据类型null



本文是博主的粗浅理解,可能存在一些错误或不完善之处,如有遗漏或错误欢迎各位补充,谢谢

  如果觉得这篇文章对您有所帮助的话,请动动小手点波关注💗,你的点赞👍收藏⭐️转发🔗评论📝都是对博主最好的支持~


Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐