Java
Java虚拟机

Java虚拟机18——虚拟机字节码执行引擎

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

1. 运行时栈帧结构

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

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

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示:

1.线程栈帧与栈帧结构.png

1.1. 局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

  1. 局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
  2. 一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress八种类型,这8种数据类型,都可以使用32位或更小的物理内存来存放。
    • reference引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束。
    • returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
  3. 在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机需要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致,以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的64位的数据类型只有long和double两种(reference类型则可能是32位也可能是64位)。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
  4. 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
  5. 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
  6. 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为。
  7. 与类变量不同,如果一个局部变量定义了但没有赋初始值是不能使用的,编译器在编译期间会检查并提示这一错误。

1.2. 操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递,重叠的过程如图所示:

2.两个栈帧之间的数据共享.png

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

1.3. 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

1.4. 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

1.5. 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

2. 方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

2.1. 解析

继续前面关于方法调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令:

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

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。

注:被final修饰的方法也是非虚方法,虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,多态选择的结果肯定是唯一的。因此,final关键字可有效地“关闭”动态绑定,或者告诉编译器不需要进行动态绑定。这样一来,编译器就可为final方法调用生成效率更高的代码。

我们观察以下代码:

  • public class InvokeTest {
  • public static void echo() {
  • System.out.println("Hello");
  • }
  • public static void main(String[] args) {
  • InvokeTest.echo();
  • }
  • }

我们编译InvokeTest类后,得到了InvokeTest.class文件,然后使用javap工具反编译InvokeTest.class文件,会得到以下的结果:

  • ...
  • Constant pool:
  • ...
  • #5 = Methodref #6.#23 // InvokeTest.echo:()V
  • ...
  • public static void main(java.lang.String[]);
  • flags: ACC_PUBLIC, ACC_STATIC
  • Code:
  • stack=0, locals=1, args_size=1
  • 0: invokestatic #5 // Method echo:()V
  • 3: return
  • LineNumberTable:
  • line 8: 0
  • line 9: 3
  • ...

从反编译结果可知,在main(String[])方法中,0: invokestatic #5即是调用echo()方法的字节码指令,其中#5代表的即是常量池中第#5号常量,即CONSTANT_Methodref_info类型的常量,代表echo()方法。

2.2. 分派

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

注:方法的接收者与方法的参数统称为方法的宗量。

2.2.1. 静态分派

我们首先理解变量的静态类型和实际类型两个概念;对于定义一个变量,使用多态的形式对其进行引用,假设Human是一个接口,而Man实现了Human接口,那么如Human man = new Man();这种定义方式中,Human称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的Man则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

理解了变量的这两个概念,我们可以观察下面的代码:

  • interface Human {
  • }
  • class Man implements Human {
  • }
  • class Woman implements Human {
  • }
  • public class StaticDispatch {
  • public void say(Human human) {
  • System.out.println("hello, human");
  • }
  • public void say(Man man) {
  • System.out.println("hello, man");
  • }
  • public void say(Woman woman) {
  • System.out.println("hello, woman");
  • }
  • public static void main(String[] args) {
  • StaticDispatch staticDispatch = new StaticDispatch();
  • Human man = new Man();
  • Human woman = new Woman();
  • staticDispatch.say(man); // hello, human
  • staticDispatch.say(woman); // hello, human
  • staticDispatch.say((Man)man); // hello, man
  • staticDispatch.say((Woman)woman); // hello, woman
  • }
  • }

main(String[])方法里面的两次say()方法调用,在方法接收者已经确定是对象staticDispatch的前提下,使用哪个重载版本取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了say(Human human)作为调用目标,并把这个方法的符号引用写到main(String[])方法里的两条invokevirtual指令的参数中。

依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。

另外,解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

2.2.2. 动态分派

动态分派一般出现在方法重写的过程中,如下面的代码:

  • abstract class Human {
  • public void say() {
  • System.out.println("Human say");
  • }
  • }
  • class Man extends Human {
  • @Override
  • public void say() {
  • System.out.println("Man say");
  • }
  • }
  • class Woman extends Human {
  • @Override
  • public void say() {
  • System.out.println("Woman say");
  • }
  • }
  • public class DynamicDispatch {
  • public static void main(String[] args) {
  • Human man = new Man();
  • Human woman = new Woman();
  • man.say(); // Man say
  • woman.say(); // Woman say
  • man = new Woman();
  • man.say(); // Man say
  • }
  • }

