七、JVM(HotSpot)虚拟机类加载机制

注:本博文主要是基于JDK1.7会适当加入1.8内容。

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析,初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

1、类加载时机

类加载到内存开始直至卸载出内存,它的生命周期包括:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析称之为连接。

加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,但是解析阶段则不一定,某些时候可以再初始化之后再进行,符合Java语言运行时绑定的特征。
五种情况,进入初始化阶段:

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令,如果类没有进行初始化,则需要先触发初始化。
  • 使用java.lang.reflect包中方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发初始化。
  • 当初始化遇到一个类的时候,如果父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个需要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类。这其实类似于getstatic指令,main()方法也是定义为static类型的。
  • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析结果REF_GetStatic、REF_PutStatic、REF_invokeStatic方法句柄,并且这个方法句柄多对应的类没有进行初始化,则需要先触发初始化。

2、类加载过程

(1)加载

加载是类加载的一个阶段,概念不要混淆。在加载阶段虚拟机需要做3件事情:

  • 通过类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问接口(存放在方法区,非Heap中)

对于数组类型而言,情况有所不同,数组类本身并不是通过类加载器创建,它是由Java虚拟机直接创建的,但数组类扔与类加载器有着很大的关联,因为数组类的元素类型最终还是要通过类加载进行创建,一个数组类创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的家在过程去加载这个组件类型,数组组件类型将在加载该组件类型的类加载器的类名称空间上被标识
  • 如果数组的组件类型不是引用类型(如int[],二维数组,多维数组),Java虚拟机将会把数组组件类型标记为与引导类加载器关联
  • 数组类的可见性和它的组件类型可见性一致,如果组件类型不是引用类型,那数组类的可见性默认为public

(2)验证

Java语言本身是安全性的语言,但是如果Class文件并不是从Java文件通过javac编译而来,则并不能保证它的安全性,所以验证Class文件二进制流至关重要(java.lang.VerifyError)。验证阶段有4个阶段的验证:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证,类文件格式章节对Class文件类格式做出了详细的要求,可参考
  • 元数据验证,对字节码描述的信息进行语义分析。包括:这个类是否有父类,除了java.lang.Object其他类都应该有父类;这个类是否继承了不被允许继承的类,被final修饰的类;如果类不是抽象类是否实现了父类或者接口中要求实现的所有方法;类中字段,方法是否与父类产生矛盾,父类final字段,不符合重载等
  • 字节码验证,通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。包括:保证任意时刻操作数栈的数据类型与指令代码序列能配合工作,不会出现操作数栈放置int类型数据,使用时却按照long类型加载本地变量表中;保证跳转指令不会跳转到方法体以外的字节码指令上等
  • 符号引用,发生在虚拟机将符号引用转化为直接引用时候,动作发生在连接的第三个阶段——解析阶段。符号引用验证可以看做是对类自身以外的信息进行匹配性校验,通常有一下内容;符号引用通过字符串描述的全限定名能否找到相应的类;指定类中是否存在符合字段描述符以及简单名称描述的方法和字段;符号引用中的类、字段、方法的访问性(private、protected、public)是否可以被当前类访问等

引用阶段对虚拟机的类加载机制来说非常重要但非必须,如果运行的代码已经被反复验证通过,那么实施阶段可以考虑使用-Xverify:none参数关闭大部分的类验证措施,缩短虚拟机类加载机制的时间。

(3)准备

正式为类变量分配内存并设置类变量初始值的阶段,这些变量的内存都将在方法区中进行匹配。注意:变量是指类变量非实例变量,通常情况下是数据类型的零值,即

public static int value = 123;

在准备阶段value的值是0,只有经过初始化也就是类构造方法< clinit >方法后才会变成123。

(4)解析

虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口(CONSTANT_Class_info)、字段(CONSTANT_Fieldref_info)、类方法(CONSTANT_Methodref_info)、接口方法(CONSTANT_Interfaceref_info)、方法类型(CONSTANT_MethodType_info)、方法句柄(CONSTANT_MethodHandle_info)、调用限定符(CONSTANT_InvokeDynamic_info)。

符号引用:以一组符号来描述锁引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
直接引用:可以是直接指向目标的指针,先对偏移量或一个能间接定位到目标的句柄。

类或接口解析

假设当前代码所处类,如果把一个为解析过的符号引用解析为一个类或接口的直接引用,步骤为:

第一步:如果类或接口不是一个数组类型,虚拟机会把符号引用的全限定名传递给代码所在类的类加载器去加载这个类的符号引用。加载过程中,由于元数据验证、字节码验证的需要,可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦加载过程中出现了任何异常,解析过程宣告失败。

