Java虚拟机(JVM)工作原理

虽然本教程的内容为 x86 处理器的原生汇编语言,但是了解其他机器架构如何工作也是有益的。JVM 是基于堆栈机器的首选示例。JVM 用堆栈实现数据传送、算术运算、比较和分支操作,而不是用寄存器来保存操作数(如同 x86 一样)。

Java 虚拟机

Java 虚拟机(JVM)是执行已编译 Java 字节码的软件。它是 Java 平台的重要组成部分,包括程序、规范、库和数据结构,让它们协同工作。Java 字节码是指编译好的 Java 程序中使用的机器语言的名字。

JVM 执行的编译程序包含了 Java 字节码。每个 Java 源程序都必须编译为 Java 字节码(形式为 .class 文件)后才能执行。包含 Java 字节码的程序可以在任何安装了 Java 运行时软件的计算机系统上执行。

例如,一个 Java 源文件名为 Account.java,编译为文件 Account.class。这个类文件是该类中每个方法的字节码流。JVM 可能选择实时编译(just-in-time compilation)技术把类字节码编译为计算机的本机机器语言。

正在执行的 Java 方法有自己的堆栈帧存放局部变量、操作数栈、输入参数、返回地址和返回值。操作数区实际位于堆栈顶端,因此,压入这个区域的数值可以作为算术和逻辑运算的操作数,以及传递给类方法的参数。

在局部变量被算术运算指令或比较指令使用之前,它们必须被压入堆栈帧的操作数区域。通常把这个区域称为操作数栈(operand stack)。

Java 字节码中,每条指令包含 1 字节的操作码、零个或多个操作数。操作码可以用 Java 反汇编工具显示名字,如 iload、istore、imul 和 goto。每个堆栈项为 4 字节(32 位)。

查看反汇编字节码

Java 开发工具包(JDK)中的工具 javap.exe 可以显示 java.class 文件的字节码,这个操作被称为文件的反汇编。命令行语法如下所示:

javap -c classname

比如,若类文件名为 Account.class,则相应的 javap 命令行为:

javap -c Account

安装 Java 开发工具包后,可以在 \bin 文件夹下找到 javap.exe 工具。

指令集

1) 基本数据类型

JVM 可以识别 7 种基本数据类型,如下表所示。和 x86 整数一样,所有有符号整数都是二进制补码形式。但它们是按照大端顺序存放的,即高位字节位于每个整数的起始地址(x86 的整数按小端顺序存放)。

数据类型 所占字节 格式  数据类型 所占字节 格式
char 2 Unicode 字符 long  8 有符号整数
byte 1 有符号整数 float 4 IEEE 单精度实数
short 2 有符号整数 double 8 IEEE 双精度实数
int 4 有符号整数       

2) 比较指令

比较指令从操作数栈的顶端弹出两个操作数,对它们进行比较,再把比较结果压入堆栈。现在假设操作数入栈顺序如下所示:


下表给出了比较 op1 和 op2 之后压入堆栈的数值:

op1 和 op2 比较的结果 压入操作数栈的数值
op1 > op2 1
op1 = op2 0
op1 < op2  -1

dcmp 指令比较双字,fcmp 指令比较浮点数。

3) 分支指令

分支指令可以分为有条件分支和无条件分支。Java 字节码中无条件分支的例子是 goto 和 jsr。

goto 指令无条件分支到一个标号:

goto label

jsr 指令调用用标号定义的子程序。其语法如下:

jsr label

条件分支指令通常检测从操作数栈顶弹出的数值。根据该值,指令决定是否分支到给定标号。比如,ifle 指令就是当弹出数值小于等于 0 时跳转到标号。其语法如下:

ifle label

同样,ifgt 指令就是当弹出数值大于等于 0 时跳转到标号。其语法如下:

ifgt label

Java 反汇编示例

为了帮助理解 Java 字节码是如何工作的,本节将给出用 Java 编写的一些短代码例子。在这些例子中,请注意不同版本 Java 的字节码清单细节会存在些许差异。

【示例 1】两个整数相加

下面的 Java 源代码行实现两个整数相加,并将和数保存在第三个变量中:
int A = 3;
int B = 2;
int sum = 0;
sum = A + B;
该 Java 代码的反汇编如下:
iconst_3
istore_0
iconst_2
istore_l
iconst_0
istore_2
iload_0
iload_l
iadd
istore_2
每个编号行表示一条 Java 字节码指令的字节偏移量。本例中,可以发现每条指令都只占一个字节,因为指令偏移量的编号是连续的。

尽管字节码反汇编一般不包括注释,这里还是会将注释添加上去。虽然局部变量在运行时堆栈中有专门的保留区域,但是指令在执行算术运算和数据传送时还会使用另一个堆栈,即操作数栈。为了避免在这两个堆栈间产生混淆,将用索引值来指代变量位置,如 0、1、2 等。

现在来仔细分析刚才的字节码。开始的两条指令将一个常数值压入操作数栈,并把同一个值弹出到位置为 0 的局部变量:

iconst_3 //常数(3)压入操作数栈
istore_0 //弹出到局部变量0

接下来的四行将其他两个常数压入操作数栈,并把它们弹岀到位置分别为 1 和 2 的局部变量:

iconst_2 //常数(2)压入操作数栈
istore_1 //弹出到局部变量1
iconst_0 //常数(0)压入操作数栈
istore_2 //弹出到局部变量2

由于已经知道了该生成字节码的 Java 源代码,因此,很明显下表列出的是三个变量的位置索引:

