一个类的生命周期
一个类的生命周期,有如下七个阶段:
其中加载、验证、准备、初始化和卸载这五个阶段发生的顺序是确定的,但是对于解析阶段来说则不一定,因为它可以发生在初始化阶段之后,这样做是为了支持 Java 语言的运行时绑定特征(也称为动态绑定,大白话就是程序会在运行的时候去选择调用哪个方法)。
加载
一个类什么时候开始加载,虚拟机规范并没有强制进行约束,而是交给虚拟机的具体实现来掌控。
Java 虚拟机的实现都是使用的懒加载(就是什么时候需要用到这个类我才去加载),并不是说一个 jar 文件里面有几百个类,而我只用了其中几个类,我还要把所有的类全部加载进来。当然你可以自己写一个 JVM 这么做,虽然这样做很沙雕。
在加载阶段中,虚拟机要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的类静态存储结构转化为方法区的运行时数据结构。
- 在堆中生成一个代表这个类的 java.lang.Class 对象(这样便可以通过该对象访问步骤2中方法区的那些数据结构)。
注意,获取二进制字节流的方式除了从某个 Class 文件中获取,还可以通过以下方式:
- 从网络中获取
- 从 ZIP 、JAR、EAR、WRA等不同格式文件中读取
- 从数据库中读取
- 运行时计算生成(例如动态代理技术)
连接
验证:确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中的信息符合当前虚拟机要求的规范及安全。从整体上看,验证阶段大致会完成以下4个阶段的检验动作:
文件格式验证:
验证字节流是否符合 Class 文件格式的规范;例如:
- 是否以 0xCAFEBABE 开头
- 主次版本号是否在当前虚拟机的处理范围之内
- 常量池中的常量是否有不被支持的类型(检查常量 tag 标志)
- 指向常量的各种索引值中是否有指向不存在的常量
- Class 文件中各个部分及文件本身是否有被篡改
- …
总而言之,这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区进行的,不会再直接读取、操作字节流了。
元数据验证:
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;例如:
- 这个类是否有父类,因为除了 java.lang.Object 之外,所有的类都有父类
- 这类是否继承了不允许被继承的类(被final 修饰的类)
- 如果这个类不是抽象类,是否实现类父类或者接口中要求实现的所有方法
- …
字节码验证:
这个阶段是整个验证过程中最复杂的一个阶段,主要作用是通过数据流和控制流进行分析,从而确定程序语义是合法的、符合逻辑的,继而保证被校验类的方法在运行时不会作出危害虚拟机的行为,例如:
- 保证任何跳转指令都不会跳转到方法体之外的字节码指令上
- 保证方法体中的类型转换总是有效的
- …
符号引用验证:
这个阶段发生在虚拟机将符号引用(JVM 并不知道引入的其他对象在哪里,所以就用唯一符号来代替)转化为直接引用(内存地址)的时候,这个转化动作将在连接的第三个阶段-解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,大白话来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。例如:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述付及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性(public、private、protected)
- …
符号引用验证的主要目的是确保解析阶段能正常执行。
总结一下:验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证没有问题,那么可以考虑采用 -Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备:
准备阶段是正式为类变量(static 修饰的变量)分配内存并设置类变量初始值的阶段,这些类变量所使用的内存都将在方法区中分配。这个阶段有以下两点需要注意:
1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
2、这里设置类变量的初始值通常情况下是数据类型默认的零值(如0、0L、null、true),而不是被在Java代码中被显式地赋予的值。举个例子说明:
//定义一个a变量
public static int a = 666;
// 变量a在准备阶段过后的初始值为0,并不是666
// 因为这个时候还没有开始执行任何java方法
// 把a赋值为666是初始化环节要做的事情
这里还需要注意如下几点:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值。
- 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将会根据对应的数据类型而被赋予默认的零值。
- 如果变量同时被 final 和 static 修饰,那么在准备阶段常量就会被初始化为指定的值。我们可以这样理解:这个常量在编译期就将其值放入了调用它的那个类的常量池中。
解析:
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用就是用一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。(其实符号引用和直接引用就好比是key-value的映射关系)
解析动作主要针对以下7类符号引用进行:
- 类或接口
- 字段
- 类方法
- 接口方法
- 方法类型
- 方法句柄
- 调用点限定符
初始化
初始化是类加载过程的最后一个步骤,直到这一阶段虚拟机才真正开始执行类中编写的代码。
初始化就是执行一个class中的static{}语句和所有类变量的赋值操作(对应字节码就是clinit方法)。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit >()方法。
虚拟机规范规定了有且只有6种情况必须立即对类进行初始化:
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候
- 调用类的静态方法
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK7 新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、
REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句
柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 - 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方
法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
使用
使用阶段包括主动引用(初始化阶段所做的事情就是主动引用)和被动引用,需要注意的是:被动引用不会引起类的初始化。
卸载
一个类什么时候结束生命周期,取决于它的 Class 对象何时结束生命周期。需要注意的是:由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。
所以类在使用阶段完成后,如果满足下面这些情况,就会被卸载掉:
- 加载该类的类加载器已经被回收了
- jvm 堆中不存在该类的任何实例了
- 该类对应的 Class 对象没有被引用了