虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
一、类加载的时机
类从加载到虚拟机内存开始,到卸载出内存结束,生命周期:加载、连接(验证、准备、解析)、初始化、使用、卸载。
注意:加载、验证、准备、初始化、卸载这五个时严格按顺序开始的,但解析的过程有时会在初始化之后,这是为了支持Java的动态绑定。
1. 主动引用
对于初始化,虚拟机规定有且只有四种情况必须立即对类进行初始化工作(加载、验证、准备在此之前):
1)遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则先触发初始化
3)当初始化某个类,发现其父类还未进行初始化
4)当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
注意:如果是接口的话,在初始化时,并不要求其父接口全部完成初始化,只有在真正使用到父接口的时候(引用接口中定义的常量)才会初始化。
2. 被动引用
引用类,却不会触发初始化
1)通过子类引用父类的静态字段,不会导致子类初始化
2)通过数组定义来引用类,不会触发此类的初始化
3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
二、类的加载过程
加载、验证、准备、解析、初始化这五个阶段
1. 加载
1)通过一个类的全限定名来获取定义此类的二进制字节流
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3)在Java堆中生成了一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口
加载完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机自行定义,虚拟机规范未规定此区域的具体数据结构。
然后再Java堆中实例化一个java.lang.Class类对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。
2. 验证
验证是连接的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。
Java本身是一个相对安全的语言,访问数组边界以外、将对象转型为其未实现类型、跳转到不存在的代码行等操作,都会被编译器拒绝编译;但通过十六进制编译器直接产生Class文件,却可以在字节码语言层面实现Java代码无法做到的事情。虚拟机如果不进行字节流检查,可能因载入有害的输入字节流而导致系统崩溃,所以验证是必要的。
验证过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
1)文件格式验证
保证输入的字节流能够正确的解析并存储于方法区内。
## 是否以魔数0xCAFEBABE开头
## 主次版本号是否在当前虚拟机处理范围内
## 常量池的常量中是否有不支持的常量类型(检查tag标志)
……
2)元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言的规范要求。对类的原数据信息进行语义校验,保证不存在不符合JAVA语言规范的元数据信息。
## 这个类是否有父类(除java.lang.Object外,都应有父类)
## 这个类的父类是否继承了不允许被继承的类(被final修饰的)
## 如果该类不是抽象类,是否实现了其父类或接口之要求的实现的所有方法
……
3)字节码验证
进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
## 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
## 保证跳转指令不会跳转到方法体以外的字节码指令上
## 保证方法体中的类型转化是有效的
4)符号引用验证
发生在JVM将符号引用转化为直接引用的时候,这个转化动作发生在解析阶段,符号引用验证可以看做是对类自身以外的信息进行匹配性的校验。
## 符号引用通过字符串描述的全限定名是否能找到对应类
## 在指定类中是否存在方法的字段描述符及简单名称所描述的方法和字段
## 符号引用中的类、字段和方法的访问性是否可以被当前类访问
3. 准备
是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。
这里只是将类变量进行内存分配,而不包括实例变量;初始化通常是数据类型的零值。
4. 解析
JVM将常量池内的符号引用替换为直接引用的过程。
1)符号引用
一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义定位到目标即可;与虚拟机实现的内存布局无关;引用目标不一定已经加载到内存中。
2)直接引用
直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄;与虚拟机实现的内存布局相关;引用目标必定在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info及CONSTANT_InterfaceMethodref_info四种常量类型。
1)类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析过程需要包括以下3个步骤:
1# 如果C不是一个数组类型,那JVM将会把代表N的全限定名传递给D的加载器去加载这个类。一旦加载过程出现异常,解析过程宣告失败。
2# 如果C是一个数组类型,并且数组的元素类型也为对象,也就是N的描述符会是类似“[Ljava.lang.Integer”的形式,将按照第1点规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载元素类型为“java.lang.Integer”,接着由JVM生成一个代表此数组维度和元素的数组对象。
3# 如果上面步骤无任何异常,那么C在JVM中实际上已经具备对D的访问权限;如果发现不具备访问权限,抛出java.lang.IllegalAccessError异常。
2)解析字段
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所述的类或接口的符号引用。如果在解析这个类或接口符号引用的过程出现任何异常,都会导致字段符号引用解析失败。
如果解析成功,将这个字段所属的类或接口用C表示,对C进行后续字段搜索:
1# 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2# 否则,如果在C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3# 否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4# 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
3)类方法解析
与字段解析一样,先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,用C表示该类,接下来的类方法搜索:
1# 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,直接抛出java.lang.IncompatibleClassChangeError异常。
2# 如通过第1步,在类C中查找是否有简单名称和描述符斗鱼目标相匹配的方法,有则返回这个方法的直接引用,查找结束。
3# 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,有则返回这个方法的直接引用,查找结束。
4# 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,此时查找结束,抛出java.lang.AbstractMethodError异常。
5# 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
如果查找过程成功返回了引用,将会对这个方法进行权限验证,如果发现不具备对方法的访问权限,将抛出java.lang.IllegalAccessError异常。
4)接口方法解析
也是需要先解析出接口方法表的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>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的
三、类加载器
把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到JVM外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
1. 类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。也就是说比较两个类是否相等,需要在两个类是由同一个类加载器加载的前提下才有意义。
这里的“相等”包括:代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
2. 双亲委派模型
从JVM角度来讲,只有两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种是所有的其他的类加载器,这些类加载器由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
绝大部分Java程序都会使用到以下三种系统提供的类加载器:
1)启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中的,并且是虚拟机识别的类库,加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。
2)扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中的,或被系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
3)应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有定义过自己的类加载器,一般情况下这个就是程序中的默认加载器。
双亲委派模型要求除顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器;但类加载器的父子关系并不是继承,而是通过组合复用父类加载器代码。
双亲委派模型工作过程:
如果一个类加载器收到了类加载的请求,首先不会自己去尝试记载该类,而是把这个请求委派给父类加载器去完成,因此所有的加载请求都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试加载。
优点:保证了无论加载那个类都会先委派给启动类加载器,不会出现多个同名类,造成混乱。
实现:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父类加载器为空默认使用启动类加载器作为父加载器。如果父类加载失败,则在抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。