04_jvm_07_instruction_1

第五十三章 Java虚拟机的解释器与指令集(JVM Interpreter and Instruction Set)

1 概述

本章将介绍Java解释器(Java Interpreter)的工作原理和Java虚拟机的指令集(Instruction Set)。Java解释器是Java虚拟机中的运算和执行单元。当数据和指令被加载到内存后,Java解释器就可以开始按照指令的顺序运行程序了。实际上,Java解释器是在反复执行read-eval两个操作。在前文我们讲过,每个Java线程会保存一个PC字段。它的作用和PC寄存器(Program Counter Register)非常类似。这个PC字段指向了当前线程运行的指令的位置。在完成了当前指令后,PC字段会指向下一条指令的位置。那么,Java解释器就会读取PC字段指向的当前指令。eval操作就是执行当前的指令。我们将会使用一些例子来解释Java解释器的运行过程。

Java指令集是运行于Java虚拟机上的,能被Java解释器识别和执行的一组指令。Java程序中的所有逻辑都会被Java编译器转换为这些指令。这些指令与汇编语言(Assembly Language)非常类似,因此,它们被称为Java虚拟机的“汇编指令”。

我们先介绍Java指令集的格式,然后再通过一些具体的例子解释Java解释器和Java程序运行的过程。

2 Java指令集

Java一共有100多条指令,每条指令都有着不同的含义。Java指令存放在Java虚拟机中的方法区(Method Area)。指令与指令之间连续存放。每条指令都由一个指令码和零个或者多个参数组成。指令码表示该条指令的意义。因为指令码为单字节(Single Byte)数据,所以,Java虚拟机最多能支持256条指令。目前,Java虚拟机保留了三个指令码用于特殊用途。指令0xFE和0xFF用于Java虚拟机的"后门"功能。指令0xCA用于调试功能(设置断点)。这三个指令码不会出现在.class文件中。

每个指令码对应着一个助记符(Mnemonic),帮助开发人员交流和使用指令码。本文也将使用这些助记符。以下是一些助记符的例子,左侧是助记符和指令参数,右侧是对应的采用十六进制表示的二进制指令的数值。其中,第三行0和1是助记符iinc的两个参数。

iconst_0   // 0x03
istore_0   // 0x3B
iinc 0, 1  // 0x84 0x00 0x01
iload_0    // 0x1A

3 Java解释器的工作原理

因为Java虚拟机没有寄存器,所以,所有的操作都在操作数栈(Operand Stack)上完成。操作数栈的使用目的和工作原理与调用栈(Call Stack)不同,我们会在本章中以例子的方式展示操作数栈的详细内容。我们会在下一章利用实例来讲解在函数调用过程中,这两个栈的联系与不同之处。

3.1 算术运算示例

下面是一个非常简单的Java程序。在main()函数中定义了两个整型变量i和j。i的值是1,j的值是3。我们将着重讲解Java解释器如何完成加法运算。读者如果对其他算术运算的计算过程感兴趣的话,可以按照类似的方法运行和查看其代码。

public class Example {
    public static void main(String[] args) {
        int i = 1;
        int j = i + 2;
    }
}

我们将上述代码编译后,使用javap工具查看它的Java指令集如下。我们可以看到,Example.class包含了两个函数,第一个函数Example()是Example类的构造函数,在本例中未被使用。当开发人员未创建构造函数时,Java编译器会自动创建一个。第二个函数是main()函数。main()函数中有七条指令,分别对应于上面Java程序中的两条赋值语句。在下面的表格中,我们按照Java解释器的工作原理解释了这七条语句的执行内容。其重点是iadd这条指令;它将操作数栈上的两个元素弹出,再进行加操作,最后再将结果压入操作数栈中。

函数内指令的位移指令助记符指令的意义
0iconst_1将常数1压入操作数栈。提示:大整数是由指令bipush压入操作数栈的。
1istore_1弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。提示,索引为0的局部变量是args。
2iload_1将本地索引为1的局部变量(即变量i)压入操作数栈。
3iconst_2将常数2压入操作数栈。
4iadd从操作数栈顶部弹出两个元素,并累加这两个元素,最后将结果压入操作数栈。
5istore_2弹出操作数栈栈顶的元素,并将其赋值给索引为2的局部变量(即变量j)。
6return函数返回。
>javac Example.java
>javap -c Example
Compiled from "Example.java"
public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1
       1: istore_1
       2: iload_1
       3: iconst_2
       4: iadd
       5: istore_2
       6: return
}

图一也展示了main()函数的运行过程。值得注意的是,所有的算术运算都是依赖于操作数栈完成的。当需要给本地变量赋值时,先把该值压入栈中。当需要计算两个操作数的和时,也是先把操作数压入操作数栈中。这种工作方式实际上是模拟了x86体系结构的CPU的工作过程。

图一 算术运算用例运行图

图一 算术运算用例运行图。

3.2 if分支示例

