7.1概述
虚拟机把类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类加载、连接和初始化是在类的运行期间完成,虽然会增加一些开销,但是提高了灵活性。例如:编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类。或类的增强,都是使用了在运行期类加载的特性。
7.2类加载的时机
类加载生命周期:加载、验证、准备、解析、初始化、使用、卸载,其中验证、准备、解析统称为连接。
类的加载没有规定必须何时开始,但规定了何时必须进行类的初始化:
- new或者读取非final静态变量时,或者调用静态方法时。
- 对类进行反射
- 初始化类时必先初始化其父类(如果有),接口不是这样。
- 执行一个类的main方法时,需要初始化其主类。
如果一个MethodHandle实例后的解析结果
7.3类加载过程
7.3.1 加载
‘加载’是‘类加载’的一个阶段,虚拟机需要完成以下3个事情:通过类的全限定名获取该类的二进制字节流
可以从zip包中读取,从网络中获取,运行时计算生成(动态代理技术,如java.lang.reflect.Proxy中 ProxyGenerator.generateProxyClass),jsp文件生成的class- 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
非数组类的加载阶段,程序员对其可控性最强,可以用引导类、用户自定义的加载器去完成,可以重写类加载器的loadClass()方法去控制字节流的获取方式。
对于数组类,它是由虚拟机直接创建。数组类的元素类型最终要靠类加载器创建。
加载阶段完成后,二进制字节流就存储在方法区中,然后内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段和连接阶段可能交叉进行,但两个阶段的开始时间保持着固定的先后顺序。
7.3.2验证
验证是连接阶段的第一步,是为了保证class文件符合虚拟机的要求,且不会危害虚拟机的安全。
java语言编译成的class文件符合验证要求,但如果是其他语言生成的class文件,则必须检查。
大致4个阶段的检验动作:
1、文件格式验证
验证class文件格式是否规范,是否可被当前版本的虚拟机处理(通过魔数),保证输入的字节流能够正确解析并存储于方法区内。所以后面3个验证动作是基于方法区的存储结构进行的。
2、元数据验证
对字节码描述的信息进行语义分析。保证必须符合Java语言规范(如继承父类接口必须实现所有方法,final类不允许被继承,所有的类必须有父类(Object类除外))。
3、字节码验证
通过数据流和控制流分析,确定程序语义合法,对类的方法体进行校验分析,保证方法在运行时不会危害虚拟机(如保证跳转指令不会跳转到方法体之外的字节码指令上,保证数据类型和指令代码都能够配合工作,不会出现在一个操作栈放置int类型却按long类型加载入本地变量表)。
方法体的code属性的属性表有一个‘stackmaptable’属性,描述了方法体中所有基本块开始时变量表和操作栈应有的状态,在字节码验证期间,只需检查该属性即可,这就是类型检查的方式进行字节码验证。
4、符号引用验证
该验证发生在虚拟机将符号引用转化为直接引用的时候,符号引用验证是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
7.3.3 准备
准备阶段是为类变量分配内存并设置初始值的阶段。类变量使用的内存在方法区分配。
public static int a = 123;该阶段会将a设置为0;
public final static int b = 123;该阶段会将b设置为123;
7.3.4解析
将常量池内的‘符号引用’转为‘直接引用’。
符号引用的存在形式是在常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等7种类型的常量出现。
直接引用:是可以直接指向目标的指针、相对偏移量或间接定位到目标的句柄。
1、类或接口的解析
①假设当前类D,符号引用是N,把N解析为一个类或接口C的直接引用,需要3步:
把N的全限定名传给D的类加载器去加载C,如C有父类则一起加载,如出现任何异常,则解析失败。
②若C是数组类型,则先下载数组元素类型,接着由虚拟机生成代表此数组维度和元素的数组对象。
③确认D是否具备对C的访问权限,如没权限,则IllegalAccessError异常。
2、字段解析
首先对字段表内‘class_index’项中索引的CONSTANT_Class_info符号引用进行解析,假设这个类或接口是C,接着对C进行后续字段的搜索。
①如果C包含了与目标相匹配的字段,则返回这个字段的直接引用,查找结束
②否则,如果C中实现了接口,则递归搜索祖宗接口,查找是否有与目标字段相匹配的,如找到,则结束
③否则,如果C不是Object类,则递归搜索祖宗类,查找匹配目标字段,匹配成功,则结束
④否则查找失败,报NoSuchFieldError异常。
如果查找过程成功返回了引用,则进行权限验证,如不对目标字段有访问权限,则抛IllegalAccessError异常。
3、类方法解析
第一个步骤和字段解析类似,先解析类方法表的class_index’项中索引的CONSTANT_Class_info符号引用进行解析,假设这个类或接口是C,接着对C进行后续类方法的搜索。
①类方法 和接口方法的符号引用的常量类性定义是分开的,如果发现C是接口,则报IncompatibleClassChangeError异常
②在C中查找是否有目标方法
③在C的祖宗类中查找目标方法
④在C实现的接口列表及祖宗接口中查找匹配方法,如存在,则说明C是抽象类,报AbstractMethodError异常。
⑤查找失败,报NoSuchMethodError异常。
最后进行权限验证。
4、接口方法解析
在接口方法表的class_index’项中索引的CONSTANT_Class_info符号引用进行解析,成功后用C表示该接口,进行后续的接口方法搜索。
①如果发现C是类,则报IncompatibleClassChangeError异常
②在C中查找目标接口方法
③在C的祖宗接口查找目标接口方法
④查找失败,报NoSuchMethodError异常。
⑤权限验证
7.3.5初始化
类初始化是类加载阶段的最后一步。此阶段真正开始执行类中定义的Java程序代码(字节码)。
初始化阶段是执行类构造器clinit()方法的过程。- clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块合并产生的。静态语句块只能访问定义在静态语句块之前的变量,定义在静态语句块之后的变量,可以赋值,但不能访问。
public class SubAndParentTest {
static class Parent{
static {
A = 2;
System.out.println(A);//此处会报错
}
public static int A = 1;
}
}
- clinit()方法与类的构造函数不同,它不需要显式调用父类构造器,虚拟机保证父类clinit()方法会在子类clinit()方法之前执行。第一个被执行的clinit()方法对应的类是java.lang.Object。
- 父类静态语句块先执行,下面代码的字段B的值会是2而不是1。
public class SubAndParentTest {
static class Parent {
static {
A = 2;
}
public static int A = 1;
}
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()方法,其它线程必须阻塞等待。