本文我们会详细了解Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。
1、加载
在加载阶段,Java需要完成以下三件事情:
1)通过一个类的
全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
2、验证
验证是连接阶段的第一步,这一阶段的目的是确保
Class
文件的字节流中包含的信息符合《
Java
虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
但从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
1)文件格式验证:
第一阶段要验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
是否以魔数0xCAFEBABE开头。
主、次版本号是否在当前Java虚拟机接受范围之内。
常量池的人常量中是否有不被支持的常量类型(检查常量tag标志)
。。。
2)元数据验证:
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
这个类是否有父类(除了Object类之外,所有的类都应该有父类)。
这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
。。。
3)字节码验证:
通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如:
-
保证任意时刻操作数栈的数据类型与指令码序列都能够配合工作,不会出现类似,在操作栈放置了一个int类型的数据,使用时却按long类型来载入本地变量表中。
-
保证跳转指令不会跳到方法体以外的字节码指令上。
-
保证类型转换是有效的,例如把父类型赋值给子类型是安全的,子类型赋值给父类型就是不安全危险的
4)符号引用验证:
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。本阶段通常需要校验下列内容:
符合引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的可访问性(
private
、
protected
、
public
、
<package>
)是否可以被当前类访问。
。。。
3、准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值。
假设一个类变量的定义为:
public static int value = 123;
那么,变量value在准备阶段过后的值为0而不是123。因为这时候尚未开始执行任何Java方法,而把vaule赋值为123的putstatic指令是程序被编译后,存放于类构造器方法<clinit>()方法中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。下面是Java中所有基本类型的零值:
上面提到在
“
通常情况
”
下初始值是零值,那言外之意是相对的会有某些
“
特殊情况
”
:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:
public static final int value = 123;
编译时Javac将会为value生成ConstantVaule属性,在准备阶段虚拟机就会初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0.
4、解析
虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口(CONSTANT_Class_info)、字段(CONSTANT_Fieldref_info)、类方法(CONSTANT_Methodref_info)、接口方法(CONSTANT_Interfaceref_info)、方法类型(CONSTANT_MethodType_info)、方法句柄(CONSTANT_MethodHandle_info)、调用限定符(CONSTANT_InvokeDynamic_info)。
符号引用:以一组符号来描述锁引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
直接引用:可以是直接指向目标的指针,先对偏移量或一个能间接定位到目标的句柄。
类或接口解析
假设当前代码所处类,如果把一个为解析过的符号引用解析为一个类或接口的直接引用,步骤为:
第一步:如果类或接口不是一个数组类型,虚拟机会把符号引用的全限定名传递给代码所在类的类加载器去加载这个类的符号引用。加载过程中,由于元数据验证、字节码验证的需要,可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦加载过程中出现了任何异常,解析过程宣告失败。
第二步:如果类或接口是一个数组类型,并且数组的元素类型为对象,也就是符号引用的描述符会是类似“[Ljava/lang/Integer”的形式,将会按照第一步的规则加载数组元素类型。如果符号引用的描述符如前面锁假设的形式,需要加载的元素类型为“java.lang.Integer”,接着虚拟机生成一个代表数组维度和元素的数组对象。
第三步:如果上面步骤没有出现异常,那么类或接口在虚拟机实际上已成为一个有效的类或者接口,但在解析完成之前还要进行符号引用验证,确认代码所在类是否具备对类或接口的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
字段解析
解析一个未被解析过的字段符号引用,首先对字段表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个字段所属的类或接口进行后续字段的搜索。
第一步:如果类或接口本身包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,查找结束。
第二步:否则,如果类或接口中实现了接口,将按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。
第三步:否则,如果类或接口不是java.lang.Object的话,按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述都与目标匹配的字段,则返回这个字段的直接引用,查找结束。
第四步:否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回了引用,还将进行权限验证,如果发现不具备这个字段的访问权限,将抛出java.lang.IllegalAccessError异常。
类方法解析
解析一个未被解析过的类方法符号引用,首先对类方法表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个类方法所属的类方法的搜索。
第一步:类方法和接口方法符号引用的常量类型定义是分开的,如果类方法表中发现class_index中索引是接口,直接抛出java.lang.IncompatibleClassChangeError异常。
第二步:通过第一步,在类中查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。
第三步:否则,在类的父类中递归查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。
第四步:否则,在类实现的接口列表即它们的父接口中递归查找是否有简单名称和描述符都匹配的方法,如果有则返回这个方法的直接引用,查找结束。
第五步:否则,查找失败,抛出java.lang.NoSuchMethodError异常。
如果查找过程成功返回了引用,还将进行权限验证,如果发现不具备这个字段的访问权限,将抛出java.lang.IllegalAccessError异常。
接口方法解析
解析一个未被解析过的接口方法符号引用,首先对接口方法表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个接口方法所属的接口方法的搜索。
第一步:与类方法解析不同,如果接口方法表中发现class_index项索引的CONSTANT_Class_info的方法所属是类,将抛出java.lang.IncompatibleClassChangeError异常。
第二步:否则,在接口中查找是否有简单名称和描述符相匹配的接口方法,如果有则返回这个方法的直接引用,查找结束。
第三步:否则,在接口的父接口中递归查找,知道java.lang.Object类为止,是否有简单名称和描述符都与目标相匹配的接口方法,如果有则返回这个方法的直接引用,查找结束。
第四步:否则,查找失败,抛出java.lang.NoSuchMethodError异常。
由于接口中的所有方法默认为public abstract,不会存在访问权限问题,因此接口方法的符号解析不会出现java.lang.IllegalAccessError异常。
5、初始化
类初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值(零值);而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化类变量和其他资源,或者更直接地说:
初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。如下:
public class Test{
static{
i=0;
System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前应用)
}
static int i=1;
}
那么注释报错的那行代码,改成下面情形,程序就可以编译通过并可以正常运行了。
public class Test{
static{
i=0;
//System.out.println(i);
}
static int i=1;
public static void main(String args[]){
System.out.println(i);
}
}
类构造器<clinit>()与实例构造器<init>()不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器<clinit>()执行之前,父类的类构造<clinit>()执行完毕。由于父类的构造器<clinit>()先执行,也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。特别地,类构造器<clinit>()对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器<clinit>()。
虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为
在同一个类加载器下,一个类型只会被初始化一次。
如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的,如下所示:
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();
}
输出:
如上述代码所示,在初始化DeadLoopClass类时,线程Thread-1得到执行并在执行这个类的类构造器<clinit>() 时,由于该方法包含一个死循环,因此久久不能退出。