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在执行字节码时,每一个字节码都被替换成一段目标机器的代码。