Java虚拟机-类加载机制
1 类加载机制
一个.java文件在编译后会形成相应的一个或多个Class文件(若一个类中含有内部类,则编译后会产生多个Class文件),但这些Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能被运行和使用。虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程就是虚拟机的 类加载机制。
2 类的生命周期
Java类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 卸载(Unloading)七个阶段。其中准备、验证、解析3个部分统称为连接(Linking)
类加载过程.gif加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。每个阶段通常都是相互交叉地混合式进行的进行。也就是说通常会在一个阶段执行的过程中调用或激活另外一个阶段,它们仅仅启动的是顺序的,执行是相互调用的。
解析阶段,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
3 类的加载
加载,是指查找字节流,并且据此创建类的过程。
3.1 类的加载流程
- 通过一个类的全限定名获取定义这个类的二进制流(clss文件)。
- 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
3.2 类的唯一性
在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
4 链接
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
4.1 验证阶段
验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。具体点来说就是保证加载类符号虚拟机规范定义Class文件结构。
验证的时机
加载阶段 与 连接阶段。验证可能发生在加载过程中,即加载还未结束链接阶段就以开始。这样就可能出现一边加载一边验证情况。
验证流程
1 文件格式验证
验证字节流是否符合Class文件格式的规范,是否能被当前版本的虚拟机处理。经过这个阶段的验证后,字节流才会进入内存的方法区中进行存储。
(1)是否以魔数0xCAFEBABE开头。
(2)主、次版本号是否在当前虚拟机处理范围之内。
(3)常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
(4)指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
(5)CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
(6)Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
2 元数据验证
对字节码描述的信息(即类的元数据信息)进行语义分析,以保证其描述的信息符合Java语言规范的要求。
(1)这个类是否有父类(除了java.lang.Object之外,所有类都应当有父类)。
(2)这个类是否继承了不允许被继承的类(被final修饰的类)。
(3)如果这个类不是抽象类,是否实现了其父类或接口之中所要求实现的所有方法。
(4)类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等等)。
3 字节码验证
进行数据流和控制流分析,即对类的方法体进行校验分析以保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
(1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
(2)保证跳转指令不会跳转到方法体以外的字节码指令上。
(3)保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。
......
4 符号引用验证
符号引用验证可以看作是类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验
(1)符号引用中通过字符串描述的全限定名是否能够找到对应的类。
(2)在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
(3)符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
5 总结
对于虚拟机的类加载机制来说,验证阶段是一个非常重要、但不一定必要(因为对程序运行期无影响)的阶段。如果所运行的全部代码(包括自己编写的和第三方包中代码)都已被反复使用和验证过,那么在验证阶段可考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。
4.2 准备阶段
准备阶段的目的,则是为被加载类的静态字段分配内存,并设置类变量初始值
//变量value在准备阶段过后的值为0而不是123
public static int value = 123;
对于final类型类变量直接在准备阶段赋值
//变量value在准备阶段过后的值为123
public static final int value = 123;
4.3 解析阶段
解析阶段会将符号引用转换为直接引用。
符号引用
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。符号引用是方法区中常量池中一类常量。符号引用用来描述类中,类,接口,字段,方法的描述信息。
对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
#define CONSTANT_Class 1 //对一个类或接口的符号引用
#define CONSTANT_Fieldref 2 //对一个字段的符号引用
#define CONSTANT_Methodref 3 //对一个类中方法的符号引用
#define CONSTANT_InterfaceMethodref 4 //对一个接口中方法的符号引用
#define CONSTANT_MethodType_info
#define CONSTANT_MethodHandle_info
#define CONSTANT_InvokeDynamic_info
- 对于一个类或接口的描述信息包括类或接口的全限定名称
#8 = Class #39 // jvm/ClassStructureMethod
- 对于一个字段描述信息包括字段所在的类以及描述符,描述符包括字段的名称和类型
#2 = Fieldref #3.#19 // jvm/TestClass.m:I
- 对于一个方法描述信息包括方法所在的类以及描述符,描述符包括方法的名称,参数和返回类型
#1 = Methodref #9.#30 // java/lang/Object."<init>":()V
因此符号引用以一组符号来描述所引用的目标,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
直接引用
直接引用可以是直接指向目标在内存中的指针,其值中存储目标在内存中的地址。这里所谓的目标就是指符号引用表示的类,接口,字段,方法。也就是说我们程序执行中需要的类在没有加载到内存中时可以使用符号引用来表示。
触发解析的指令
anewarray 创建一个引用型(如类,接口,数组)的数组,并将其引用值压入
checkcast 检验类型转换,检验未通过将抛出ClassCastException
getfield 获取指定类的实例域,并将其值压入栈顶
getstatic 获取指定类的静态域,并将其值压入栈顶
instanceof 检验对象是否是指定的类的实例,如果是将1压入栈顶,否则将0
invokeinterface 调用接口方法
invokespecial 调用超类构造方法,实例初始化方法,私有方法
invokestatic 调用静态方法
invokevirtual 调用实例方法
ldc 将int, float或String型常量值从常量池中推送至栈顶
ldc_w 将int, float或String型常量值从常量池中推送至栈顶(宽索引)
multianewarray 创建指定类型和指定维度的多维数组(执行该指令时,操作栈中必须包含各维度的长度值),并将其引用值压入栈顶
new 创建一个对象,并将其引用值压入栈顶
putfield 用栈顶的值为指定的类的实例域赋值
putstatic 用栈顶的值为指定的类的静态域赋值
解析缓存
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存
类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:
public class D{
public static C N;
}
- 1)如果C 不是一个数组类型,虚拟机将会把代表N的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作(例如加载这个类的父类或实现的接口)。一旦这个加载过程出现任何异常,解析过程宣告失败。
- 2)如果C 是一个数组类型,并且数组的元素类型为对象,也就是N 的描述符会是类似“[Ljava/lang/Integer”的形式,将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的数据是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
- 3)如果上面步骤无任何异常,那么 C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 D 是否具备对C的访问权限。若不具备访问权限,将抛出 java.lang.IllegalAccessError异常。
字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号进行引用解析,也就是字段所属的类或接口的符号引用。如果在此解析这个类或接口符号引用的过程中出现任何异常,都会导致字段符号引用解析的失败。如果解析成功,那将这个字段所属的类或接口用 C 表示,虚拟机规范要求按照如下步骤进行后续字段搜索:
- 1)如果C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 2) 否则,如果在C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 3)否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 4)否则,查找失败,抛出 java.lang.NoSuchFieldError 异常
类方法解析
类方法解析的第一个步骤与字段解析相同,也需要先解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C 表示这个类。虚拟机会按照以下步骤进行后续的类方法搜索:
- 1) 类方法和接口方法符号引用的常量类型定义时分开的,如果在类方法表中发现 class_index 项中索引的C 是个接口,直接抛出 java.lang.IncompatibleClassChangeError异常。
- 2)如果通过第一步,在类 C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的 直接引用,查找结束。
- 3)否则,在类C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的 直接引用,查找结束。
- 4)否则,在类C 实现的接口列表以及它们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C 是一个抽象类,这时查找结束,抛出 java.lang.AbstractMethodError异常。
- 5)否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError异常。
接口方法解析
接口方法也需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口。虚拟机会按照以下步骤进行后续的接口方法搜索:
- 1)与类方法解析不同,如果在接口方法表中发现 class_index 中的索引C 是个类而不是接口,直接抛出 java.lang.IncompatibleClassChangeError异常。
- 2)否则,在接口 C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的 直接引用,查找结束。
- 3)否则,在接口 C 的父接口中递归查找,直到 java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的 直接引用,查找结束。
- 4)否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError异常。
由于接口中的所有方法默认是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出 java.lang.IllegalAccessError 异常。
4.4 初始化阶段
初始化阶段是执行类构造器<clinit>()方法的过程。
4.4.1 类初始化时机
类的初始化的前提是类必须加载,JVM规定如下情况会去初始化一个类。这些场被称为对一个类进行 主动引用.
1) 遇到new、getstatic、putstatic或invokestatic这四条字节码指令(注意,newarray指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String[]只会直接触发String[]类的初始化,也就是触发对类[Ljava.lang.String的初始化,而直接不会触发String类的初始化)时,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:
使用new关键字实例化对象的时候;
读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
调用一个类的静态方法的时候。
2) 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3) 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4) 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5) 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
除此之外,所有引用类的方式,都不会触发初始化,称为 被动引用。
通过子类引用父类的静态字段,不会导致子类初始化
通过数组定义来引用类,不会触发此类的初始化
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
4.4.2 类初始化做什么
在Java类的初始化过程中主要涉及,类变量初始化,类代码块初始化。
4.4.3 类初始化原则
- 1 Java是按照编码顺序来执行类变量初始化和类代码块初始化中的代码的。
- 2 Java要求在类的初始化之前,必须先初始化其父类。
- 3 Java虚拟机在实例化一个对象前会加载对象的类,并初始化。但是在程序中可以将实例初始化嵌入到了静态初始化流程中,导致实例初始化在类初始化结束之前进行。
- 4 类初始化过程是线程安全的.
- 5 编译器不允许 类代码块使用未被赋值的类变量【只能赋值不能使用】
4.4.4 类初始化案例
类初始化过程是线程安全的
public class DealLoopTest {
static{
System.out.println("DealLoopTest...");
}
static class DeadLoopClass {
static {
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();
}
}/* Output:
DealLoopTest...
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass
*///:~
将实例初始化嵌入到了静态初始化流程中,可以导致实例初始化不一定要在类初始化结束之后。
public class InitializationClass {
public static void main(String[] args) {
new Bar1();
}
}
class Foo1 {
int a = 110;
static Foo1 st = new Foo1();
private static int i = 0;
static {
i++;
}
protected int getValue() {
return i;
}
public Foo1() {
System.out.println(i);
System.out.println(getValue());
System.out.println(a);
}
}
class Bar1 extends Foo1 {
static {
j = 1; // 编译器无法通过编译,使用未被实例变量初始化的变量
}
private static int j = 0;
@Override
protected int getValue() {
return j;
}
public Bar1() {
System.out.println(j);
}
}
0
0
110
1
0
110
0
编译器不允许 类代码块使用 未被赋值的类变量【只能赋值不能使用】
public class InitializationClass {
}
class Foo1 {
private static int k = 0;
{
k = getI(); //跳过编译器检查,使用未被实例变量初始化的变量
System.out.println(k);
}
private static int i = 0;
private static int getI() {
return i;
}
static {
i++;
}
public Foo1() {
System.out.println(i);
}
}
class Bar1 extends Foo1 {
static {
j = 1; // 编译器无法通过编译,使用未被实例变量初始化的变量
}
private static int j = 0;
public Bar1() {
System.out.println(j);
}
}