1.概述
虚拟机把描述类的数据从Class文件加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译期需要进行连接工作的语言不同,在Java语言里面,类的加载、连接和初始化过程都是在程序运行期间完成的,虽然这种策略会令类加载时稍微增加一些性能开销,但是为Java应用程序提供了高度的灵活性,Java语言可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。从最基础的Applet、Jsp到相对复杂的OSGi技术,都是用了Java语言运行期类加载的特性。
2.类的加载时机
加载、验证、准备、初始化、卸载;5个阶段的顺序是确定的,类的加载顺序必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定,这里的按部就班的“开始”,不是按部就班的“进行”或者完成,强调这一点是因为这些阶段通常是相互交叉地混合式进行的,通常会在一个阶段的执行过程中会激活、调用另外的一个阶段。
3.类加载的过程
3.1加载
“加载”是“类加载”(Class Loading)过程中的一个阶段。
在加载阶段虚拟机需要完成以下三件事情:
1) 通过一个类的全限定名来获取定义此类的二进制字节流。
2) 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
3) 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
虚拟机的这3点要求其实并不算具体,因此虚拟机的实现和具体应用的灵活度都是相当大的。
例如:“通过一个类的全限定名来获取定义此类的二进制字节流”,这一条没有明确指出,从哪里获取,怎么获取?
Java发展的历程中,许多举足轻重的技术都建立在这一基础之上:
1)从ZIP中获取,这很常见,最终成为日后JAR,EAR,WAR格式的基础
2)网络中获取,这种场景典型的就是Applet
3)运行时计算生成,这种场景使用最多的就是动态代理技术
4)由其他文件生成,典型的就是JSP应用,即有JSP生成对应的Class类
5)从数据库中获取,这场景相对较少
........
相对于类的其他加载阶段,一个非数组类的加载阶段(准确的说是加载阶段获取类的二进制字节流动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的启动类加载器,也可以由用户自定义类加载器去完成,开发人员可以自定义自己的类加载器去控制字节流的获取方式。
对于数组类而言,情况又有所不同,数组类本身不通过类加载创建,它是由Java虚拟机直接创建。但是数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type 指数组去掉所有维度的类型)最终是要靠类加载器去创建。
一个数组类的创建过程遵循以下原则:
1) 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该该组件类型的类加载器的类命名空间上被标识(一个类必须与类加载器一起确定唯一的标识)。
2)如果数组的组件类型不是引用类型(例如:int[]数组)虚拟机将把数组标记为与引导类加载器关联
3)数组类可见性与它的组件类型可见性一致,如果组件类型不是引用类型,那数组类的可见性将为public
加载阶段完成之后,虚拟机的外部二进制字节流按照虚拟机所需的格式存储在方法区之中,方法区的数据存储格式有虚拟自行定义,虚拟机规范未规定此区域的具体的数据机构,然后,“在内存中生成一个代表这个类的java.lang.Class对象”,对于Hospot虚拟机而言,虽然Class是一个对象,当时存放在方法区里面,这个对象将作为程序访问方法区中这些类型数据的外部接口。
3.2验证
验证是连接阶段的第一步,这一阶段的作用是确保Class文件字节流中包含的信息符合当前虚拟机的要求,并且不会危害当前虚拟机的安全,所以验证是虚拟机对其自身保护的一项重要工作。
验证阶段大致会完成下面四个阶段的校验工作,文件格式验证,元数据验证,字节码验证,符号引用验证。
1)文件格式验证
验证字节流是否符合Class文件规范,并且能被当前虚拟机所处理。
2)元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范要求。
3)字节码验证
通过数据流和控制流分析,确定语义是合法、符合逻辑的,在第二个阶段对元数据信息中数据类型做完校验之后,这个阶段将对类的方法体进行校验分析,以保证被检验的类的方法在运行时不会做出危害虚拟机的事件。
4)符号引用验证
最后一个阶段发生在虚拟机将符号引用转为直接引用的时候,这个转化动作发生在连接的第三阶段——解析阶段中发生,符号引用可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
3.3准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所需要的内存都将在方法区中进行分配。
这个阶段统一产生混淆的两个概念:
1)这时候分配内存的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量会随着对象实例化一起被分配在Java堆中
2)这里所说的“初始值”,“通常情况下”是数据类型的零值。
假设:一个类变量定义为
public static int value =123;
那么准备阶段过后value的初始值是0而不是123,因为这个时候尚未执行任何的Java方法,把value赋值为123的指令putstatic是在程序被编译之后,存放在类构造器<clinit>方法中,所以把value赋值为123是在初始化阶段发生。
“特殊情况”:如果类字段的字段属性表名中存在ConstantValue属性,那么在准备阶段变量value就会被初始化ConstantValue属性所指定的值,假设上面的变量定义为
public static final int value =123;
编译时Javac时将会为value生成ConstantValue属性,在准备阶段Java虚拟机就根据ConstantValue将value赋值为123。
3.4解析
解析阶段是将常量池内的符号引用转化为直接引用的过程。
解析阶段所说的直接引用和符号引用又有什么不同:
3.4.1 符号引用
符号引用是一组符号描述所引用的目标,与虚拟机实现的内存布局无关
3.4.2 直接引用
直接引用可以是直接指向目标的指针、相对偏移量或者一个间接能定位到目标的句柄,直接引用和虚拟机的内存布局相关。如果有了直接引用,那么引用的目标必然已经存在内存之中。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
1)类或接口的解析
假设当前代码所处的类为D,如果把一个从未解析的符号引用N解析为一个类或者一个接口C的直接引用,那么虚拟完成解析需要一下三个步骤:
A.如果C不是一个数组类型,那虚拟机会代表N的全限定名传给D的类加载器去加载这个类C,加载过程中由于元数据的验证、字节码的验证的需要又会出发其他相关类的加载动作,例如加载这个类的父类或者实现的接口,一旦这个加载过程出现异常,解析过程就宣告失败。
B.如果C是一个数组,并且数组的元素类型为对象,那就将会按照A中所描述的规则加载数组元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
C.如果上面的步骤都没有上面异常,那么C在虚拟机中实际已经成为一个有效的类或者接口了,但在解析完成之前还需要进行符号引用验证,确认D是否具有对C的访问权限。
2)字段解析
解析一个从未解析过的字段符号引用,首先会对字段表内class_index中的索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或者接口的符号引用。如果解析成功,这个字段所属的的类或者接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
A.如果C本身就包含了简单名称与字段描述都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
B.否则,如果C实现了接口,则按照继承关系从下往上搜索各个接口和它的父接口,如果接口包含了简单名称与字段描述都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
C.否则,如果C不是“java.lang.Object”的话,则按照继承关系从下往上搜索其父类,如果父类包含了简单名称与字段描述都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
D.否则查找失败
3)类方法解析
类方法解析的第一个步骤和字段解析一样,首先会对字段表内class_index中的索引的方法所属的类或者接口的符号引用,如果解析成功,我们依然用C表示这个类,将按照如下步骤进行后续的类方法步骤查找:
A.类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index索引的C是一个接口,那就直接报错
B.如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果则返回这个方法的直接引用,查找结束。
C.在类C父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果则返回这个方法的直接引用,查找结束。
D.否则,在C实现的接口列表以及他们的父类接口之中递归查找,如果存在匹配的话,说明类C是一个抽象类。
E.否则,宣告类方法查找失败。
4)接口方法解析
接口方法解析的第一个步骤和字段解析一样,首先会对字段表内class_index中的索引的方法所属的类或者接口的符号引用,如果解析成功,我们依然用C表示这个类,将按照如下步骤进行后续的接口方法步骤查找:
A.如果在接口方法表中发现class_index索引的C是一个类,那就直接报错
B.如果通过了第一步,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果则返回这个方法的直接引用,查找结束。
C.在接口C接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果则返回这个方法的直接引用,查找结束。
D.否则,宣告接口方法查找失败。
3.5初始化
类初始化阶段是类加载过程的最后一步,在前面了类加载过程,除了加载阶段用户应用程序可以自定义类加载器参与之外,其余动作都是由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的Java代码(或者说是字节码)。在准备阶段变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主管计划去初始化类变量和其他资源,或者从另外一个角度来表达,初始化阶段就是执行类构造器方法<clinit>()方法的过程。
1)<clinit>()方法有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并而成。
2)<clinit>()方法与类构造函数(实例构造器<init>)不同,它不需要显示的调用父类构造器,虚拟机会保证在子类的<clinit>方法之前执行,因此虚拟机中第一个被执行的<clinit>()方法一定是java.lang.Object
3)由于父类的<clinit>方法优先于子类执行,所以父类中定义的静态代码块优先于子类执行
4)<clinit>方法对于类或者接口来说不是必需的,如果一个类没有静态代码块也没有对变量的赋值操作,那么编译器不会为这个类生成<clinit>方法
5)接口不能使用静态代码块,但是可以有变量初始化操作,因此接口和类一样都可以生成<clinit>方法,执行接口的<clinit>方法不需要先执行父接口的<clinit>方法,只有父接口中定义的常量使用时,父接口才会被初始化,另外,接口的实现类在初始化时也一样不需要执行父接口的<clinit>方法,
6)虚拟机会保证一个类的<clinit>方法在多线程环境中被正确的加锁,同步;如果多线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>方法,其他线程都需阻塞等待,如果在一个类的<clinit>方法有耗时很长的操作的话,可能会造成多个进程阻塞。