Java虚拟机字节码执行引擎

执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。

1)运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

还是要强调,虚拟机栈、程序计数器、本地方法栈都是线程私有的,每一个线程都有对应的虚拟机栈、程序计数器、本地方法栈。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,可以存储8种类型:boolean、byte、char、short、int、float、reference或returnAddress。

reference指的是一个对象的引用:一是可以找到对象地址,二是可以直接或间接的找到方法区中的类型信息。

returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。

至于方法正常结束后的返回,只要虚拟机栈中的栈帧出栈就行了。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。(书中举的例子是方法体中的一段花括号中的代码,其作用域就是这个花括号,如果执行完这个花括号,且有其他变量需要使用变量槽,就会重用)。

操作数栈

同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。

操作数栈的作用是在方法的执行过程中,作为各种字节码指令往操作数栈中写入和提取内容的地方。这也是基于栈的指令集与基于寄存器的指令集的区别。(操作数栈中只会存入操作数,就和寄存器一样)

所以Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接

为什么会有动态连接?

是因为Java中的对父类和接口的重载(overload)方法、对相同名称方法的重写方法(override)这些都是不确定的,只有在运行期间才能够知道到底需要调用哪一个方法。

重写是override ,父类或接口方法重写;

重载是overload ,相同方法名参数列表不同,返回值类型可以相同也可以不同。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。

第一种方式是执行返回指令,正常退出。

另一种是产生异常后在本方法的异常表中没有搜索到匹配的异常处理器,就会异常退出。这种退出方法的方式称为“异常调用 完成(Abrupt Method Invocation Completion)”。

2)方法调用

解析

这个解析就是类加载时的解析阶段。将常量池中的一部分方法调用的方法符号引用转化为直接引用。

这部分方法的符号引用在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

非虚方法,虚方法

分派

静态分派、动态分派;单分派、多分派。

首先理解一个概念:静态类型和实际类型。如Human human = new Man() 中Human为静态类型,Man为实际类型,或者叫“运行时类型”(Runtime Type)。

静态分派(Method Overload Resolution)

/**
* 方法静态分派演示
* @author zzm
*/
public class StaticDispatch {
    static abstract class Human {
    }
    static class Man extends Human {
    }
    static class Woman extends Human {
    }
    
    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}
//事实上结果是:
//hello,guy!
//hello,guy!
//也就是执行了public void sayHello(Human guy) 方法,对于重载的这些方法通过静态类型找到了具体的方法。

而且由于重载主要是参数的类型不同,参数的类型就对应着传入对象的静态类型,在编译期间(Java编译为字节码)就可以确定。

编译期间确定,所以可能是叫静态分派的原因。因为方法编译成Class字节码文件后,他的方法符号引用是类似”methodName(Lclassname;Lclassname)V”。所以可以在编译期间指定具体的重载方法。

动态分派(动态连接

由于重写导致需要确定是哪一个类的实例才可以知道时哪一个方法,所以需要知道运行时类型,这是动态分派。

实现方式是invokevirtual指令,invokevirtual指令的运行时解析过程[4]大致分为以下几步:

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

单分派和多分派

单分派和多分派的区别就是确定是哪一个方法的时候用了多少个宗量

方法的接收者(对象的静态类型)与方法的参数统称为方法的宗量。

Java语言的静态分派属于多分派类型。需要静态类型和参数确定。

Java语言的动态分派属于单分派类型,需要知道实际类型。

虚拟机动态分派的实现:为类型在方法 区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也 会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能。

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

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

解释执行

基于栈的指令集与基于寄存器的指令集

基于栈的:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。

基于寄存器的:

mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。