天天看点

四、虚拟机字节码执行引擎

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息和一些额外的附加信息。

四、虚拟机字节码执行引擎

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义 的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方 法所需分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位。Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变 量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位 数据类型的变量,则说明会同时使用第N和N+1两个变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用 域,那这个变量对应的变量槽就可以交给其他变量来重用。

操作数栈

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

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种 字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过 将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作 数栈来进行方法参数的传递。

动态连接

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

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法 返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为正常调用完成。另外一种退出方式是在方法执行的过程中遇到了异常,一个方法使用异常完成出口的方式退出,是不会给它 的上层调用者提供任何返回值的。方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统程序语言编译的 连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局 中的入口地址(也就是之前说的直接引用)。

解析

在Java虚拟机支持以下5条方法调用字节码指令:

·invokestatic:用于调用静态方法。

·invokespecial:用于调用实例构造器()方法、私有方法和父类中的方法。

·invokevirtual:用于调用所有的虚方法。

·invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。

·invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

解析调用: 被Invokespecial和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,包含静态方法、私有方法、实例构造器、父类方法4种,再加上被final 修饰的方法(尽管它使用invokevirtual指令调用)。这是一个静态的过程。

分派调用:可能是静态的也可能是动态的,可分为单分派和多分派,还可细分为:静态单分派、静态多分派、动态单分派、动态多 分派4种分派组合情况。

分派

Java面向对象的基本特性:封装、继承、多态。

静态分派:就是重载的实现。

动态分派:多态的实现(重写)。运行期根据实 际类型确定方法执行版本的分派过程称为动态分派。

动态分派其中涉及到nvokevirtual指令调用方法的解析步骤:

1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

字段是不会存在多态的,当子类中定义和父类中一样的字段的时候,子类中访问只能访问到子类的字段,父类的字段被隐藏了。

单分派与多分派

  1. public class Dispatch {  
  2.  static class QQ {}  
  3.  static class _360 {}  
  4.  public static class Father {  
  5.      public void hardChoice(QQ arg) {  
  6.          System.out.println("father choose qq");  
  7.      }  
  8.      public void hardChoice(_360 arg) {  
  9.          System.out.println("father choose 360");  
  10.      }  
  11.  }  
  12.  public static class Son extends Father {  
  13.      public void hardChoice(QQ arg) {  
  14.          System.out.println("son choose qq");  
  15.      }  
  16.      public void hardChoice(_360 arg) {  
  17.          System.out.println("son choose 360");  
  18.      }  
  19.  }  
  20.  public static void main(String[] args) {  
  21.      Father father = new Father();  
  22.      Father son = new Son();  
  23.      father.hardChoice(new _360());  
  24.      son.hardChoice(new QQ());  
  25.  } 

方法的接收者与方法的参数统称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

见上面的例子,在java源代码进行编译的过程中首先确定方法的接收者,发现两个对象变量的静态类型都是Father类型的,因此在class文件中写的Father类中方法的符号引用。

再者,对于方法参数,一个是_360对象,一个是QQ对象,按照静态类型匹配的原则,自然找到各自的方法。上面的两步都是在编译器中做出的,属于静态分派,在选择目标方法时根据了两个宗量,是多分派的。因此,静态分派属于多分派类型。

当java执行时,当执行到son.hardChoice(new QQ()); 时,发现son的实际类型是Son,因此会调用Son类中的方法。在执行father.hardChoice(new _360()); 时也有这个过程,只不过father的实际类型就是Father而已。发现,在目标选择时只依据了一个宗量,是单分派的。因此,动态分派属于单分派类型。

虚拟机动态分派的实现

Java虚拟机中由于动态分派执行非常频繁,为了解决行性能问题就有虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

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

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序 号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需 的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

上文中提到了查虚方法表是分派调用的一种优化手段,由于Java对象里面的方法默认(即不使用final修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间。

继续阅读