分支语句的处理稍稍比算术运算复杂一些。我们使用一个if-else的例子来解释Java解释器如何处理if分支语句。在下面的例子中,main()函数包含了一个if-else语句。局部变量i首先初始化为0;然后,在if-else语句中,程序会运行else分支,变量i会自增2。

public class Example {
    public static void main(String[] args) {
        int i = 0;
        if (i == 1)
            i += 1;
        else
            i += 2;
    }
}

我们将上述的程序编译并反汇编显示其Java虚拟机的指令代码如下。这段代码最核心的逻辑在if_icmpne这条指令。它也是从操作数栈弹出两个元素,并比较这两个元素的大小,然后再做出分支运行的决定。if_icmpne指令在位移4的位置,而下一条指令iinc在位移7的位置,这是因为if_icmpne指令的参数13占用了两个字节(即,位移为5和6的位置),所以,iinc指令排在第7个字节处。

指令的位移指令助记符指令的意义
0iconst_0将常数0压入操作数栈。
1istore_1弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。
2iload_1将本地索引为1的局部变量(即变量i)压入操作数栈。
3iconst_1将常数1压入操作数栈。
4if_icmpne 13从操作数栈中弹出两个元素,并比较其大小。如果两个元素不相等,则跳转到地址为13的指令继续执行。如果相等,则执行下一条语句。
7iinc 1, 1将索引为1的局部变量(即变量i)自增1。
10goto 16程序跳转到地址为16的指令继续执行。
13iinc 1, 2将索引为1的局部变量(即变量i)自增2。
16return函数返回。

在这段代码中,if_icmpne和goto是用来处理分支语句的两条指令。if_icmpne会比较栈顶中的两个元素,如果条件满足的话(两个元素不相等),则Java解释器会跳转到位移为13的指令继续执行。如果条件不满足的话,则继续执行下一条指令(位移为7的指令)。goto指令则是无条件跳转指令。当if_icmpne指令的第一条分支执行完毕后,直接跳过第二个分支并执行return指令。

>javac Example.java
>javap -c Example
Compiled from "Example.java"
public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: iconst_1
       4: if_icmpne     13
       7: iinc          1, 1
      10: goto          16
      13: iinc          1, 2
      16: return
}

图二展示了Java解释器如何处理if-else分支语句。if_icmpne指令也是依赖于操作数栈的,从栈顶弹出两个元素,然后再比较其大小。如果读者有兴趣,可按照这个方法查看Java解释器如何处理switch分支语句。

图二 if分支用例运行图

图二 if分支用例运行图。

3.3 while循环示例

循环语句是逻辑处理的另一种处理过程。我们以while循环为例,讲解一下Java解释器如何处理循环语句。在下面的代码中,main()函数首先初始化变量i为1,然后进入循环,直到i的值大于6。

public class Example {
    public static void main(String[] args) {
        int i = 1;
        while (i <= 6)
            i += 8;
    }
}

本例对应的Java指令如下所示。bipush和if_icmpgt是两条新的指令。与iconst_1指令类似,iconst_1指令将常数1压入操作数栈,而bipush则是将较大的常数压入操作数栈。Java虚拟机标准为常数0-5单独创建了iconst_<n>指令,这是因为0-5是Java程序中最为常见的常量。与bipush指令相比,单独创建和使用iconst_<n>指令可以节省一个字节。if_icmpgt是if_icmp_<cond>家族的成员之一,其运行逻辑是:从栈顶弹出两个元素,如果弹出的第二个元素比第一个元素大,则跳转到位移为14的指令处继续执行。否则,继续执行下一条指令。

指令的位移指令助记符指令的意义
0iconst_1将常数1压入操作数栈。
1istore_1弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。
2iload_1将本地索引为1的局部变量(即变量i)压入操作数栈。
3bipush 6将参数6压入操作数栈。
5if_icmpge 14从操作数栈中弹出两个元素,并比较其大小。如果栈顶第二个元素i大于栈顶第一个元素6,则跳转到地址为14的指令继续执行,否则执行下一条语句。
8iinc 1, 8将索引为1的局部变量(即变量i)自增8。
11goto 2程序跳转到地址为2的指令继续执行。
14return函数返回。
>javac Example.java
>javap -c Example
Compiled from "Example.java"
public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1
       1: istore_1
       2: iload_1
       3: bipush        6
       5: if_icmpgt     14
       8: iinc          1, 8
      11: goto          2
      14: return
}

图三展示了Java解释器处理while循环的过程。其实,与处理if分支语句类似,都是使用了if_icmp_<cond>家族中的指令来控制执行的顺序。for循环的处理方式与while循环的处理方式十分类似,有兴趣的读者可以自行运行查看。

图三 while循环用例运行图

图三 while循环用例运行图。

4 总结

本章简单的介绍了Java指令集和通过三个例子解释了Java解释器如何处理算术运算、if条件分支语句、和while循环语句。我们将在下一章介绍Java解释器如何处理函数调用。

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.