显然上面的代码不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量manwoman在调用say()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因是这两个变量的实际类型不同。使用javap命令反编译得到的Class字节码文件,内容如下:

  • Constant pool:
  • ...
  • #1 = Methodref #8.#17 // java/lang/Object."<init>":()V
  • #2 = Class #18 // Man
  • #3 = Methodref #2.#17 // Man."<init>":()V
  • #4 = Class #19 // Woman
  • #5 = Methodref #4.#17 // Woman."<init>":()V
  • #6 = Methodref #20.#21 // Human.say:()V
  • ...
  • public static void main(java.lang.String[]);
  • flags: ACC_PUBLIC, ACC_STATIC
  • Code:
  • stack=2, locals=3, args_size=1
  • 0: new #2 // class Man
  • 3: dup
  • 4: invokespecial #3 // Method Man."<init>":()V
  • 7: astore_1
  • 8: new #4 // class Woman
  • 11: dup
  • 12: invokespecial #5 // Method Woman."<init>":()V
  • 15: astore_2
  • 16: aload_1
  • 17: invokevirtual #6 // Method Human.say:()V
  • 20: aload_2
  • 21: invokevirtual #6 // Method Human.say:()V
  • 24: new #4 // class Woman
  • 27: dup
  • 28: invokespecial #5 // Method Woman."<init>":()V
  • 31: astore_1
  • 32: aload_1
  • 33: invokevirtual #6 // Method Human.say:()V
  • 36: return
  • LineNumberTable:
  • line 23: 0
  • line 24: 8
  • line 25: 16
  • line 26: 20
  • line 28: 24
  • line 29: 32
  • line 30: 36

在上述的字节码指令中,第1行到第8行分别是创建Man对象和Woman对象的指令,同时使用astore_1astore_2将创建好的Man对象和Woman对象放入了分配的局部变量表的第一个和第二个Slot中;在第9行使用aload_1指令载入存放的Man对象,然后在第10行调用了该对象的say()方法;同样的,对于Woman对象也是如此,第11行和第12行分别载入并调用对应的say()方法。

虽然在第10行和第13行执行say()方法时,使用的执行指令一致(即invokevirtual),同时参数都是第#6号常量索引,即CONSTANT_Methodref_info类型的Human.say:()V,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派(Dynamic Dispatch)。动态分派发生在运行时期,动态分派动态地置换掉某个方法。面向对象的语言利用动态分派来实现方法置换产生的多态性,即所谓的运行时多态。

2.2.3. 单分派和多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

  • 在静态分派过程中,当调用一个重载的方法时,需要确定方法的调用者(调用者的静态类型)以及被调用方法的参数(确定具体调用哪一个静态方法),此时方法的调用者和方法的参数就作为静态分派进行选择时参照的两个宗量,因此静态分派是一个多分派过程。
  • 在动态分派时,被调用方法的参数已经在编译期的静态分派过程中确定了,只剩下确定方法的调用者(调用者的实际类型)这一个宗量作为参照,因此动态分派是一个单分派过程。

我们观察下面的代码:

  • public class Dispatch {
  • static class Race {
  • @Override
  • public String toString() {
  • return "Race";
  • }
  • }
  • static class Fruit {
  • @Override
  • public String toString() {
  • return "Fruit";
  • }
  • }
  • static class Human {
  • public void eat(Race race) {
  • System.out.println("Human eat: " + race);
  • }
  • public void eat(Fruit fruit) {
  • System.out.println("Human eat: " + fruit);
  • }
  • }
  • static class Man extends Human {
  • @Override
  • public void eat(Race race) {
  • System.out.println("Man eat: " + race);
  • }
  • @Override
  • public void eat(Fruit fruit) {
  • System.out.println("Man eat: " + fruit);
  • }
  • }
  • static class Woman extends Human {
  • @Override
  • public void eat(Race race) {
  • System.out.println("Woman eat: " + race);
  • }
  • @Override
  • public void eat(Fruit fruit) {
  • System.out.println("Woman eat: " + fruit);
  • }
  • }
  • public static void main(String[] args) {
  • Human man = new Man();
  • Human woman = new Woman();
  • man.eat(new Race()); // Man eat: Race
  • woman.eat(new Fruit()); // Woman eat: Fruit
  • }
  • }

在上面的代码中,Man和Woman两个类继承自Human类,并且都有两个重载的eat()方法:eat(Race)eat(Fruit),在主方法main(String[])中,首先创建了Man类对象和Woman类对象,然后分别调用了这两个对象的eat(Race)eat(Fruit)方法。在这个过程中是存在静态分派和动态分派两种分派过程的。

