类的加载过程

写好的代码经过编译变成了字节码,并且可以打包成 Jar 文件。

ruby 复制代码
$ java Hello

运行 java 程序的第一步就是加载 class 文件/或输入流里面包含的字节码。

按照 Java 语言规范和 Java 虚拟机规范的定义, 我们用 "类加载(Class Loading)" 来表示: 将 class/interface 名称映射为 Class 对象的一整个过程。 这个过程还可以划分为更具体的阶段: 加载,链接和初始化(loading, linking and initializing)。

类的生命周期与加载过程

一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。

其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载。

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

关于在什么情况下需要开始类加载过程的第一个阶段"加载",《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行"初始化"(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

    1. 使用new关键字实例化对象的时候。
    2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    3. 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

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

csharp 复制代码
/**
 * 被动使用类字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化
 **/
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

/**
 * 非主动使用类字段演示
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
csharp 复制代码
SuperClass init!
123

上述代码运行之后,不会输出"SubClass init!"。对于静态字段, 只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,所以这点取决于虚拟机的具体实现。对于HotSpot虚拟机来说,可通过-XX: +TraceClassLoading参数观察到此操作是会导致子类加载的。

arduino 复制代码
public class NotInitialization {
    
    /**
     * 被动使用类字段演示二:
     * 通过数组定义来引用类,不会触发此类的初始化
     **/
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

运行之后发现没有输出"SuperClass init!",说明并没有触发类SuperClass的初始化阶段。但是这段代码里面触发了另一个名为"SuperClass"的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

这个类代表了一个元素类型为SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。Java语言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问,而C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

arduino 复制代码
/**
 * 被动使用类字段演示三:
 * 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的
 * 类的初始化
 **/
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
}

/**
 * 非主动使用类字段演示
 **/
class NotInitialization_1 {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}
复制代码
hello world

上述代码运行之后,也没有输出"ConstClass init!",这是因为虽然在Java源码中确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值"hello world"直接存储在NotInitialization_1类的常量池中,以后NotInitialization_1对常量 ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization_1类对自身常量池的引用了。也就是说,实际上NotInitialization_1的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成 Class文件后就已不存在任何联系了。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程, 这点与类是一致的,上面的代码都是用静态语句块"static{}"来输出初始化信息的,而接口中不能使用"static{}"语句块,但编译器仍然会为接口生成"()"类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的六种"有且仅有"需要触发初始化场景中的第三种: 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

加载

查找并加载类的二进制数据

加载"(Loading)阶段是整个"类加载"(Class Loading)过程中的一个阶段

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的。例如"通过一个类的全限定名来获取定义此类的二进制字节流"这条规则,它并没有指明二进制字节流必须得从某个Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。仅仅这一点空隙,Java虚拟机的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础之上,例如:

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  • 从网络中获取,这种场景最典型的应用就是Web Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流。
  • 由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

类加载器并不需要等到某个类被"首次主动使用"时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

连接

验证

确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

文件格式验证:
  • 魔数检查:是否以魔数0xCAFEBABE开头。
  • 版本检查:主、次版本号是否在当前虚拟机处理范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
  • 长度检查
元数据验证:
  • 是否有父类
  • 是否继承了final修饰的类
  • 抽象方法是否有实现
字节码验证:

主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会产生危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作:例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的:例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。

(Halting Problem:通过程序去校验程序逻辑是无法做到绝对准确的------不能通过程序准确的检查出程序是否能在有限时间之内结束运行。)

符号引用验证:

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段------解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当 前类访问。
  • 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都 已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的 类验证措施,以缩短虚拟机类加载的时间。

准备

为类的静态变量分配内存,并将其初始化为默认值

  1. 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值
  2. 用final修饰的static,在编译的时候就会分配好了默认值,准备阶段会显式初始化

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候"类变量在 方法区"就完全是一种对逻辑概念的表述了。

关于准备阶段,还有两个容易产生混淆的概念

  1. 这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

  2. 是这里所说的初始值"通常情况"下是数据类型的零值

假设一个类变量的定义为:

arduino 复制代码
public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行。表7-1列出了Java中所有基本数据类型的零值

上面提到在"通常情况"下初始值是零值,那言外之意是相对的会有某些"特殊情况":如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值。假设上面类变量value的定义修改为:

arduino 复制代码
public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。假设一个类变量的定义为: public static int value = 3;那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。

这里还需要注意如下几点

  • 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过
  • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;
  • 只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
  • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
  • 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为: public static final int value = 3;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中

解析

把类中的符号引用转换为直接引用

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规 范》的Class文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机 的内存中存在。

然后进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接口方法解析。

简单的来说就是我们编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。

在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。

加载一个 class 时, 需要加载所有的 super 类和 super 接口。

  1. 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
  2. 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

反编译 class 文件后可以查看符号引用,下面带# 的就是符号引用

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪 怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。

不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为"动态调用点限定符 (Dynamically-Computed Call Site Specifier)",这里"动态"的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是"静态"的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

初始化

