深入探索JVM垃圾回收:ARM服务器垃圾回收的挑战和优化
上QQ阅读APP看书,第一时间看更新

1.4.3 解释执行

在JVM规范中对于字节码的解释执行有详细的说明。从规范中可以看到,解释器主要有3个主要组件:PC、Operand Stack和Local Var,含义分别为PC是下一条执行字节码的地址、Operand Stack是解释器栈帧、Local Var是局部变量表,存放局部变量。下面以main函数调用add函数为例演示一下解释器的执行过程。

在main函数中通过invokevirtual调用add函数,在调用之前执行了3个字节码,分别是:

ALOAD 1  //将局部变量表中第1个槽位的对象放在栈中
ICONST_1 //将常量1放在操作数栈中
ICONST_3 //将常量3放在操作数栈中

在执行invokevirtual前需要将参数放入操作数栈中,参数的顺序是对象、参数1、参数2……,参数的顺序和方法描述的保持一致。此时PC、操作数栈和局部变量的状态如图1-15所示。

图1-15 main函数调用add函数前的状态

执行invokevirtual字节码进入函数add中,根据JVM规范,需要做以下动作:

1)创建新的栈帧(包含操作数栈和局部变量)。

2)将对象和参数传递到目标函数的局部变量表。

3)PC指向调用方法的首条指令。实际上这涉及函数查找过程,解释器需要从常量表中找到函数签名,然后找到执行方法的对象,从对象找到Klass信息,然后再找到虚方法,此时才能找到方法执行的起始地址。

4)执行对象的虚函数。

当进入add函数中时,PC、操作数栈和局部变量的状态如图1-16所示。

图1-16 进入add函数的状态

此处的操作数栈和局部方法表是add函数的,与main函数无关。需要注意的是,在Java源代码的编译过程中,已经知道add函数所需要的局部变量表的大小和操作数栈的大小,在上述字节码反编译代码中也可以看到这些信息,如MAXSTACK=2、MAXLOCALS=3,其中反编译代码中还有局部变量表存储的对象及对象所在的槽位(slot)。

当执行iload 1和iload 2时,PC、操作数栈和局部变量状态如图1-17所示。

图1-17 执行两个iload后的状态

当执行iadd时,根据JVM规范会将操作数栈中的两个对象弹出,然后执行add操作,并将执行的结果放入操作数栈顶。此时PC、操作数栈和局部变量的状态如图1-18所示。

图1-18 执行iadd后的状态

执行字节码ireturn时需要返回到调用者(caller)中,JVM规范中规定返回值从被调用者(callee)的栈帧出栈,然后入栈到caller的操作数栈中,callee栈帧中的其他值都被丢弃。解释器会切换至caller的栈帧,并将执行权交给caller。执行ireturn后caller的PC、操作数栈和局部变量的状态如图1-19所示。

图1-19 执行ireturn后的状态

caller(此例中为main函数)接下来执行istore 2指令,将操作数栈中的值出栈并存放在局部变量表中的第2个槽位中。PC、操作数栈和局部变量的状态如图1-20所示。

图1-20 istore执行后状态

解释器的实现也非常简单,执行过程中针对每一条字节码执行一段相应的逻辑。一个典型的解释器实现流程图如图1-21所示。

图1-21 解释器执行流程图

下面给出一个解释器实现的伪代码,使用vPC模拟程序执行下一条执行的指令,使用操作数栈模拟程序执行指令的操作数和执行结果,使用局部变量模拟store/load操作的内存空间。伪代码如下:

interpreter() {
    int *vPC;
    while(1) {
        switch(*vPC++) {
            case ICONST:
                int c= *vPC++;
                //将结果C放入操作数栈
                break;
            case ILOAD:
                // 加载局部变量数据到操作数栈中
                break;
            case ISTORE:
                // 将操作数栈的数据存入局部变量表
                break;
    ...
}

在伪代码中,针对每一个字节码都有一段相应的代码,通常把代码封装在一个函数中,将所有的函数组成一个分发表(dispatch table)。在执行每个字节码时,通过查询分发表执行相应的函数,就可以实现一个优雅的解释器。

对于解释执行,针对上述的Switch方式有不少的优化实践:

1)Direct Call Threading:将每条字节码用函数的方式实现,通过函数指针的方式调用每条字节码。

2)Direct Threading:在一个循环中实现每条字节码,并用Label和Goto分隔开。将每个指令从Label标记的地址开始实现。在加载阶段,将程序的字节码转换Label地址,存储到Direct Threading Table(DTT)。用vPC指向DTT的一项,表示下一条要执行的字节码。这种方式的主要问题是Goto会有分支预测失败的代价。

3)Subroutine Threading:衍生自Direct Threading,在加载解析字节码的时候生成Context Threading Table(CTT),根据CTT执行程序,可以认为是一个极简的JIT。对于非虚拟跳转有效果,但该方法无法提升虚拟跳转的性能。

4)Context Threading:衍生自Subroutine Threading,并针对虚拟跳转进行改进,相对Subroutine Threading有5%的性能提升。

更多关于解释器优化的细节可以参考相关论文。

在JVM中解释方式的实现主要是通过模板解释器完成的。在模板解释器中,每一个字节码对应一段可以执行的机器代码(本质上仍然是函数代码,但是模板解释器已经将函数使用机器码实现)。目前JVM中提供了202个字节码,在X86架构下字节码对应的机器代码如表1-1所示。

表1-1 字节码正常执行对应的解释模板表

注意

模板解释表中实际存放的是对应代码的地址(编译后位于代码区),这里为了便于理解,把代码直接放在表中。

例如,指令iload_1对应的代码如表1-1所示。这个代码的功能就是把栈中的对象加载到寄存器rax中(其中vtos和itos是栈顶执行的状态,即该指令执行完成后,栈顶存放的是一个整数。指令中iaddress(n)最终会转换成X86的地址寻址指令)。

所以,可以简单地认为JVM在执行字节码时,每一个字节码都被替换成一段目标机器的代码。