本章将介绍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程序运行的过程。
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
因为Java虚拟机没有寄存器,所以,所有的操作都在操作数栈(Operand Stack)上完成。操作数栈的使用目的和工作原理与调用栈(Call Stack)不同,我们会在本章中以例子的方式展示操作数栈的详细内容。我们会在下一章利用实例来讲解在函数调用过程中,这两个栈的联系与不同之处。
下面是一个非常简单的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这条指令;它将操作数栈上的两个元素弹出,再进行加操作,最后再将结果压入操作数栈中。
函数内指令的位移 | 指令助记符 | 指令的意义 |
---|---|---|
0 | iconst_1 | 将常数1压入操作数栈。提示:大整数是由指令bipush压入操作数栈的。 |
1 | istore_1 | 弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。提示,索引为0的局部变量是args。 |
2 | iload_1 | 将本地索引为1的局部变量(即变量i)压入操作数栈。 |
3 | iconst_2 | 将常数2压入操作数栈。 |
4 | iadd | 从操作数栈顶部弹出两个元素,并累加这两个元素,最后将结果压入操作数栈。 |
5 | istore_2 | 弹出操作数栈栈顶的元素,并将其赋值给索引为2的局部变量(即变量j)。 |
6 | 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_1
1: istore_1
2: iload_1
3: iconst_2
4: iadd
5: istore_2
6: return
}
图一也展示了main()函数的运行过程。值得注意的是,所有的算术运算都是依赖于操作数栈完成的。当需要给本地变量赋值时,先把该值压入栈中。当需要计算两个操作数的和时,也是先把操作数压入操作数栈中。这种工作方式实际上是模拟了x86体系结构的CPU的工作过程。
图一 算术运算用例运行图。
分支语句的处理稍稍比算术运算复杂一些。我们使用一个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个字节处。
指令的位移 | 指令助记符 | 指令的意义 |
---|---|---|
0 | iconst_0 | 将常数0压入操作数栈。 |
1 | istore_1 | 弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。 |
2 | iload_1 | 将本地索引为1的局部变量(即变量i)压入操作数栈。 |
3 | iconst_1 | 将常数1压入操作数栈。 |
4 | if_icmpne 13 | 从操作数栈中弹出两个元素,并比较其大小。如果两个元素不相等,则跳转到地址为13的指令继续执行。如果相等,则执行下一条语句。 |
7 | iinc 1, 1 | 将索引为1的局部变量(即变量i)自增1。 |
10 | goto 16 | 程序跳转到地址为16的指令继续执行。 |
13 | iinc 1, 2 | 将索引为1的局部变量(即变量i)自增2。 |
16 | return | 函数返回。 |
在这段代码中,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分支用例运行图。
循环语句是逻辑处理的另一种处理过程。我们以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的指令处继续执行。否则,继续执行下一条指令。
指令的位移 | 指令助记符 | 指令的意义 |
---|---|---|
0 | iconst_1 | 将常数1压入操作数栈。 |
1 | istore_1 | 弹出操作数栈栈顶的元素,并将其赋值给索引为1的局部变量(即变量i)。 |
2 | iload_1 | 将本地索引为1的局部变量(即变量i)压入操作数栈。 |
3 | bipush 6 | 将参数6压入操作数栈。 |
5 | if_icmpge 14 | 从操作数栈中弹出两个元素,并比较其大小。如果栈顶第二个元素i大于栈顶第一个元素6,则跳转到地址为14的指令继续执行,否则执行下一条语句。 |
8 | iinc 1, 8 | 将索引为1的局部变量(即变量i)自增8。 |
11 | goto 2 | 程序跳转到地址为2的指令继续执行。 |
14 | 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_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循环用例运行图。
本章简单的介绍了Java指令集和通过三个例子解释了Java解释器如何处理算术运算、if条件分支语句、和while循环语句。我们将在下一章介绍Java解释器如何处理函数调用。
注册用户登陆后可留言