类的生命周期

类的生命周期

  • [1 类的加载时机](#1 类的加载时机)
  • [2 加载阶段](#2 加载阶段)
  • [3 连接阶段](#3 连接阶段)
    • [3.1 验证](#3.1 验证)
    • [3.2 准备](#3.2 准备)
    • [3.3 解析](#3.3 解析)
  • [4 初始化阶段](#4 初始化阶段)
    • [4.1 ClINIT](#4.1 ClINIT)
    • [4.2 总结](#4.2 总结)

1 类的加载时机

类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:

  • 加载
  • 连接,其中又分为验证、准备、解析三个子阶段
  • 初始化
  • 使用
  • 卸载

图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

请注意,这里写的是按部就班地"开始",而不是按部就班地"进行"或按部就班地"完成",强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶

段执行的过程中调用、激活另一个阶段。

关于在什么情况下需要开始类加载过程的第一个阶段"加载",《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。

但是对于初始化阶段《Java 虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行"初始化"(而加载验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    • 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类)虚拟机会先初始化这个主类。
  5. 当使用JDK7新加人的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK8新加人的默认方法(被 default 关键字修饰的接口方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语--"有且只有",这六种场景中的行为称为对一个类型进行主动引用。除此之外所有引用类型的方式都不会触发初始化,称为被动引用。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:

接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块"static{}"来输出初始化信息的,而接口中不能使用"static {}"语句块,但编译器仍然会为接口生成"<clinit>()"类构造器。,用于初始化接口中所定义的成员变量。

接口与类真正有所区别的是前面讲述的六种"有且仅有"需要触发初始化场景中的第三种:

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

2 加载阶段

1、加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。

  • 从本地磁盘上获取文件
  • 运行时通过动态代理生成,比如Spring框架
  • Applet技术通过网络获取字节码文件

2、类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中,方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。

3、Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。

3 连接阶段

连接阶段分为三个子阶段:

  • 验证,验证内容是否满足《Java虚拟机规范》。
  • 准备,给静态变量赋初值。
  • 解析,将常量池中的符号引用替换成指向内存的直接引用。

3.1 验证

验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:

  1. 文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。

  2. 元信息(元数据)验证,例如类必须有父类(super不能为空)。

  3. 字节码验证,验证程序执行指令的语义是合法的,符合逻辑的,比如方法内的指令执行中跳转到不正确的位置。

  4. 符号引用验证,例如是否访问了其他类中private的方法等。

对版本号的验证,在JDK8的源码中如下:

编译文件的主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过。

3.2 准备

准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。

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

如下代码:

java 复制代码
public class Student{

public static int value = 1;

}

在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。

final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。

如下例子中,变量加上final进行修饰,在准备阶段value值就直接变成1了,因为final修饰的变量后续不会发生值的变更。

来看这个案例:

java 复制代码
public class HsdbDemo {
    public static final int i = 2;
    public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException {
        HsdbDemo hsdbDemo = new HsdbDemo();
        System.out.println(i);
        System.in.read();
    }
}

从字节码文件也可以看到,编译器已经确定了该字段指向了常量池中的常量2:

3.3 解析

解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。

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

4 初始化阶段

4.1 ClINIT

初始化阶段会执行字节码文件中 clinit(class init 类的初始化) 方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。

如下代码编译成字节码文件之后,会生成三个方法:

java 复制代码
public class Demo1 {

    public static int value = 1;
    static {
        value = 2;
    }
   
    public static void main(String[] args) {

    }
}
  • init方法,会在对象初始化时执行
  • main方法,主方法
  • clinit方法,类的初始化阶段执行

继续来看clinit方法中的字节码指令:

1、iconst_1,将常量1放入操作数栈。此时栈中只有1这个数。

2、putstatic指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中#2指向了常量池中的静态变量value,在解析阶段会被替换成变量的地址。

3、后两步操作类似,执行value=2,将堆上的value赋值为2。

如果将代码的位置互换:

java 复制代码
public class Demo1 {
    static {
        value = 2;//给变量赋值可以编译通过
        //System.out.print(value);//错误的代码,会提示非法向前引用。
    }
   
    public static int value = 1;
   
    public static void main(String[] args) {

    }
}

字节码指令的位置也会发生变化:

这样初始化结束之后,最终value的值就变成了1而不是2。

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

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

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

示例1:

如下代码的输出结果是什么?

java 复制代码
public class Test1 {
    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");
    }
}

分析步骤:

1、执行main方法之前,先执行clinit指令。

指令会输出D

2、执行main方法的字节码指令。

指令会输出A

3、创建两个对象,会执行两次对象初始化的指令。

这里会输出CB,源代码中输出C这行,被放到了对象初始化的一开始来执行。

所以最后的结果应该是DACBCB

clinit不会执行的几种情况 (说明<cinit>()方法不是必须的)

如下几种情况是不会进行初始化指令执行的:

  1. 无静态代码块且无静态变量赋值语句。

  2. 有静态变量的声明,但是没有赋值语句。

  3. 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。

示例2:

如下代码的输出结果是什么?

java 复制代码
public class Demo01 {
    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,优先初始化父类。
  2. 执行A02的初始化代码,将a赋值为1。
  3. B02初始化,将a赋值为2。

变化

new B02();注释掉会怎么样?

分析步骤:

  1. 访问父类的静态变量,只初始化父类。
  2. 执行A02的初始化代码,将a赋值为1。

补充练习题

分析如下代码执行结果:

java 复制代码
public class Test2 {
    public static void main(String[] args) {
        Test2_A[] arr = new Test2_A[10];

    }
}

class Test2_A {
    static {
        System.out.println("Test2 A的静态代码块运行");
    }
}

数组的创建不会导致数组中元素的类进行初始化。

java 复制代码
public class Test4 {
    public static void main(String[] args) {
        System.out.println(Test4_A.a);
    }
}

class Test4_A {
    public static final int a = Integer.valueOf(1);

    static {
        System.out.println("Test3 A的静态代码块运行");
    }
}

final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。

4.2 总结

  • <clini>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>() 方法中有耗时很长的操作,那就可能造成多个进程阻塞在实际应用中这种阻塞往往是很隐蔽的。

下面的代码是情况3演示。

java 复制代码
/**
 * @author shenyang
 * @version 1.0
 * @info IO_dome
 * @since 2024/4/13 下午7:24
 */
public class CS {

    static class DeadLoopClass{
        static {
            //如果不加 if 会拒绝编译
            //编译器将提示报 Initializer does not complete normally
            if (true){
                System.out.println(Thread.currentThread()+"init DeadLoopClass");
                while (true){

                }
            }

        }
    }

    public static void main(String[] args) {
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread() + " start");
            DeadLoopClass deadLoopClass = new DeadLoopClass();
            System.out.println(Thread.currentThread() + " run over");
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }

}

运行结果如下,一条线程在死循环以模拟长时间操作,另一条线程在阻塞等待:

相关推荐
学到头秃的suhian3 小时前
JVM-类加载机制
java·jvm
NEFU AB-IN10 小时前
Prompt Gen Desktop 管理和迭代你的 Prompt!
java·jvm·prompt
唐古乌梁海15 小时前
【Java】JVM 内存区域划分
java·开发语言·jvm
众俗16 小时前
JVM整理
jvm
echoyu.16 小时前
java源代码、字节码、jvm、jit、aot的关系
java·开发语言·jvm·八股
代码栈上的思考1 天前
JVM中内存管理的策略
java·jvm
thginWalker1 天前
深入浅出 Java 虚拟机之进阶部分
jvm
沐浴露z1 天前
【JVM】详解 线程与协程
java·jvm
thginWalker1 天前
深入浅出 Java 虚拟机之实战部分
jvm
程序员卷卷狗3 天前
JVM 调优实战:从线上问题复盘到精细化内存治理
java·开发语言·jvm