虚拟机加载机制

前言

自上一篇Claas文件结构,我们了解Java代码编译成的字节码存储的具体细节。

本篇文章,我们需要理解虚拟机加载Class文件的过程。

环境:

操作系统:win10

JDK版本:1.8.0_291

本文所有内容都是基于以上环境的,如果有异议的地方欢迎邮件交流。

加载链接初始化

开始之前,我们先大致浏览下,类的生命周期。

类的生命周期

其主要分为LoadingLinkingInitializing这三个阶段,下面解释这三个阶段大致所做的工作:

  • Loading:加载是找到具有特定名称的类或接口类型的二进制表示,并从该二进制表示创建类或接口的过程
  • Linking:链接是获取一个类或接口并将其组合到Java虚拟机的运行时状态以便执行的过程
  • Initializing:类或接口的初始化包括执行类或接口初始化方法<clinit>

运行时常量池

jvm维护了一个包含所有类型的常量池,是运行时结构,用以实现符号表。

类或者接口的常量池组成运行时常量池,运行时常量池中所有的引用最初都是符号引用,运行时常量池的引用来源于以下几个方面:

  • 类或者接口的符号引用,是源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_Class_info结构,这样的一个类或者接口的引用以Class.getName方法的返回值命名,其中:

    • 对于非数组接口,名称是类或者接口的二进制名称,例如:java/lang/Thread
    • 对一个n维数组,名称以n个[开头,例如:*[[java/lang/Thread,表示二维数组*
      • 如果数组元素是基本类型,那么通过相应的字段描述符对应表表示,例如:*[I,表示整数数组*
      • 如果是引用类型,那么以L开头,*;结尾,中间是二进制名称,例如:[[Ljava/lang/Integer; ,表示Integer二维数组*
  • 字段的符号引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_Fieldref_info结构。这个引用给出了该字段名称和描述符,以及字段所在类或者接口的符号引用。

  • 方法的符号引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_Methodref_info结构。这个引用给出了该方法名称和描述符,以及方法所在类或者接口的符号引用。

  • 接口的符号引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_InterfaceMethodref_info结构。这个引用给出了该接口名称和描述符,以及接口的符号引用。

  • 方法句柄(可以看作引用)的符号引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_MethodHandle_info结构。这个引用给出了给出了对类或接口的字段、类的方法或接口的方法的符号引用,这取决于方法句柄的类型。

  • 方法类型的符号引用引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_MethodType_info结构。这个引用指向方法描述符

  • 调用点的符号引用源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_InvokeDynamic_info,这个比较复杂,我会单独抽出来讲

  • 字符串字面量是String对象的引用,源于CONSTANT_String_info,这个结构给出组成字符串字面量的Unicode的码点。Java中规定,相同字面量(也就是码点序列一样)的字符串必须指向相同的String的引用。除此之外,调用String.intern方法,返回和这个字符串字面量相同的字符串对象的引用,例如:

    ("a" + "b" + "c").intern() == "abc";//true
    new String("abc") == "abc"; //false
    new String("abc").intern() == "abc"; //true
    

    为了得到字符串字面量,JVM检查CONSTANT_String_info结构的码点序列

    • 如果方法String.intern之前在一个String类的实例上被调用过,该实例包含与CONSTANT_String_info结构体相同的Unicode码位序列,那么字符串字面量的结果是对String类的同一个实例的引用
    • 否则,将创建一个新的String类实例,其中包含由CONSTANT_String_info结构体给出的Unicode码位序列;对该类实例的引用是字符串字面量派生的结果。最后,调用新的String实例的intern方法
  • 运行时常量值源于类或者接口的二进制表示(class文件,io流等)中CONSTANT_Integer_info,CONSTANT_Float_info,CONSTANT_Long_info,CONSTANT_Double_info。

虚拟机启动

Java虚拟机通过使用bootstrap类加载器创建初始类来启动,该类以依赖于实现的方式指定。然后,Java虚拟机链接初始类,对其进行初始化,并调用公共类方法void main(String[])。此方法的调用将驱动所有进一步的执行

创建和加载

在jvm方法区中创建一个名称是N表示的类或者接口C,它是C的一个特定的内部实现。创建过程是由另一个类或者接口D触发,通过运行时常量池引用C触发。类或接口的创建也可以通过D调用某些Java SE平台类库中的方法来触发,比如反射。

总结就是,创建过程是被动的,要么是别人调用你触发,要么是调用反射接口触发

如果C不是数组,那么通过加载C的二进制表示(字节码文件)来加载它;如果是数组,没有二进制表示形式,直接通过虚拟机创建。

类加载器L通过两种方式创建C,直接定义或者委托给另外一个类加载器,具体:

  • 如果L直接创建了C,我们说L定义了C(L defines C),等价的L是C的defining loader
  • 如果L委托另一个类加载器创建C,我们说L启动了C的加载(**L initiates loading of C **),等价的L是C的initiating loader

运行时,类或者接口通过它的二进制名称和它的定义类加载器确定;同时,每个这样的类或接口都属于一个运行时包,而运行时包也是由包名和类的定义类加载器确定。

一个好的类加载器,应该维护三个属性:

  1. 给定相同的名字,一个好的类加载器应该始终返回相同Class对象
  2. 如果一个类加载器L1委托类加载器L2去加载类C,那么对于任何类型T(可以是C的直接父类或者直接父接口,可以是C的属性字段,可以是C中方法或者构造器的形参,也可以是C中方法的返回类型),L1和L2应该返回相同的Class对象
  3. 如果用户定义的类加载器预取类和接口的二进制表示,或者一起加载一组相关的类,那么它必须只在程序中没有预取或组加载时可能出现加载错误的地方反映加载错误

使用Bootstrap类加载器加载

下面步骤用于使用bootstrap类加载器,加载创建非数组类或者接口(用C表示类),并用N表示其类名称的过程:

  1. JVM首先确定是否bootstrap加载器被记录为接口或类N的初始化加载器。如果是的,那么这个类或者接口就是C,无需重新创建;如果不是,jvm将参数N传递给bootstrap类加载器上的方法调用,去搜索C。

    注意:

    不能保证所找到的所谓表示是有效的,或者是c的表示。

    这个加载阶段必须检测到以下错误:如果没有找到所谓的C表示,加载时会抛出ClassNotFoundException的实例

  2. JVM虚拟机会使用bootstrap类加载器生产一个用N表示的类C

使用用户自定义的类加载器加载

下面步骤用于使用用户自定义的类加载器L,加载创建非数组类或者接口(用C表示类),并用N表示其类名称的过程:

JVM首先确定是否自定义加载器L被记录为接口或类N的初始化加载器。如果是的,那么这个类或者接口就是C,无需重新创建;

如果不是,JVM调用L上的loadClass(N),并返回创建好的类或者接口C。并且,JVM记录L是C的初始化加载器。

创建数组类

下面步骤用于使用类加载器L(N可以是bootstrap加载器,可以是自定义加载器),加载创建数组类(C表述数组类),并用N表示其类名的过程:

  1. JVM首先确定是否L加载器被记录为数组类N中类型(指数组元素的类型)的初始化加载器。如果是的,那么这个数组类或者接口就是C,无需重新创建

  2. 不是的话,在看元素组件的类型。如果是引用类型,那么则递归调用L创建C的元素的类型。然后JVM根据明确类型创建一个新的数组类。此时,如果C是引用类型,那么C被标记为C元素的define loader加载;否则,C被标记为由bootstrap加载。

    但无论如何,C都会记录L是C的initiating loader

链接

链接一个类或者接口包括验证和准备这个类或者接口,它的直接父类,它的直接父接口,和它的元素类型(如果它是一个数组类型)。解析类或者接口中的符号引用是可选部分。

JVM规范在链接活动发生时灵活的实现,但是必须要维护以下几个点:

  • 类或接口在被链接之前是完全加载的。
  • 类或接口在初始化之前要完全验证和准备。
  • 在链接期间检测到的错误会在程序中的某个点抛出,在这个点上,程序采取了一些操作,这些操作可能直接或间接地要求链接到错误中涉及的类或接口。

因为链接涉及到新数据结构的分配,所以它可能会失败并出现OutOfMemoryError错误。

验证

验证确保类或者接口的二进制表现形式结构正确,可能会造成额外的类或者接口被加载,但是无需对这些额外的类或者接口进行验证。

如果类或者接口的二进制结构不对,必须抛出VerifyError异常。

准备

准备涉及到为类或者接口创建静态字段,初始化这些字段为默认值。注意这个过程不需要调用JVM代码,显示初始化字段(类似:a = 1这样的操作)并不属于准备这个过程。

准备可以在创建阶段之后任何时间节点发生,但是一定要在初始化之前完成。

解析

JVM指令anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokspecial、invokestatic、invokvirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic对运行时常量池进行符号引用。执行任何这些指令都需要解析其符号引用。

符号引用:类似这样的,java/lang/Thread,*[I,表示整数数组[[java/lang/Thread,表示二维数组*

解析过程就是根据符号引用从运行时常量池中动态确定具体值的过程

初始化

类或接口的初始化包括执行其类或接口初始化方法。

类或者接口C只在以下情况初始化:

  • JVM指令new , getstatic or invokestatic中任意一个引用到C。这些指令通过字段引用或者方法引用直接或间接引用C。
  • 方法句柄(java.lang.invoke.MethodHandle)解析到句柄类型2(REF_getStatic),4(REF_putStatic),6(REF_invokeStatic),8(REF_invokeStatic)
  • 在类库中调用反射方法,例如Class.forObject
  • 如果C是一个类,初始化它的子类
  • 如果C是非抽象或者非静态的接口,则直接或间接实现C的类初始化
  • 如果C事一个类,被设计为JVM初始化类

注意:

在初始化之前,必须要被链接包括验证,准备,有选择性的解析

假设有类或者接口C,唯一的初始化锁LC。

下面给出C的初始化过程

  1. 为C同步获取初始化锁LC,等待直到获取成功
  2. 如果类对象C显示被其他线程正在初始化C,那么久释放当前线程锁LC并阻赛当前线程,知道通知其他线程初始化成功,在继续重复这个过程
  3. 如果类对象C显示被当前线程正在初始化C,那么这肯定是一个递归调用初始化,释放LC并完成
  4. 如果类对象C显示C已经被初始化,无需操作,释放LC并完成
  5. 如果类对象C显示C处于错误状态,初始化为成功,释放LC,并抛出一个NoClassDefFoundError
  6. 否则,记录当前线程正在对C进行初始化,并释放LC
  7. 下一步,如果C是一个类而不是接口,并且它的父类并没有被初始化。然后让SC是它的父类,并且让SL1。。SLn是C的所有的父接口(至少声明一个non-abstract,non-static方法),不论是直接或者间接。父接口的顺序由C实现的文件层次结构枚举给出。对于C直接实现的接口,按照C接口数组的顺序。
  8. 下一步通过查询C的defining loader来确认是否启用断言
  9. 然后执行C的初始化方法
  10. 如果类或接口初始化方法的执行正常完成,则获取LC,将C的class对象标记为完全初始化,通知所有等待的线程,释放LC,并正常完成此过程。
  11. 否则,类或接口初始化方法必须通过抛出一些异常E突然完成
  12. 获取LC,将C的Class对象标记为错误,通知所有等待线程,释放LC,并突然以原因E或其替换(如前一步所确定的)完成此步

绑定本地方法实现

绑定是将用Java编程语言以外的语言编写并实现本机方法的函数集成到Java虚拟机中以便执行的过程。尽管这个过程传统上被称为链接,但规范中使用术语绑定是为了避免与Java虚拟机的类或接口链接混淆。

退出虚拟机

当某个线程调用类Runtime或类System的退出方法或类Runtime的暂停方法,并且安全管理器允许退出或暂停操作时,Java虚拟机退出。

此外,JNI (Java本机接口)规范描述了当使用JNI调用API加载和卸载Java虚拟机时Java虚拟机的终止。