简述:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
下面我们具体来看类加载的过程:类从被加载到内存中开始,到卸载出内存,经历了加载、连接、初始化、使用四个阶段,其中连接又包含了验证、准备、解析三个步骤。这些步骤总体上是按照图中顺序进行的,但是Java语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。
类加载的时机
首先要知道什么时候类需要被加载,Java虚拟机规范并没有约束这一点,但是却规定了类必须进行初始化的5种情况,很显然加载、验证、准备得在初始化之前,下面具体来说说这5种情况:
其中情况1中的4条字节码指令在Java里最常见的场景是:
1 . new一个对象时
2 . set或者get一个类的静态字段(除去那种被final修饰放入常量池的静态字段)
3 . 调用一个类的静态方法
类加载的过程
下面我们一步一步分析类加载的每个过程
1. 加载
加载是整个类加载过程的第一步,如果需要创建类或者接口,就需要现在Java虚拟机方法区创建于虚拟机实现规定相匹配的内部表示。一般来说类的创建是由另一个类或者接口触发的,它通过自己的运行时常量池引用到了需要创建的类,也可能是由于调用了Java核心类库中的某些方法,譬如反射等。
一般来说加载分为以下几步:
- 通过一个类的全限定名获取此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
创建名字为C的类,如果C不是数组类型,那么它就可以通过类加载器加载C的二进制表示(即Class文件)。如果是数组,则是通过Java虚拟机创建,虚拟机递归地采用上面提到的加载过程不断加载数组的组件。
Java虚拟机支持两种类加载器:
- 引导类加载器(Bootstrap ClassLoader)
- 用户自定义类加载器(User-Defined Class Loader)
用户自定义的类加载器应该是抽象类ClassLoader的某个子类的实例。应用程序使用用户自定义的类加载器是为了扩展Java虚拟机的功能,支持动态加载并创建类。比如,在加载的第一个步骤中,获取二进制字节流,通过自定义类加载器,我们可以从网络下载、动态产生或者从一个加密文件中提取类的信息。
关于类加载器,会新开一篇文章描述。
2. 验证
验证作为链接的第一步,用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来说是安全的。Java虚拟机规范中关于验证阶段的规则也是在不断增加的,但大体上会完成下面4个验证动作。
1 . 文件格式验证:主要验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。
主要验证点:
- 是否以魔数
0xCAFEBABE
开头 - 主次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持的类型 (检查常量tag标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
- Class文件中各个部分及文件本身是否有被删除的或者附加的其他信息
...
实际上验证的不仅仅是这些,关于Class文件格式可以参考我的深入理解JVM类文件格式,这阶段的验证是基于二进制字节流的,只有通过文件格式验证后,字节流才会进入内存的方法区中进行存储。
2 . 元数据验证:主要对字节码描述的信息进行语义分析,以保证其提供的信息符合Java语言规范的要求。
主要验证点:
- 该类是否有父类(只有Object对象没有父类,其余都有)
- 该类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同)
...
3 . 字节码验证:主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
主要有:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似的情况:操作数栈里的一个int数据,但是使用时却当做long类型加载到本地变量中
- 保证跳转不会跳到方法体以外的字节码指令上
- 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,但是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。
-
符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验。
通常有:
- 符号引用中通过字符串描述的全限定名是否找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、方法、字段的访问性(private,public,protected、default)是否可被当前类访问
符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
验证阶段非常重要,但不一定必要,如果所有代码极影被反复使用和验证过,那么可以通过虚拟机参数-Xverify: none
来关闭验证,加速类加载时间。
3. 准备
准备阶段的任务是为类或者接口的静态字段分配空间,并且默认初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段,所以准备阶段不会做这些事情。假设有:
public static int value = 123;
value在准备阶段的初始值为0而不是123,只有到了初始化阶段,value才会为0。
下面看一下Java中所有基础类型的零值:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | '\u0000' |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
一种特殊情况是,如果字段属性表中包含ConstantValue属性,那么准备阶段变量value就会被初始化为ConstantValue属性所指定的值,比如上面的value如果这样定义:
public static final int value = 123;
编译时,value一开始就指向ConstantValue,所以准备期间value的值就已经是123了。
4. 解析
解析阶段是把常量池内的符号引用替换成直接引用的过程,符号引用就是Class文件中的CONSTANT_Class_info、** CONSTANT_Fieldref_info、CONSTANT_Methodref_info**等类型的常量。下面我们看符号引用和直接引用的定义。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用于内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接受的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义在Class文件格式中。
直接引用(Direct References):直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。
以下Java虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都需要对它的符号引用进行解析:
对同一个符号进行多次解析请求是很常见的,除了invokedynamic指令以外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免解析动作重复。
对于invokedynamic指令,上面规则不成立。当遇到前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令同样生效。这是由invokedynamic指令的语义决定的,它本来就是用于动态语言支持的,也就是必须等到程序实际运行这条指令的时候,解析动作才会执行。其它的命令都是“静态”的,可以再刚刚完成记载阶段,还没有开始执行代码时就解析。
下面来看几种基本的解析:
类与接口的解析: 假设Java虚拟机在类D的方法体中引用了类N或者接口C,那么会执行下面步骤:
- 如果C不是数组类型,D的定义类加载器被用来创建类N或者接口C。加载过程中出现任何异常,可以被认为是类和接口解析失败。
- 如果C是数组类型,并且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会通过递归调用来解析。
- 检查C的访问权限,如果D对C没有访问权限,则会抛出
java.lang.IllegalAccessError
异常。
字段解析:
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info
符号引用进行解析,这边记不清的可以继续回顾深入理解JVM类文件格式,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段解析失败。如果解析完成,那将这个字段所属的类或者接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
1 . 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
2 . 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3 . 再不然,如果C不是java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父类,如果在类中包含
了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4 . 如果都没有,查找失败退出,抛出java.lang.NoSuchFieldError
异常。如果返回了引用,还需要检查访问权限,如果没有访问权限,则会抛出java.lang.IllegalAccessError
异常。
在实际的实现中,要求可能更严格,如果同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。
类方法解析
类方法解析也是先对类方法表中的class_index项中索引的方法所属的类或接口的符号引用进行解析。我们依然用C来代表解析出来的类,接下来虚拟机将按照下面步骤对C进行后续的类方法搜索。
1 . 首先检查方法引用的C是否为类或接口,如果是接口,那么方法引用就会抛出IncompatibleClassChangeError
异常
2 . 方法引用过程中会检查C和它的父类中是否包含此方法,如果C中确实有一个方法与方法引用的指定名称相同,并且声明是签名多态方法(Signature Polymorphic Method),那么方法的查找过程就被认为是成功的,所有方法描述符所提到的类也需要解析。对于C来说,没有必要使用方法引用指定的描述符来声明方法。
3 . 否则,如果C声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功。
4 . 如果C有父类的话,那么按照第2步的方法递归查找C的直接父类。
5 . 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类C时一个抽象类,查找结束,并且抛出java.lang.AbstractMethodError
异常。
- 否则,宣告方法失败,并且抛出
java.lang.NoSuchMethodError
。
最后的最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,那么会抛出java.lang.IllegalAccessError
异常。
接口方法解析
接口方法也需要解析出接口方法表的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
异常。
5. 初始化
初始化是类加载的最后一步,在前面的阶段里,除了加载阶段可以通过用户自定义的类加载器加载,其余部分基本都是由虚拟机主导的。但是到了初始化阶段,才开始真正执行用户编写的java代码了。
在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
public class Test {
static {
i=0; //可以赋值
System.out.print(i); //编译器会提示“非法向前引用”
}
static int i=1;
}
<clinit>()
方法与类的构造函数<init>()
方法不同,它不需要显示地调用父类构造器,虚拟机会宝成在子类的<clinit>()
方法执行之前,父类的<clinit>()
已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()
一定是java.lang.Object
的。
也是由于<clinit>()
执行的顺序,所以父类中的静态语句块优于子类的变量赋值操作,所以下面的代码段,B的值会是2。
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>()
方法,但是接口中的<clinit>()
不需要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的<clinit>()
方法。
虚拟机会保证一个类的<clinit>()
方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行<clinit>()
方法,其它线程都需要等待。
6. Java虚拟机退出
Java虚拟机退出的一般条件是:某些线程调用Runtime类或System类的exit方法,或者时Runtime类的halt方法,并且Java安全管理器也允许这些exit或者halt操作。
除此之外,在JNI(Java Native Interface)规范中还描述了当使用JNI API来加载和卸载(Load & Unload)Java虚拟机时,Java虚拟机退出过程。