Java虚拟机学习(四)-类的加载机制

摘要

在Java虚拟机中,经过编译的Java文件都通过Class文件格式存储。当执行Java程序时,需要将Class文件加载到Java虚拟机内存中。从虚拟机把类的Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被Java虚拟机使用的Java类型,这就是虚拟机的类加载机制。

简介

注意:以下的类都默认表示为类或接口,在Java虚拟机中类或接口有着相似的加载过程。
在Java虚拟机中类的加载过程就是从类加载到内存中开始,到卸载出内存为止,整个生命周期可以概括为:加载、验证、准备、解析、初始化、使用和卸载。在Java虚拟机规范中,又可以概括为:加载、链接(验证、准备、解析)、初始化。
那么类在什么时候开始加载的第一个阶段呢?Java虚拟机规范中并没有强制的约束。但是在Java虚拟机规范中严格规定了5种情况必须立即对类进行“初始化”,当然在初始化阶段开始时,之前的阶段在“初始化”之前开始了。

类加载的过程

加载是类加载过程的第一个阶段。在加载过程中,虚拟机的主要工作是通过类的全限定名获取此类的二进制字节流,然后通过类的二进制表示的获得类在运行时常量池中的符号引用。然后Java虚拟机在方法区为类创建与虚拟机实现规定相匹配的内部结构。最后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

如果类不是数组类,那么它就可以直接通过类加载器加载类的二进制表示来创建Class对象。而数组类型没有外部的二进制表示,它不通过类加载器加载,而是直接由Java虚拟机创建。

类是通过类加载器加载的,Java虚拟机支持两种类加载器:Java虚拟机提供的引导类加载器(Bootstrap Class Loader)和用户自定义类加载器(User-Defined Class Loader)。每个用户自定义的类加载器应该是抽象类ClassLoader的某个子类的实例。应用程序使用用户自定义类加载器是为了便于扩展Java虚拟机的功能,支持动态加载并创建类。

在加载类的过程中被加载的类在Java虚拟机中就会有唯一确定的类型,判断条件就是通过类的全限定名和加载它的类加载器确定。

类加载阶段的过程:

  • 使用引导类加载器加载类型:对于非数组类型的类。首先Java虚拟机检查引导类加载器是否是已经加载过标记为N的类的初始化加载器,如果是,就创建相应的类型。否则,Java虚拟机将参数N传递给引导类加载器的特定方法,并且搜索类的描述。如果没有找到与类相关的描述,加载过程要抛出ClassNotFoundException 异常。
  • 使用用户自定义类加载器加载类型:对于非数组类型的类。Java虚拟机先检查用户自定义类加载器是否是已经加载过类的初始化加载器。如果是,就加载类。否则,Java虚拟机调用用户自定义类加载器的loadClass()方法创建类。
  • 创建数组类:如果组件类型(数组去除维度后的类型)是引用类型,就使用类加载器递归加载和创建数组类的组件类型。 Java虚拟机使用显式的组件类型和数组维度来创建新的数组类。如果组件类型是引用类型,就被标记为它已经被该组件类型的定义类加载器定义过。否则,就被标记为它被引导类加载器定义过。不管哪种情况,Java虚拟机都会把该类加载器记录为初始加载器。如果数组的组件类型是引用类型,数组类的可见性就由组件类型的可见性决定,否则,数组类的可见性将被默认为public。

验证是链接的第一阶段,为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。

验证主要有文件格式验证、元数据验证、字节码验证、引用符号验证4个阶段。

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版 本虚拟机处理。比如:主、、次版本号是否在当前虚拟机处理范围内;常量池的常量是否有不被支持的类型;指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。比如:这个类是否有父类(出了java.lang.Object之外,所有的类都应当有父类);这个类是否有继承了不允许被继承的类(被final修饰的类);如果这个类不是抽象类,是否实现了其父类或接口要求实现的所有方法。
  • 字节码验证:通过数据流和控制流分析,确保程序语义是合法的、符合逻辑的。比如:保证任意时刻操作数栈的数据类型与指令序列都能配合工作;保证跳转指令不会跳转到方法体意外的字节指令上;保证方法体中的类型转化是有效的。
  • 符号引用验证:对类在运行时常量池中的各种符号引用的信息进行匹配性校验。比如:符号引用中通过字符串描述的全限定名能否找到相应的类;符号引用中的类、字段、方法的访问权限是否可以被当前类访问。如果符号引用校验失败,可能抛出java.lang.IncompatibleClassError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

准备阶段的目的是为类或接口的静态字段分配空间(在方法区中进行分配),并用默认值初始化这些字段(不进行赋值)。这个阶段不执行任何虚拟机字节码指令,在初始化阶段会有显示的初始化器来初始化这些静态字段(首次赋值),所以准备阶段不做这些事情。

解析阶段是根据运行时常量池的符号引用来动态决定值的过程。Java虚拟机指令anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic将符号引用指向运行时常量池。执行上述任何一条指令都需要对它的符号引用的进行解析。

