类的加载是指将.class文件中的二进制数据读入到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。数据放在运行时数据区的方法区中,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
该图显示了类的生命周期中的七个阶段。类的加载过程包括了加载、验证、准备、解析、初始化五步。加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,但解析不一定:它在某些情况下可以再初始化之后开始。事实上这些阶段都不是一个阶段进行完再开始另一个,通常它们都是相互交叉地混合式进行地,通常在一个阶段的执行过程中调用,激活里一个阶段。
类加载过程分五个阶段:加载、验证、准备、解析、初始化。
在加载阶段,虚拟机需要做三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
一个非数组类的加载阶段是可控性最强的,因为加载阶段既可以使用系统的引导类加载器来完成,开发人员也可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。
对于数组类而言,数组类本身不通过类加载器创建,它是由JVM直接创建的。但数组类的元素类型最终是要依靠类加载器去创建的。一个数组类的创建规则有三点:
1)如果数组类的组件类型(数组去掉一个维度的类型)是引用类型,那就递归采用之前定义的加载过程去加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识(之后会详细介绍)。
2)如果数组的组件类型不是引用类型,Java虚拟机将会把数组标记为与引导类加载器关联。
3)数组类的可见性与它的组件类型的可见类型一直,如果组件类型不是引用类型那数组可见性默认为public。
加载完成后,虚拟机外部的二进制字节流就存储再方法区之内,然后再内存中实例化一个java.lang.Class的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。
验证是连接的第一阶段,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机的安全。
因为Class文件不一定是由Java源码编译出来的,可以通过任何途径产生。所以如果不检查输入的字节流,很可能会因为载入了有害的字节流导致系统崩溃。验证阶段大致分为4个阶段的验证动作:
1)文件格式验证
该阶段要验证字节流是否符合Class文件格式的规范。这里用一张图介绍一下Class文件的格式,不做详细介绍。
验证点主要包括一下几个:
(1)是否以魔数0xCAFEBABE开头。
(2)主、次版本号是否再当前虚拟机处理范围之内。
(3)常量池中的常量是否有不被支持的常量类型。
(4)指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
…
第一阶段事实上验证的点很多,该阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区进行存储。
2)元数据验证
该阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,主要包括以下认证点:
(1)这个类(除了java.lang.Object)是否有父类。
(2)这个类的父类是否继承了不允许继承的类(用final修饰的类)。
(3)如果这个类不是抽象类,是否实现了其父类或接口中要实现的所有方法。
(4)类中的字段、方法是否与父类产生矛盾。
…
3)字节码认证
该阶段通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,这个阶段是最复杂。该阶段将对类的方法体进行校验分析,例如:
(1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现如在操作数栈放置了一个int类型的数据,使用时却按long类型来加载到本地变量表中。
(2)保证跳转指令不会跳转到方法体外。
(3)保证类型转换是有效的。
…
如果一个方法体通过了字节码认证,也未必安全。
4)符号引用验证
该阶段发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作将在解析阶段发生。符号引用验证可以看作是对常量池的各种符号引用的信息进行匹配性校验:
(1)符号引用中通过字符串描述的权限定名是否能找到对应的类。
(2)在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
(3)符号引用中的类、字段、方法的访问权限是否可被当前类访问。
…
对于类加载机制来说,验证阶段是个非常重要、但不是必要的阶段。如果运行的全部代码都已经被反复使用和验证过,可以关闭大部分验证措施。
该阶段是为静态变量(类变量)分配内存并设置初始值,这些内存将在方法区被分配。这里需要强调一下这里所说的初始值通常是数据类型的零值,假设一个一个变量定义为:
public static int a = 1;
那么准备阶段给a赋得初始值是0而不是1,而把a赋值为1的putstatic指令是程序被编译以后,存放于类构造器()方法中,所以该操作是在初始化阶段进行的。但也有一种特殊情况:
public static final int a=1;
编译时Javac就会将a生成ConstantValue属性,在准备阶段会根据ConstantValue的设置将a赋值为1。
注意:同时被final和static修饰的话必须为其显式地赋值,否则编译不通过。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用与虚拟机实现的内存布局无关。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范中的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存相关的,同一符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
1、类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,解析过程分需要3个步骤:
1)如果C不是一个数组类型,那虚拟机将把代表N的全限定名传递给D的类加载器去加载这个类C。
2)如果C是一个数组类型,并且数组的元素类型为对象,那会按照第1点加载数组元素类型。如果N的描述符是类似"java/lang/Integer"的形式,接着由虚拟机生成一个代表次数组维度和元素的数组对象。
3)如果上述步骤没有异常,事实上已经生成一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D对C的访问权限。
2、字段解析
将这个字段所属的类或接口用C表示。
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则,如果C实现了接口,将会按照继承关系从下往上递归搜索各个接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出java.lang.NosuchError异常。
3、类方法解析
依旧用C表示这个类。
1)类方法和接口方法符号引用的常量类型是分开的,如果类方法发现class_index索引的C是个接口,就直接抛出java.lang.IncompatibleClassChangeError异常。
2)第一步通过后,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,有则返回这个方法的直接引用。
3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。
4)否则,在类C实现的接口列表中以及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有,则说明类C是个抽象类,抛出Java.lang.AbstractMethodError异常。
5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
4、接口方法解析
1)如果在接口方法表中发现class_index中的索引是个类而不是接口,抛出java.lang.IncompatibleClassError异常。
2)否则,在接口中查找是否有简单名称和描述符都与目标相匹配的方法,有则返回这个方法的直接引用。
3)否则,在接口的父接口中递归查找,直到java.lang.Object类为止,看是否有简单称和描述符都与目标相匹配的方法,有则返回这个方法的直接引用。
4)否则,查找宣告失败,抛出java.lang.NoSuchMethodError异常。
为类静态变量和静态代码块进行初始化操作。
1)< clinit >()方法是由编译器自动收集类中的所有类变量(static修饰的变量)的赋值动作和静态语句块中的语句合并产生的。静态语句块只能访问到静态语句块之前的变量,定义在它后面的变量,前面的静态语句块可以赋值,但不能访问。
public class Test{
static{
i=0; //赋值编译可以通过
System.out.println(i); //编译不通过,Cannot reference a field before it is defined.
}
static int i=1;
}
2)< clinit >()方法与类的构造方法不同,它不需要显示的调用父类构造器,虚拟机会保证再子类的< clinit >()方法执行之前,父类的< clinit >()方法已经构造完毕。
3)父类的静态语句块有限于子类的静态语句块。
4)如果一个类没有静态语句块也没有静态变量的赋值操作,那么编译器可以不为这个类生成< clinit >()方法。
5)接口不能有静态语句块,但可以有静态变量。接口与类不同的是,执行接口的< clinit >()方法不需要先执行父接口的< clinit >()方法。另外,接口的实现类在初始化也一样不会执行接口的< clinit >()方法。
6)如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法完毕。
类初始化的时机
1、创建了实例对象,即new。
2、读取或设置一个类的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
3、反射调用。
4、初始化一个类时,需先初始化其父类。
5、虚拟机启动时,用户需要先初始化主类(包含main()方法的类)。
类加载器用于实现的类的加载。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在JVM中的唯一性,每一个类加载器都有一个独立的类名称空间。
类加载器大致分为三类:
启动类加载器:负责加载存放在JDK\jre\lib下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。这个类加载器是用C++实现的,是虚拟机自身的一部分。
扩展类加载器:该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器: 它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
这些类加载器的关系如图:该图所展示的类加载器之间的层次关系,称为类加载器的双亲委派模型。从图中可以看出,除了顶层的启动类加载器,其余加载器都有自己的父类加载器,这种父子关系一般不已继承关系实现,而是已组合关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是将其委托给父类加载器去完成,因此所有加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器无法完成这个加载请求(搜索范围内没有找到所需的类),子加载器才会尝试自己去加载。
以上内容参考至:
《深入理解Java虚拟机》第2版