始化阶段就是执行类构造器<clinit>()方法的过程

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及 <clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如代码清单7-5所示。
csharp 复制代码
public class Test { 
    static {
        i = 0; // 给变量复制可以正常编译通过
        System.out.print(i); // 这句编译器会提示"非法向前引用" 
    }
    static int i = 1; 
}
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。
  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下代码清单中,字段B的值将会是2而不是1。
arduino 复制代码
static class Parent {
    public static int A = 1;

    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;

    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}
  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法。
  • Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。代码清单7-7演示了这种场景。
csharp 复制代码
class test {
    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 script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

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

总结
  • 初始化步骤中,主要是对类的静态变量赋予正确的初始值,也就是在声明静态变量时指定的初始化值以及静态代码块中的赋值。本质上就是执行类构造器方法<clinit>()的过程
  • 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 <clinit>
  • 类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 <clinit> 方法的过程。Java 虚拟机会通过加锁来确保类的 <clinit> 方法仅被执行一次。

类的初始化时机

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(比如:Class.forName("xxx.xxx.Test"))
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类
  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)

clinit()

  1. 初始化阶段就是执行类构造器方法<clinit>()的过程
  2. 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法
  3. <clinit>()方法中的指令按语句在源文件中出现的顺序执行
  4. <clinit>()不同于对象构造器方法。(关联:对象构造器是虚拟机视角下的<init>()
  5. 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
  6. 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

IDEA 中安装 JClassLib Bytecode viewer 插件,可以很方便的看字节码。安装过程可以自行百度

<clinit>()<init>() 区别

<clinit>()

Java 类加载的初始化过程中,编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生 <clinit>() 方法。 如果类中没有静态语句和静态代码块,那可以不生成 <clinit>() 方法。

并且 <clinit>() 不需要显式调用父类(接口除外,接口不需要调用父接口的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法 <clinit>(),虚拟机会保证在子类的<clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。

<init>()

对象构造时用以初始化对象的,构造器以及非静态初始化块中的代码。

  • init is the (or one of the) constructor(s) for the instance, and non-static field initialization.
  • clinit are the static initialization blocks for the class, and static field initialization.

上面这两句是Stack Overflow上的解析,很清楚<init>()是instance实例构造器,对非静态变量解析初始化,而<clinit>()是class类构造器对静态变量,静态代码块进行初始化。

Java 编译器就不会生成clinit()方法的场景

  • 场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成clinit()方法
  • 场景2:静态的字段,没有显式的赋值,不会生成clinit()方法
  • 场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成clinit()方法

DEMO

arduino 复制代码
public class ClinitTest2 {
    /**
     * 哪些场景下,Java 编译器就不会生成<clinit>()方法
     */
    //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
    public int num = 1;
    //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
    public static int num1;
    //场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
    public static final int num2 = 1;

    //存在static成员变量或静态代码块就会有clinit()方法生成
    public static  int num3 = 1;
    static{
        System.out.println("加载我了");
    }

    public static void main(String[] args) {
        ClinitTest2 clinitTest2 = new ClinitTest2();
    }

}
结论:

在链接阶段的准备环节赋值的情况:

  • 对于基本数据类型的字段来说,如果使用 static final 修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行
  • 对于 String 来说,如果使用字面量的方式赋值,使用 static final 修饰的话,则显式赋值通常是在链接阶段的准备环节进行

在初始化阶段clinit()中赋值的情况

  • 排除上述的在准备环节赋值的情况之外的情况

最终结论:

  • 使用 static + final 修饰,且显式赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行
  • 使用static修饰的静态变量在初始化阶段clinit()赋值
执行顺序(静态代码块+构造代码块+构造方法)
csharp 复制代码
            public class Parent {
            
                // 初始化clinint()运行,执行一次
                static {
                    System.out.println("父-静态代码块");
                }
                // 实例化运行时调用,每次实例化调用
                {
                    System.out.println("父-构造代码块");
                }
                // 实例化构造时调用,每次实例化调用
                public Parent() {
                    System.out.println("父-构造器");
                }

            }
            
            public class Son extends Parent {

                static {
                    System.out.println("子-静态代码块");
                }

                {
                    System.out.println("子-构造代码块");
                }

                public Son() {
                    System.out.println("子-构造器");
                }

                public static void main(String[] args) {
                    Son zi = new Son();
                    Son zi2 = new Son();

                }

            }
markdown 复制代码
    父-静态代码块
    子-静态代码块
    父-构造代码块
    父-构造器
    子-构造代码块
    子-构造器
    
    父-构造代码块
    父-构造器
    子-构造代码块
    子-构造器

DEMO

csharp 复制代码
public class ClassInitTest {
   private static int num = 11;

   static{
       num = 22;
       number = 20;
       System.out.println(num);
       //System.out.println(number);//报错:非法的前向引用。
   }

   /**
    * 1、linking之prepare: number = 0 --> initial: 20 --> 10
    * 2、这里因为静态代码块出现在声明变量语句前面,所以之前被准备阶段为0的number变量会
    * 首先被初始化为20,再接着被初始化成10(这也是面试时常考的问题哦)
    *
    */
   private static int number = 10;

    public static void main(String[] args) {
        System.out.println(ClassInitTest.num);//22
        System.out.println(ClassInitTest.number);//10
    }
}
less 复制代码
 0 bipush 11
 2 putstatic #3 <com/core/iterator/ClassInitTest.num : I>
 5 bipush 22
 7 putstatic #3 <com/core/iterator/ClassInitTest.num : I>
10 bipush 20
12 putstatic #5 <com/core/iterator/ClassInitTest.number : I>
15 bipush 10
17 putstatic #5 <com/core/iterator/ClassInitTest.number : I>
20 return

DEMO

代码:变量a在准备阶段会赋初始值,但不是1,而是0,在初始化阶段会被赋值为 1

arduino 复制代码
public class HelloApp {
    private static int a = 1;//prepare:a = 0 ---> initial : a = 1


    public static void main(String[] args) {
        System.out.println(a);
    }
csharp 复制代码
        class MyObject {
            static int num1 = 100;
            static int num2 = 100;
            static MyObject myObject = new MyObject();

            public MyObject() {
                num1 = 200;
                num2 = 200;
            }

            @Override
            public String toString() {
                return num1 + "\t" + num2;
            }
        }

        class MyObject2 {
            static int num1 = 100;      
            static MyObject2 myObject2 = new MyObject2(); 
            public MyObject2() {
                num1 = 200;
                num2 = 200;
            }

            static int num2 = 100;      
            @Override
            public String toString() {
                return num1 + "\t" + num2;
            }
        }

        public class ClassLoadingProcessStatic {

            public static void main(String[] args) {
                System.out.println(MyObject.myObject);          
                System.out.println(MyObject2.myObject2);      
            }

        }
复制代码
  200	200
  200	100
第一个输出结果:

准备阶段:

  • num1:0
  • num2:0
  • myObject:null

初始化阶段:

  • num1:0赋值100
  • num2:0赋值100
  • myObject:null变成一个内存地址,同时执行构造方法(init()):num由100赋值为200; num2由100赋值为200

所以最终输出结果: 200 200

第二个输出结果:

准备阶段:

  • num1:0
  • myObject:null
  • num2:0

初始化阶段:

  • num1:0赋值100
  • myObject:null变成一个内存地址,同时执行构造方法:num1由100赋值200;num2由0赋值200 num2:200赋值100

所以最终输出结果: 200 100

第二个输出结果初始化阶段注意:

这里myObject和num2都是静态变量,初始化阶段语句要包括在clinit()方法,即

scss 复制代码
//伪代码
clinit(){
  static MyObject2 myObject2 = new MyObject2(); 
  static int num2 = 100;      
}

num2语句且在myObject实例化后面,所有先实例化调用构造函数,再运行static int num2 = 100; 按照顺序执行linit()方法语句

clinit()线程安全

clinit() 方法的调用,虚拟机必须保证一个类的clinit()在多线程下被同步加载

java 复制代码
   public class DeadThreadTest {
       public static void main(String[] args) {
           Runnable r = () -> {
               System.out.println(Thread.currentThread().getName() + "开始");
               DeadThread dead = new DeadThread();
               System.out.println(Thread.currentThread().getName() + "结束");
           };

           Thread t1 = new Thread(r,"线程1");
           Thread t2 = new Thread(r,"线程2");

           t1.start();
           t2.start();
       }
   }

   class DeadThread{

       static{
           if(true){
               System.out.println(Thread.currentThread().getName() + "初始化当前类");
               while(true){

               }
           }
       }
   }

程序不结束,有一个线程始终等待另一个线程加载完,输出结果如下

markdown 复制代码
    线程1开始
    线程2开始
    线程1初始化当前类

类的加载过程可以分为三个过程:加载、连接、初始化

  • 加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
  • 链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
  • 初始化,则是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。

使用

类访问方法区内的数据结构的接口, 对象是Heap区的数据。

卸载

Java虚拟机将结束生命周期的几种情况

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止
相关推荐
why1518 小时前
腾讯(QQ浏览器)后端开发
开发语言·后端·golang
浪裡遊8 小时前
跨域问题(Cross-Origin Problem)
linux·前端·vue.js·后端·https·sprint
声声codeGrandMaster8 小时前
django之优化分页功能(利用参数共存及封装来实现)
数据库·后端·python·django
呼Lu噜8 小时前
WPF-遵循MVVM框架创建图表的显示【保姆级】
前端·后端·wpf
bing_1589 小时前
为什么选择 Spring Boot? 它是如何简化单个微服务的创建、配置和部署的?
spring boot·后端·微服务
学c真好玩9 小时前
Django创建的应用目录详细解释以及如何操作数据库自动创建表
后端·python·django
Asthenia04129 小时前
GenericObjectPool——重用你的对象
后端
Piper蛋窝9 小时前
Go 1.18 相比 Go 1.17 有哪些值得注意的改动?
后端
excel9 小时前
招幕技术人员
前端·javascript·后端
盖世英雄酱581369 小时前
什么是MCP
后端·程序员