类的生命周期如下图所示
类加载的全过程包括加载,验证,准备,解析,初始化这五个阶段。
本篇文章我们来了解Java虚拟机中这五个阶段的具体过程。
加载(Loading)
“加载(Loading)”和“类加载(Class Loading)”是不同的两个概念,前者是后者的一部分。在加载阶段,虚拟机需要完成以下三个事情。
①通过一个类的全限定名来获取其定义的二进制字节流。
②将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
③在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口。
加载阶段可控性最强,在该阶段,开发人员可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段与连接阶段的部分内容是交叉进行的,加载阶段还没完成,连接阶段就可能已经开始了,但是这两个阶段仍然保持固定的顺序开始。
连接(Linking)
验证
验证是连接阶段的第一步,这一步的目的是为了保证Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会影响虚拟机自身的安全性。
验证阶段大致会完成4个阶段的检验动作:
①文件格式验证:验证字节流是否符合Class文件格式的规范,例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型(检查常量tag标志)、指向常量的各种索引值是否有指向不存在的变量或不符合类型的变量等等。
这个检验动作是基于二进制字节流进行的,目的是保证输入的字节流能够正确的被解析并存储到方法区内。
②元数据验证:对字节码描述的信息进行语义分析,以确保描述信息符合Java语言规范,例如:除了 java.lang.Object之外,这个类是否有父类;该类的父类是否继承了不可被继承的类(final 类);若不是抽象类,那么是否全部实现父类或者接口中的方法等等。
这个检验动作是基于方法区的存储结构进行的,目的对类的元数据信息进行语义检验,以符合Java语言规范。
③字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作;保证方法体的类型转换是有效的等等。
这个检验动作是基于方法区的存储结构进行的,该阶段是最复杂的一个阶段,对类的方法体进行分析校验,保证虚拟机的安全性。
④符号引用验证:确保解析动作能正确执行。发生在虚拟机将符号引用转为直接引用的时候,这个动作其实是发生在解析阶段。例如:能否通过类的全限定名找到对应的类。
该验证非常重要,但是不是必须的,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备(Preparation)
准备阶段是正式为类变量在方法区分配内存并设置类变量初始值的阶段。
对于该阶段有以下注意点:
①这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
②这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
public static int value=3;
变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的 publicstatic指令是在程序编译后,存放于类构造器 <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常量在编译期就将其结果放入了调用它的类的常量池中
解析(Resolution)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行。
关于符号引用与直接引用的解释
符号引用:就是一组符号来描述目标,可以是任何字面量,只要使用时能无歧义的定位到目标就行,与虚拟机的内存布局无关。
直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄,与虚拟机的内存布局有关。
初始化(Initialization)
类加载过程的最后一步,前面的类加载过程中,除了加载阶段可以由用户自定义类加载器参与外,其余阶段都完全由虚拟机主导和控制。在这个阶段,才开始真正的执行Java代码。
准备阶段会进行设置类变量初始值,在初始化阶段,会按照开发人员自己定义的数值去初始化类变量和其他资源,也就是说初始化阶段是执行类构造器<clinit>方法的过程。
区别于 <init>:其实就是构造函数,在生成class文件时,编译器会在构造函数中添加一些代码,在本类的构造函数中,会最优先调用父类的<init>函数,接着执行剩余构造函数中剩余的代码
什么叫<clinit>()方法与它的注意点
①<clinit>()方法是由编译器自动收集类中所有的类变量的赋值动作和静态语句块(static{})中的语句合并而产生的。编译器的收集顺序是由语句的出现顺序决定,比如静态语句块(static{})只能访问定义在它之前的变量,定义在它之后的变量只能参与赋值,不能进行访问。比如下面的代码
class Demo{
static {
i = 0;//可以进行赋值
System.out.println(i); //这里无法编译成功,提示“非法向前引用”
}
static int i = 0;
}
②<clinit>()方法与类的实例构造函数<init>() 方法不同,它不需要显示的调用父类的构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,因此第一个被虚拟机执行的<clinit>()方法肯定是java.lang.object。
③由于父类的<clinit>()方法比子类的更先执行完毕,因此父类的静态语句块要优先于子类操作。
④并不是每个类或接口都必须有<clinit>()方法,如果这个类或接口中并没有静态语句块,也没有对变量的赋值操作。
⑤接口与类一样都会生成 <clinit>()方法,但是在接口中并不要求父接口全部都完成初始化,只有在真正使用到父接口的时候它才会被初始化(比如引用接口中定义的常量),另外接口的实现类在初始化时一样不会先执行接口的 <clinit>()方法。
⑥虚拟机会保证 <clinit>()方法在多线程环境下会被正确的加锁,同步。因此若一个类的 <clinit>()方法执行时需要消耗大量时间,将会引起线程阻塞。在其他线程阻塞的情况下,强制将占用 <clinit>()方法的线程退出,再将其他线程唤醒,这些线程也不会再进入到 <clinit>()方法中。同一个类加载器下,一个类型只会被初始化一次。
结束生命周期
在如下几种情况下,Java虚拟机将结束生命周期
- 执行了 System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类的加载过程各阶段对比