JVM的类加载和字节码执行引擎

时间:2022-05-01 17:33:17

在上篇《JVM的Class文件结构》中,简单梳理了Class文件内容,然而这只是一个小小的开始。Class文件只是一个你所编写的程序信息的等价完备的存储,它是死的,若要让它活起来,就需要将Class文件加载到虚拟机中并运行。

Class文件的类加载过程是怎样?进入到虚拟机中的Class文件信息又会以什么样的形式存储?虚拟机如何找到应该执行的方法?虚拟机如何执行Class文件中的字节码?这些都是需要思考的问题。

一、类加载

C/C++在编译时就需要进行连接工作,而在Java里,类型的加载、连接和初始化都是在运行期完成的。这为Java应用程序提供了高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期如下图:

JVM的类加载和字节码执行引擎

先明确几个要点:

  • 区分“加载(Loading)”和“类加载(Class Loading)”:“加载”是“类加载”过程中的一个阶段,“类加载”过程包括“加载”、“验证”、“准备”、“解析”、“初始化”。
  • “加载”、“验证”、“准备”、“初始化”、“卸载”这五个阶段的开始顺序是确定的,而“解析”阶段既可以在初始化前开始,也可以在初始化后真正使用前开始。
  • “验证”、“准备”、“解析”这三个阶段统称为“连接(Linking)”。

