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

声明:本文摘抄自《深入理解Java虚拟机》一书,本文完全为自我学习,请感兴趣的同学购买正版,支持原创

Java语言经常被人们定位为“解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。

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

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

举个简单例子,分别使用这两种指令计算1+1的结果,基于栈的指令集会是这个样子:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。
如果基于寄存器的指令集,那程序可能会是这个样子:

mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里面。
基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获得最好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑,编译器实现更加简单等。
栈架构指令集的只要缺点是执行速度相对来说会稍微慢一些。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需要的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的操作指令数量。
更重要的是,栈实现在内存之中,频繁的栈操作意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机采取栈顶缓存的优化手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致栈架构指令集的执行速度会相对较慢。

基于栈的解释器的执行过程

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

以上面的代码为例,看看虚拟机是如何执行的。使用javap命令查看它的字节码指令,字节码指令如下:

  public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
      LineNumberTable:
        line 3: 0
        line 4: 3
        line 5: 7
        line 6: 11
}

编译后的字节码指令显示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间。我们通过下面几张图来了解代码执行过程中的代码、操作数栈和局部变量表的变化情况。


执行偏移地址为0的指令情况
  1. 首先执行偏移地址为0的指令,bipush指令的作用是将单个字节的整形常量值(-128~127)推入操作栈顶,跟随有一个参数,指明推送的常量值,这里是100。
  2. 执行偏移地址为2的指令,istore_1指令的作用是将操作栈顶的整形值出栈并存入局部变量表Slot中。后续4条指令都是做一样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。
  3. 执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第一个Slot中的整形值复制到操作栈顶。
  4. 执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整形值入栈。当前局部变量表和操作栈如下图所属:


    执行偏移地址为12的指令情况

    5.执行偏移地址为13的指令,iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整形加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们的和300重新入栈。

  5. 执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量Slot中的300压入操作栈中。这时操作栈中为两个整数300。下一条指令imul是将操作栈中头两个栈顶元素出栈,做整形乘法,然后把结果重新入栈,与iadd完全类似。
  6. 执行偏移地址16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作栈顶的整形值返回给此方法的调用者。到此为止,这段代码执行结束。

推荐阅读更多精彩内容