对于同一个符号引用Java虚拟机可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用)从而避免重复解析。但是invokedynamic指令不适用上述规则,因为invokedynamic指令是用于动态语言的支持。它所对应的指令称为“动态调用点限定符”,动态的意思就是在程序实际运行到这条指令时,解析动作才能进行。相对的,其余可以触发解析动作的指令都是静态的,可以再完成加载阶段,还没有开始执行代码的时候进行解析。

在Java虚拟机中解析有不同的类型,解析的动作主要针对类或接口、字段、方法、方法类型、方法句柄和调用点限定符。不同的解析类型方式也不一样。

  • 类或接口的解析:Java虚拟机为了解析D(类或接口)中标记为N的类或接口C的未解析符号引用,会执行以下步骤:如果C不是数组类型,Java虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C;如果C是一个数组类型,并且数组的元素类型是对象,将会按照第一步的规则加载数组的元素类型;最后检查C的访问权限,如果C对D不可见,则抛出IllegalAccessError异常。
  • 字段解析:要解析一个未被解析过的字段符号引用,首先将会对字段表class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。因此,在解析类或接口引用时发生的任何异常都可以当作是解析字段引用的异常一样被抛出。(1 当解析一个字段引用时,字段解析会在C和它的父类中先尝试查找这个字段:如果C中声明的字段与字段引用有相同的名称及描述符,那么此次查找成功。字段查找的结果是C中那个声明的字段;(2 不然的话,字段查找就会被递归应用到类或接口C的直接父接口上;(3 否则,如果C 有一个父类S,字段查找也会递归应用到S上;(4 如果还不行,那么字段查找失败。
  • 类方法解析:类方法的解析的第一个步骤和字段解析一样,也性需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用。因此,在解析类的符号引用时出现的任何异常都可以看作解析方法引用的异常而被抛出。当解析一个方法引用时:(1 首先检查方法引用中的C是否类或接口;如果C是接口,那么方法引用就会抛出IncompatibleClassChangeError 异常;(2 方法引用解析过程会检查C 和它的父类中是否包含此方法:如果C中确有一个方法与方法引用的指定名称相同,并且声明是签名多态方法,那么方法的查找过程就被认为是成功的。所有方法描述符中所提到的类也需要解析;这次解析的方法是签名多态方法。否则,如果C声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功。如果C有父类的话,那么如第2步所述的查找方式递归查找C的直接父类。(3 方法查找过程也会试图从C的父接口中去定位所引用的方法。如果C的某个父接口中确实声明了与方法引用相同的名称与描述符的方法,那么方法查找成功。否则,方法查找失败。
  • 接口方法解析:接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,则继续接口方法查找。(1 如果C 不是一个接口,那么接口方法解析就会抛出IncompatibleClassChangeError 异常。(2 否则,如果在C 与它的父接口(或Object 类)中也不存在与接口方法描述符相同的方法的话,接口方法解析会抛出NoSuchMethodError 异常。
  • 方法类型解析:为了解析方法类型(Method Type)的未解析符号引用,此方法类型所封装的方法描述符中所有的类型符号引用(Symbolic References To Classes)都必须先被解析因此,在解析这些类型符号引用的过程中如果有任何异常发生,也会当作解析方法类型的异常而被抛出。解析方法类型的结果是得到一个对java.lang.invoke.MethodType 实例的引用,它可用来表示一个方法的描述符。
  • 调用限定点解析:解析一个未被解析过的调用点限定符(Call Site Specifier)需要下列三个步骤: (1 调用点限定符提供对方法句柄的符号引用,它作为引导方法(Bootstrap Method)向动态调用点提供服务。解析这个方法句柄(§5.4.3.5)是为了获取一个对java.lang.invoke.MethodHandle实例的引用。 (2 调用点限定符提供了一个方法描述符,记作TD。它是一java.lang.invoke.MethodType实例的引用。可以通过解析与TD有相同的参数及返回值的方法类型(§5.4.3.5)的符号引用而获得。 (3 调用点限定符提供零至多个静态参数(Static Arguments),用于传递与特定应用相关的元数据给引导方法。静态参数只要是对类、方法句柄或方法类型的符号引用,都需要被解析,例如调用ldc指令,可以分别获取到对Class对象、java.lang.invoke.MethodHandle对象和java.lang.invoke.MethodType对象等。如果静态参数都是字面常量,它就是为了获取对String对象的引用。 调用点限定符的解析结果是一个数组,它包含:到java.lang.invoke.MethodHandle实例的引用,到java.lang..invoke.MethodType实例的引用,到Class,java.lang.invoke.MethodHandle,java.lang.invoke.MethodType和String实例的引用。 在解析调用点限定符的方法句柄符号引用,或解析调用点限定符中方法类型的描述符的符号引用时,或是解析任何的静态参数的符号引用时,任何与方法类型或方法句柄解析)有关的异常都可以被抛出。

初始化(Initialization)对于类或接口来说,就是执行它的初始化方法。在发生下列行为时,类或接口将会被初始化:

  • 在执行下列需要引用类或接口的Java虚拟机指令时:new,getstatic,putstatic或invokestatic。这些指令通过字段或方法引用来直接或间接地引用其类。执行上面所述的new指令,在类或接口没有被初始化过时就初始化它。执行上面的getstatic,putstatic或invokestatic指令时,那些解析好的字段或方法中的类或接口如果还没有被初始化那就初始化它。
  • 在初次调用java.lang.invoke.MethodHandle实例时,它的执行结果为通过Java虚拟机解析出类型是REF_getStatic、REF_putStatic或者REF_invokeStatic的方法句柄。
  • 在调用JDK核心类库中的反射方法时,例如,Class类或java.lang.reflect包。
  • 在对于类的某个子类的初始化时。
  • 在它被选定为Java虚拟机启动时的初始类时。

在类或接口被初始化之前,它必须被链接过,也就是经过验证、准备阶段,且有可能已经被解析完成了。

因为Java虚拟机是支持多线程的,所以在初始化类或接口的时候要特别注意线程同步问题,可能其它一些线程也想要初始化相同名称的类或接口。也有可能在初始化一些类或接口时,初始的请求被递归要求初始化它自己。Java虚拟机实现需要负责处理好线程同步和递归初始化,具体可以使用下面的步骤来处理。这些处理步骤假定Class对象已经被验证和准备过,并且处于下面所述的四种状态之一:

  • Class对象已经被验证和准备过,但还没有被初始化。
  • Class对象正在被其它特定线程初始化。
  • Class对象已经成功被初始化且可以使用。
  • Class对象处于错误的状态,可能因为尝试初始化时失败过。

每个类或接口C,都有一个唯一的初始化锁LC。如何实现从C到LC的映射可由Java虚拟机实现自行决定。例如,LC可以是C的Class对象,或者是与Class对象相关的管程(Monitor)初始化C的过程如下:

  1. 同步C的初始化锁LC。这个操作会导致当前线程一直等待直到可以获得LC锁。
  2. 如果C的Class对象显示当前C的初始化是由其它线程正在进行,那么当前线程释放LC并进入阻塞状态,直到它知道初始化工作已经由其它线程完成,那么当前线程在此重试此步骤。
  3. 如果C的 Class对象显示C的初始化正由当前线程在做,这就是对初始化的递归请求。释放LC并正常返回。
  4. 如果C的Class对象显示Class已经被初始化完成,那么什么也不做。释放LC并正常返回。
  5. 如果C的Class对象显示它处于一个错误的状态,就不再初始化了。释放LC并抛出NoClassDefFoundError异常。
  6. 否则,记录下当前线程正在初始化C的Class对象,随后释放LC。根据属性出现在ClassFile的顺序,利用常量池中的ConstantValue属性(§4.7.2)来初始化C中的各个final static字段。
  7. 接下来,如果C是类而不是接口,而且它的父类SC还没有被初始化过,那就对于SC也进行完整的初始化过程。当然如果必要的话,需要先验证和准备SC。如果在初始化SC的时候因为抛出异常而中断,那么就获取LC后将C的Class对象标识为错误状态,并通知所有正在等待的线程,最后释放LC并异常退出,抛出与SC初始化遇到的异常相同的异常。
  8. 之后,通过查询C的定义加载器来决定是否为C开启断言机制。
  9. 执行C的类或接口初始化方法。
  10. 如果正常地执行了类或接口的初始化方法,之后就请求获取LC,标记C的Class对象已经被完全初始化,通知所有正在等待的线程,接着释放LC,正常地退出整个过程。
  11. 否则,类或接口的初始化方法就必须抛出一个异常E并中断退出。如果E不是Error或它的某个子类,那就创建一个新的ExceptionInInitializerError实例,然后将此实例作为E的参数,之后的步骤就使用E这个对象。如果因为OutOfMemoryError问题而不能创建ExceptionInInitializerError实例,那在之后就使用OutOfMemoryError异常对象作为E的参数。
  12. 获取LC,标记下C的Class对象有错误发生,通知所有的等待线程,释放LC,将E或上述的具体错误对象作为此次意外中断的原因。虚拟机在具体实现时可以通过省略第1步在检查类初始化是否完成时的锁获取过在第4、5步时释放)而获得更好的性能。允许这样做,是因为在Java内存模型中的happens-before规则在锁释放后依然存在,因此可以进行相应的优化。
总结

总的来说,Java的类加载机制就是在我们使用Java类之前Java虚拟机帮助我们完成准备工作的一个过程。
加载阶段:将类的二进制字节流加载为方法区中的数据结构,并在Java堆中生成Class对象。
验证:确保类或接口的二进制表示结构是正确的。
准备:为类或接口的静态字段分配空间,并初始化为默认值。
解析:根据运行时常量池的符号引用动态的决定具体值。
初始化:执行类或接口的初始化方法。

猜你喜欢

转载自blog.csdn.net/programerxiaoer/article/details/79406440