首先,在编译阶段,编译器通过静态分派确定了manwoman的静态类型为Human,同时根据传入eat()方法的参数确定了具体调用的重载方法,在这个过程中会生成两条invokevirtual指令,即Human.eat(Race)Human.eat(Fruit),方法的调用者及重载的方法参数作为静态分派进行方法选择时参照的两个宗量。查看编译后的相关Class字节码如下:

  • public static void main(java.lang.String[]);
  • flags: ACC_PUBLIC, ACC_STATIC
  • Code:
  • stack=3, locals=3, args_size=1
  • 0: new #2 // class Dispatch$Man
  • 3: dup
  • 4: invokespecial #3 // Method Dispatch$Man."<init>":()V
  • 7: astore_1
  • 8: new #4 // class Dispatch$Woman
  • 11: dup
  • 12: invokespecial #5 // Method Dispatch$Woman."<init>":()V
  • 15: astore_2
  • 16: aload_1
  • 17: new #6 // class Dispatch$Race
  • 20: dup
  • 21: invokespecial #7 // Method Dispatch$Race."<init>":()V
  • 24: invokevirtual #8 // Method Dispatch$Human.eat:(LDispatch$Race;)V
  • 27: aload_2
  • 28: new #9 // class Dispatch$Fruit
  • 31: dup
  • 32: invokespecial #10 // Method Dispatch$Fruit."<init>":()V
  • 35: invokevirtual #11 // Method Dispatch$Human.eat:(LDispatch$Fruit;)V
  • 38: return

上述Class字节码内容中,invokevirtual #8invokevirtual #11分别代表了执行Dispatch$Human.eat:(LDispatch$Race;)VDispatch$Human.eat:(LDispatch$Fruit;)V,即Human.eat(Race)Human.eat(Fruit)

其次,在运行阶段,虚拟机通过动态分派确定了manwoman的实际类型分别为Man和Woman,由于在静态分派中已经确定了需要调用的具体的重载方法,因此在动态分派过程中只需要确定方法调用者的实际类型即可,此时方法调用者将作为动态分派进行方法选择时参照的唯一宗量。

2.2.4. 虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能。

2.3. 动态类型语言支持

在Java语言中,当调用一个确定对象的某个方法时,由于在编译期会确定对象的静态类型,如果确定的静态类型中没有定义该方法,即是对象的实际类型中包含了方法的定义,编译也会直接报错,原因是Java语言在编译期间已将方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中。这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。

而在其他动态语言,类似于JavaScript、Python等语言中,无论对象具体是何种类型,只要这种类型的定义中确实包含有相应的方法,那方法调用便可成功。在这一类动态类型语言中,对象本身是没有类型的,对象的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点是动态类型语言的一个重要特征。

2.3.1. java.lang.invoke包

JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。因此,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一,这就是JDK 1.7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。有如下代码:

  • import static java.lang.invoke.MethodHandles.lookup;
  • import java.lang.invoke.MethodHandle;
  • import java.lang.invoke.MethodType;
  • public class MethodHandleTest {
  • static class ClassA {
  • public void println(String s) {
  • System.out.println(s);
  • }
  • }
  • public static void main(String[] args) throws Throwable {
  • Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
  • getPrintlnMethodHandle(obj).invokeExact("Hello");
  • }
  • private static MethodHandle getPrintlnMethodHandle(Object reveiver) throws Throwable {
  • // methodType()方法的第一个参数为方法的返回值类型,第二个参数为方法的参数类型
  • MethodType mt = MethodType.methodType(void.class, String.class);
  • // findVirtual()用于在类中查找指定方法;bindTo()用于绑定方法的接收者
  • return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
  • }
  • }

上面的代码中,方法getPrintlnMethodHandle(Object)中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。

MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,它们还是有以下这些区别:

  1. 从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.lookup中的3个方法——findStatic()findVirtual()findSpecial()正是为了对应于invokestatic、invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
  2. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
  3. 由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。
  4. Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。

2.3.2. invokedynamic指令

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条invoke*指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。

每一处含有invokedynamic指令的位置都称做“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。

3. 基于栈的字节码解释执行引擎

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

图书信息

深入理解Java虚拟机(第2版)

作者:周志明

出版社:机械工业出版社

出版日期:

页数:433

评分:

JVM
Java
虚拟机
java
计算机
编程
软件开发
程序设计

推荐阅读

Java虚拟机06——垃圾收集器之CMS

Java
Java虚拟机

2015-01-29 0 320

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和...

目录