虚拟机规范严格规定有且仅有以下五种情况必须立即对类进行初始化(而加载、验证、准备阶段自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,若类没有进行过初始化,则需要先触发其初始化。
  2. 使用java.lang.reflect包的方法对类进行反射调用时,若类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,若发现其父类还没有进行过初始化,则需要先出发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的包含main方法的主类,虚拟机会先初始化这个主类。
  5. 当使用JDK1.7的动态语言支持时,若一个java.lang.invoke.MedthodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStaic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上这五种场景的行为称为对一个类进行主动引用,除此以外所有引用类的方式都成为被动引用,不会触发初始化,摘录几个例子如下:

  1. 通过子类引用父类的静态字段,不会导致子类初始化。
  2. 通过数组定义来引用类,不会出发此类的初始化。
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类没,不会触发定义常量的类的初始化。

1.1、加载

首先需说明,相对与类加载过程的其它阶段,一个类的加载阶段,更准确地说是加载阶段中获取类的二进制字节流的动作,是开发人员可控性最强的。虚拟机规范没有指明和限定要从哪里获取、怎样获取类的二进制字节流,所以开发人员可以通过自定义类加载器去控制字节流的获取方式,这个“缺口”引发了开发人员在这里的各种玩法,成为Java技术的一大亮点所在。

在加载阶段,虚拟机需要完成以下三件事情:

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

1.2、验证

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

验证阶段大致上会完成四个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。当通过了文件格式验证后,字节流就会进入内存的方法区中进行存储,后面的三个阶段的验证全部都是基于方法区的存储结构进行的,不会再直接操作字节流。

1.3、准备

准备阶段是正式为类变量(即被static修饰的变量,不包括实例变量)分配内存并设置类变量初始值(通常情况下是数据类型的零值)的阶段。

1.4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。然而虚拟机规范中并未规定解析阶段发生的具体时间,只要求在执行anewarray、multianewarray、new、getfield、putfield、getstatic、putstatic、checkcast、instanceof、invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic、ldc、ldc_w 这16个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

(一)类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那么虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C(类D和类C会被相同的类加载器所加载)。在加载类C的过程中,又可能触发其他的加载动作,比如加载这个类的父类或实现的接口。

(二)字段解析

(1) 首先会解析字段所属的类或接口的符号引用。(2)接着,若类本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。(3)否则,若在类中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口。(4)否则,将会按照继承关系从下往上递归搜索其父类直至搜到java.lang.Object为止。

可以看出,在进行字段解析时,优先搜索接口,然后再搜索父类。

在实际应用中,虚拟机的编译器实现会比上述规范要求更严格一些,如果有一个同名字段同时出现在自己的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译,提示“The field XX.xx is ambiguous”。

(三)类方法解析

(1)首先会解析类方法所属的类的符号引用。(2)接着,若类本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。(3)否则,将会按照继承关系从下往上递归搜索其父类直至搜到java.lang.Object为止。(4)否则,在类实现的接口列表及它们的父接口中递归查找,若找到了,则抛出 java.lang.AbstractMethodError 异常,若还是没找到,就抛出 java.lang.NoSuchMethodError 异常。

可以看出,在进行类方法解析时,优先搜索父类。若在所有父类中都没有找到,那么程序必然是有问题的必抛异常。

(四)接口方法解析

(1) 首先会解析接口方法所属的接口的符号引用。(2)接着,若接口本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。(3)否则,在接口的父接口中递归查找,直到java.lang.Object为止。

注意,并不是所有符号引用通过解析阶段后都可以完全确定其直接引用:

(1)首先,类、接口、字段,这些根本没有所谓的多态特性,就是指哪打哪,所以在编译期就可以完全确定,并在解析阶段可以完全转化为直接引用。
(2)其次,类方法中的静态方法、私有方法、构造方法、父类构造方法、final方法这5类非虚方法,也不存在多态特性,不需要在多个版本中去确定使用哪个版本,所以在编译期就可以完全确定(此处也可能会经过方法的静态分派),并在解析阶段可以完全转化为直接引用。
(3)其余的,可以通过继承重写其他版本的方法和通过invokedynamic指令调用的动态方法,还需在解析阶段完成后、在真正调用前,也就是在代码运行期,再经过一道方法的动态分派逻辑,才可以完全确定其直接引用。

1.5 初始化

在类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。在准备阶段,变量已经赋过一次系统要求的初始值,到了初始化阶段,才真正开始执行类中定义的程序代码。或者从另一个角度来说,初始化阶段是执行类构造器 <clint>() 方法的过程。

<clinit>() 方法的几个要点:

  • <clinit>() 方法是由编译器自动收集类中的所有类变量(不包括实例变量)的赋值动作和静态语句块(static{}块)中的语句合并产生的,并且编译器收集的顺序是由语句在源文件中出现的顺序决定的。
  • 在类的构造函数即<init>() 方法中,需要显式地调用父类构造器,与之不同的是,虚拟机会保证在子类的<clinit>() 方法执行之前,父类的<clinit>() 方法已经执行完毕。这也就意味着父类中定义的静态语句块要优先于子类的类变量赋值操作。
  • 接口的<clinit>() 方法有点不同,执行接口的<clinit>() 方法不需要先执行父接口的<clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。
  • 虚拟机会保证一个类的<clinit>() 方法在多线程环境中被正确地加锁。同步。

至此,一个类的所有前期准备工作都做好了,而且这些工作都是真真正正地在程序运行期完成的,下面就可以开始使用这个类了,进而自然地就需要了解方法调用的过程。方法调用阶段唯一的任务就是确定被调用方法的版本,即调用哪一个方法

二、方法调用

一个类文件包括两大种类的数据,一是元数据,二是执行代码。执行代码是程序真正的运行逻辑所在,而元数据可以说就是为了代码的执行而提供的各种辅助数据、中间数据和上下文数据等。

在Java中,执行代码就是字节码,而字节码存于Java对象的方法中(在Class文件结构层面即存于方法表的Code属性中)。而无论什么程序,都必须要有一个main()方法作为程序的入口方法,然后main()方法中又会不断层层调用其他方法。

所以所谓程序运行,就是在不断调用执行各种方法中的代码。然而在执行方法前,你首先要确定下面将调用哪个方法吧?虚拟机是怎么找到应该执行的方法的呢?特别是在重载、多态等特性情况下,又是如何寻找定位的呢?本小节就专注描述方法的调用过程,不涉及方法的执行过程(见下一小节)。

虚拟机提供的5条方法调用字节码指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用私有方法、构造方法和父类构造方法。
  • invokevirtual:调用所有的虚方法和final方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。在此之前的4条调用指令,分派逻辑是固化在虚拟机内部的,而该条指令的分派逻辑是由用户所设定的阴道方法决定的。

非虚方法:

  1. 只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在类加载的解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、构造方法和父类构造方法。
  2. 能被 invokevirtual 指令调用的final方法,也可以在解析阶段确定唯一的调用版本。

虚方法:

  1. 只要能被 invokeinterface 和 invokedynamic 指令调用的方法。
  2. 能被 invokevirtual 指令调用的非final方法。

2.1、解析调用

此处标题中的“解析”就是指类加载过程中的解析阶段。所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析能成立的前提是方法在编译期就有一个可确定的调用版本,并且这个方法的版本在运行期是不可改变的。 —— 这类方法的调用就称为解析调用,所有非虚方法的调用都是解析调用。

2.2、分派调用

Java作为一门面向对象的编程语言具有多态性特征,而“分派调用”的过程即是“重载”和“重写”在虚拟机中的实现过程。

分派调用可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

class Human {}
class Man extends Human {}

Human h = new Man();

上面代码中的“Human”称为变量的静态类型(Static Type)或外观类型(Apparent Type),后面的“Man”则成为变量的实际类型(Actual Type)。

静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

(1)静态分派

在编译期根据静态类型来确定方法执行版本的分派动作成为静态分派。虚拟机(准确地说是编译器)在重载(Overload)时是通过参数的静态类型而不是实际类型作为判定依据的,并且在编译阶段就会根据参数的静态类型决定使用哪个重载版本。

(2)动态分派

在运行期根据实际类型来确定方法执行版本的分派动作成为动态分派。虚拟机在重写(Override)时是通过参数的实际类型作为判定依据的,invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型(即在执行该指令时,操作数栈顶的第一个元素所指向的对象的实际类型),然后再把常量池中的类方法符号引用解析到对应实际类型的方法的直接引用上。

(3)单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少宗量,可以将分派划分为单分派和多分派。

Java语言是一门静态多分派、动态单分派语言:

  • Java在静态分派时选择目标方法的依赖有两点:一是静态类型,二是方法参数,所以Java的静态分派属于多分派。
  • Java在动态分派时,由于编译期已经确定了目标方法的签名,所以此时虚拟机不会关心方法参数是什么,而只会关心方法的接收者的实际类型。因为只有一个宗量作为选择依据,所以Java的动态分派属于单分派。

上面介绍的分派过程只是一个概念模型和外观模型,实际上各种虚拟机对此都可以有自己的实现。动态分派是非常频繁的动作,若每次动态分派都进行一次方法查找,将会十分影响程序执行效率。面对这种情况,最常用的稳定优化手段就是在类加载的连接阶段就为类在方法区中建立一个虚方法表,在运行期使用虚方法表索引来代替元数据查找以提高性能,这也是一种典型的以空间换取时间的设计思想。

三、字节码执行引擎

在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

3.1、运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧(Current Stack Frame)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧的概念结构 —— 《深入理解Java虚拟机》
JVM的类加载和字节码执行引擎

3.2、基于栈的字节码解释执行引擎

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

基于栈的指令集与基于寄存器的指令集相比,
优点有:(1)可移植。(2)代码更加紧凑,占用存储空间小。(3)编译器实现更加简单,不需要考虑空间分配的问题,所需空间都在栈上操作。
缺点有:执行速度稍慢一些,完成相同功能所需的指令数量一般会比寄存器架构的多,并且内存访问更加频繁。

四、参考书籍

《深入理解Java虚拟机》—— 周志明
第7章 虚拟机类加载机制
第8章 虚拟机字节码执行引擎