第二步:如果类或接口是一个数组类型,并且数组的元素类型为对象,也就是符号引用的描述符会是类似“[Ljava/lang/Integer”的形式,将会按照第一步的规则加载数组元素类型。如果符号引用的描述符如前面锁假设的形式,需要加载的元素类型为“java.lang.Integer”,接着虚拟机生成一个代表数组维度和元素的数组对象。

第三步:如果上面步骤没有出现异常,那么类或接口在虚拟机实际上已成为一个有效的类或者接口,但在解析完成之前还要进行符号引用验证,确认代码所在类是否具备对类或接口的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

字段解析

解析一个未被解析过的字段符号引用,首先对字段表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个字段所属的类或接口进行后续字段的搜索。

第一步:如果类或接口本身包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,查找结束。

第二步:否则,如果类或接口中实现了接口,将按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

第三步:否则,如果类或接口不是java.lang.Object的话,按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

第四步:否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果查找过程成功返回了引用,还将进行权限验证,如果发现不具备这个字段的访问权限,将抛出java.lang.IllegalAccessError异常。

类方法解析

解析一个未被解析过的类方法符号引用,首先对类方法表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个类方法所属的类方法的搜索。

第一步:类方法和接口方法符号引用的常量类型定义是分开的,如果类方法表中发现class_index中索引是接口,直接抛出java.lang.IncompatibleClassChangeError异常。

第二步:通过第一步,在类中查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。

第三步:否则,在类的父类中递归查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。

第四步:否则,在类实现的接口列表即它们的父接口中递归查找是否有简单名称和描述符都匹配的方法,如果有则返回这个方法的直接引用,查找结束。

第五步:否则,查找失败,抛出java.lang.NoSuchMethodError异常。

如果查找过程成功返回了引用,还将进行权限验证,如果发现不具备这个字段的访问权限,将抛出java.lang.IllegalAccessError异常。

接口方法解析

解析一个未被解析过的接口方法符号引用,首先对接口方法表内class_index项索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,将对这个接口方法所属的接口方法的搜索。

第一步:与类方法解析不同,如果接口方法表中发现class_index项索引的CONSTANT_Class_info的方法所属是类,将抛出java.lang.IncompatibleClassChangeError异常。

第二步:否则,在接口中查找是否有简单名称和描述符相匹配的接口方法,如果有则返回这个方法的直接引用,查找结束。

第三步:否则,在接口的父接口中递归查找,知道java.lang.Object类为止,是否有简单名称和描述符都与目标相匹配的接口方法,如果有则返回这个方法的直接引用,查找结束。

第四步:否则,查找失败,抛出java.lang.NoSuchMethodError异常。

由于接口中的所有方法默认为public abstract,不会存在访问权限问题,因此接口方法的符号解析不会出现java.lang.IllegalAccessError异常。

(5)验证

初始化阶段是执行类构造器< clinit >()方法的过程。

  • < clinit >()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块。编译器收集顺序是由语句在源文件中出现的顺序决定,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量可以赋值但不能访问。
  • < clinit >()方法与类的构造函数(实例构造函数< init >)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类< clinit >()方法执行之前,父类< clinit >()方法已经执行,所以在虚拟机中第一个执行的永远是java.lang.Object的< clinit >()方法。
  • 父类< clinit >()方法先执行,意味父类中定义的静态语句先于子类中静态语句执行。
  • < clinit >()方法非必须,如果一个类或接口中没有静态语句块也没有对类变量的赋值操作,编译器不为这个类或接口生成< clinit >()方法。
  • 接口中不能使用静态代码块,但仍然有变量初始化操作,因此和类一样,接口也会生成< clinit >()方法。但是,不同的是,执行接口的< clinit >()方法不需要先执行父类< clinit >()方法,只有父接口中的变量使用到时,才会执行< clinit >()方法。
  • 虚拟机保证一个类的< clinit >()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法结束。

3、类加载器

通过一个类的全限定名来获取描述此类的二进制字节流的动作放到虚拟机外实现,以便让应用程序决定如何去获取所需要的类。实现这个动作的代码模块称之为“类加载器”。

(1)类与类加载器

判断两个类是否相等的前提是类所在的类加载器是一致的,如果不一致,则类一定不相等。

(2)双亲委派模型

从Java虚拟机角度讲,只存在两种不同的类加载器,一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现;另一种是所有其他类加载器,由Java语言实现,独立于虚拟机外部,并且继承自抽象类java.lang.ClassLoader。

  • 启动类加载器(Bootstrap ClassLoader):< JAVA_HOME >\lib。负责存放虚拟机识别的类库加载到虚拟机内存中,启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用null代替即可。
  • 扩展类加载器(Extension ClassLoader):< JAVA_HOME >\lib\ext或者被java.ext.dirs系统变量指定路径中的所有类库。
  • 应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现。它负责加载用户类路径上所指定的类库。

双亲委派模型要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父类加载器。过程:如果一个类加载器收到类加载请求,首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器完成,每一层的类加载器都是如此,直到传到顶层启动类加载器中,只有当父加载器反馈无法完成这个加载请求,才会下放到子类加载器中执行。

(3)破坏双亲委派模型

  • 双亲委派机制在JDK1.2中引入,而类加载器和抽象类java.lang.ClassLoader则在1.0时代已经存在,需要满足向前兼容。java.lang.ClassLoader添加了一个新的protected findClass()方法,在此之前是用户继承java.lang.ClassLoader唯一目的是重写loadClass()方法,因为虚拟机在进行类加载时调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑是调用自己的loaderClass()。
  • 模型自身缺陷所致。出现基础类调用回用户代码,怎么办?设计线程上下文类加载器(Thread Context ClassLoader),可以通过java.lang.Thread类的setContextClassLoader()设置。
  • 用户追求程序动态性导致,代码热替换、模块热部署等。OSGi环境中,类加载不再是双亲委派模型的树状结构,而是进一步发展成为更复杂的网状结构。第一步,将以java.*开头的类委托给父类加载器加载;第二步,否则将委派列表名单内的类委派给父类加载器加载;第三步,否则将import列表中的类委派给export这个类的Bundle的类加载器加载;第四步,否则查找当前Bundle的ClassPath,使用自己的类加载器加载;第五步,否则查找类是否在自己的FragmentBundle中,如果在则委派给FragmentBundle的类加载器加载;第六步,否则查找DynamicImport列表中的Bundle,委派给对应Bundle的类加载器加载;最后,类查找失败。

猜你喜欢

转载自blog.csdn.net/zhangwei408089826/article/details/81667803