java虚拟机随手笔记(6)虚拟机类加载机制

时间:2022-12-29 09:47:20

上一章我们了解了class文件的基本结构,下面我们要做的就是来了解虚拟机是如何将class文件加载的了-----虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

java是运行时动态加载和动态链接的,虽然可能会牺牲一些性能开销,但是赋予了java无比强大的灵活性。


类加载的时机

java虚拟机随手笔记(6)虚拟机类加载机制
类从被加载到虚拟机内存开始,到卸载出内存位置,整个生命周期如上图所示。上图加载,验证,准备,初始化,卸载这5个阶段的先后顺序是确定的。但是解析不一定,它视java运行时绑定的时间有关,可能在初始化之后再解析。 对于初始化阶段,虚拟机规范严格规定了只有5中情况必须进行立即初始化:
  1. 遇到new,getstatic,putstatic或invokestatic这4条字节码指令时。如果累没有进行过初始化,则需要先出发其初始化。(使用new关键字,读取或设置一个类的静态字段(被final修饰的static字段除外,它在常量池中)),以及使用一个静态方法的时候(这也说明了为什么A.method()会触发构造函数的调用(如果A没有被初始化过))
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有初始化过,就需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类,虚拟机会先初始化这个主类)
  5. 当使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MehtodHandle实例最后的解析结果REF_getStatic、 REF_putStatic、 REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
上述5种方法称为主动引用,除此之外,所有引用类的方式都是被动引用。举几个常见的被动引用的例子
  1. 比如一个子类引用了一个父类的静态变量,那么只会触发父类的初始化,不会触发子类的初始化。A exntedns B,A.b(b是B的静态属性)只会触发B的实例化,
  2. 通过数组定义来引用类,不会触发这个类的初始化 ;A[] a = new A[];这是虚拟机自动生成的类,这个不会触发A的初始化
  3. static final修饰的变量是java中的常亮,调用这个常量不会触发类的初始化。

类加载的过程(加载,验证,准备,解析和初始化)


1.加载:加载是类加载过程的一个阶段,主要完成以下3件事情:
  • 通过一个类的全限定名来获取此类定义的二进制流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。 但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类(下面简称为C)创建过程就遵循以下规则:
  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,在7.4节会介绍到,一个类必须与类加载器一起确定唯一性)。
  • 如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组C标记为与引导类加载器关联。
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。
对于第三步在内存中生成的Class对象,并没有规定是存在堆中的,对于hotspot虚拟机,这个对象是存在方法区里面。这个对象将作为程序访问方法区的数据的外部接口。

2.验证:验证是连接阶段的第一步(连接阶段和加载阶段式交叉进行的,不一定加载完毕才会连接。)我们知道,对于class文件,不一定全部来自于java源码文件编译而来,可以使用16进制工具直接编辑,这就可能以为着有各种非法操作产生(比如数组越界,比如非法类型转化,跳转到不存在的区域等):主要包括文件格式验证,元数据验证,字节码验证和符号引用验证。
  • 文件格式验证:主要验证字节流是否符合class文件格式规范(上一章讲的class文件格式)
  • 元数据验证:对字节码描述的信息进行语义分析,保证描述的信息符合java语言规范(是否有父类,是否继承了不该继承的类,是否实现了接口的方法,是否。。。)
  • 字节码验证:最复杂的阶段,通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的,如保证任意时刻操作数栈的合理性,保证跳转的合理性,保证类型转换有效等等。
  • 符号引用验证:这个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转换动作将在连接的第三阶段--解析中发生。需要校验符号引用能否找到对应的类或者对象或者方法等。
3.准备:准备阶段式正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的的内存都在方法区中分配。这个时候进行的内存分配的仅包含类变量(static修饰的变量),而不包括实例变量,实例变量将会在对象被实例化时随着对象一起被分配到java堆中。这里说的初始值知识默认分配的初始值,比如public static int value=123,准备阶段将会被初始化为0。赋值为123将会在初始化阶段才会执行。有一种特殊情况,被static final修饰的常量将会直接被初始化为指定的值。

4.解析:符号引用转化为直接引用的过程。前面说过,解析可能会和初始化交替进行,同时,解析阶段也会进行符号引用验证。这个阶段是将常量池中的符号引用替换为直接引用的过程。会和初始化交替执行的原因符号引用可能在用到的时候才会进行解析。就是这样。 解析动作主要针对类或者接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、 CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、 CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7中常量类型。
  • 类或接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:
    1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。 在加载过程中,由于元数据验证、 字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。 一旦这个加载过程出现了任何异常,解析过程就宣告失败。
    2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。 如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
    3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。 如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
  • 字段解析:要解析未被解析过的字段符号引用,首先会对字段表内class_index相中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或者接口的符号引用。java虚拟机随手笔记(6)虚拟机类加载机制
    如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对类C进行后续字段的搜索。
    1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    2)否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。
    如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
  • 类方法解析
    类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的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异常。
  • 接口方法解析
    接口方法也需要先解析出接口方法表的class_index[4]项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。
    1)与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。
5.初始化:初始化是类加载的最后一步,前面的过程都是主要有虚拟机控制执行的(除了自定义类加载器)。到了初始化阶段,才开始正式执行类中定义的java程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段其中一步是初始化类变量以及静态块的过程,即执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。收集的顺序是按照语句在源文件中出现的顺序所决定的。<clint>()方法会保证第一个执行的初始化过程肯定是Object类。如果没有类变量或者静态语句块,那么编译器可以不生成这个方法,而且这个方法的执行是线程安全的,也就是说类变量以及静态块只能由一个线程初始化!

类加载器

java语言的一项创新就是实现了一个代码模块类加载器,虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块被称为类加载器。 1.类与类加载器 java虚拟机随手笔记(6)虚拟机类加载机制

它可以加载与自己在同一路径下的Class文件。 我们使用这个类加载器去加载了一个名为“org.fenixsoft.classloading.ClassLoaderTest”的类,并实例化了这个类的对象。 两行输出结果中,从第一句可以看出,这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实例化出来的对象,但从第二句可以发现,这个对象与类org.fenixsoft.classloading.ClassLoaderTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个Class文件,但依然是两个独立的类,做对象所属类型检查时结果自然为false

2.双亲委派模型 从java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。类加载器双亲委派模型如下图所示 java虚拟机随手笔记(6)虚拟机类加载机制

  • 启动类加载器(Bootstrap ClassLoader:前面已经介绍过,这个类将器负责将存放在<JAVA_HOME\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。 启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher $AppClassLoader实现。 由于这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。 例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。 相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。 如果读者有兴趣的话,可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行。

类加载器部分还是需要再看的,关键是这涉及到Java的反射机制,需要仔细的看。