位置索引 变量名
0 A
1 B
2 sum

接下来,为了实现加法,必须将两个操作数压入操作数栈。指令 iload_0 将变量 A 入栈,指令 iload_1 对变量 B 进行相同的操作:

iload_0 // (A 入栈)
iload_1 // (B 入栈)

现在操作数栈包含两个数:


这里并不关心这些例子的实际机器表示,因此上图中的运行时堆栈是向上生长的。每个堆栈示意图中的最大值即为栈顶。

指令 iadd 将栈顶的两个数相加,并把和数压入堆栈:

iadd

操作数栈现在包含的是 A、B 的和数:


指令 istore_2 将栈顶内容弹出到位置为 2 的变量,其变量名为 sum:

istore_2

操作数栈现在为空。

【示例 2】两个 Double 类型数据相加

下面的 Java 代码片段实现两个 double 类型的变量相加,并将和数保存到 sum。它执行的操作与两个整数相加示例相同,因此这里主要关注的是整数处理与 double 处理的差异:
double A = 3.1;
double B = 2;
double sum = A + B;
本例的反汇编字节码如下所示,用 javap 实用程序可以在右边插入注释:
ldc2_w #20; // double 3.Id
dstore_0
ldc2_w #22; // double 2.Od
dstore_2
dload_0
dload_2
dadd
dstore_4
下面对这个代码进行分步讨论。偏移量为 0 的指令 ldc2_w 把一个浮点常数(3.1)从常数池压入操作数栈。ldc2 指令总是用两个字节作为常数池区域的索引:

ldc2_w #20; // double 3.ld

偏移量为 3 的 dstore 指令从堆栈弹出一个 double 数,送入位置为 0 的局部变量。该指令起始偏移量(3)反映出第一条指令占用的字节数(操作码加上两字节索引):

dstore_0 //保存到 A

同样,接下来偏移量为 4 和 7 的两条指令对变量 B 进行初始化:

ldc2_w #22; // double 2.Od
dstore_2 // 保存到 B

指令 dload_0 和 dload_2 把局部变量入栈,其索引指的是 64 位位置(两个变量栈项),因为双字数值要占用 8 个字节:

dload_0
dload_2

接下来的指令(dadd)将栈顶的两个 double 值相加,并把和数入栈:

dadd

最后,指令 dstore_4 把栈顶内容弹出到位置为 4 的局部变量:

dstore_4

JVM 条件分支

了解 JVM 怎样处理条件分支是理解 Java 字节码的重要一环。比较操作总是从堆栈栈顶弹出两个数据,对它们进行比较后,再把结果数值入栈。条件分支指令常常跟在比较操作的后面,利用栈顶数值决定是否分支到目标标号。比如,下面的 Java 代码包含一个简单的 IF 语句,它将两个数值中的一个分配给一个布尔变量:
double A = 3.0;
boolean result = false;
if( A > 2.0 )
result = false;
else
result = true;
该 Java 代码对应的反汇编如下所示:
ldc2_w #26; // double 3.Od
dstore_0 // 弹出到 A
iconst_0 // false = 0
istore_2 //保存到 result
dload_0
ldc2_w #22; // double 2.0d
dcmpl
ifle 19     //如果 A ≤ 2.0,转到 19
iconst_0 // false
istore_2 // result = false
goto 21     //跳过后面两条语句
iconst_l // true
istore_2 // result = true
开始的两条指令将 3.0 从常数池复制到运行时堆栈,再把它从堆栈弹岀到变量 A:

ldc2_w #26; // double 3.0d
dstore_0 // 弹出至A

接下来的两条指令将布尔值 false (等于 0)从常量区复制到堆栈,再把它弹出到变量 result:

iconst_0 // false = 0
istore_2 // 保存到 result

A 的值(位置 0)压入操作数栈,数值 2.0 紧跟其后入栈:

dload_0     //A 入栈
ldc2_w #22; // double 2.0d

操作数栈现在有两个数值:


指令 dcmpl 将两个 double 数弹出堆栈进行比较。由于栈顶的数值(2.0)小于它下面的数值(3.0),因此整数 1 被压入堆栈。

dcmpl

如果从堆栈弹出的数值小于等于 0,则指令 ifle 就分支到给定的偏移量:

ifle 19   //如果 stack.pop() <= 0,转到 19

这里要回顾一下之前给出的 Java 源代码示例,若 A>2.0,其分配的值为 false:
if( A > 2.0 )
    result = false;
else
    result = true;
如果 A <= 2.0,Java 字节码就把 IF 语句转向偏移量为 19 的语句,为 result 分配数值 true。与此同时,如果不发生到偏移量 19 的分支,则由下面几条指令把 false 赋给 result:

iconst_0     // false
istore_2     // result = false
goto 21     //跳过后面两条指令

偏移量 16 的指令 goto 跳过后面两行代码,它们的作用是给 result 分配 true:

iconst_l // true
istore_2 // result = true

Java 虚拟机的指令集与 x86 处理器系列的指令集有很大的不同。它采用面向堆栈的方法实现计算、比较和分支,与 x86 指令经常使用寄存器和内存操作数形成了鲜明的对比。

虽然字节码的符号反汇编不如 x86 汇编语言简单,但是,编译器生成字节码也是相当容易的。每个操作都是原子的,这就意味着它只执行一个操作。

若 JVM 使用的是实时编译器,则 Java 字节码只要在执行前转换为本地机器语言即可。就这方面来说,Java 字节码与基于精简指令集(RISC)模型的机器语言有很多共同点。