Java虚拟机旨在支持Java编程语言。Oracle的JDK软件包含一个编译器,可以将用Java编程语言编写的源代码编译为Java虚拟机的指令集,还包含一个实现Java虚拟机本身的运行时系统。了解一个编译器如何利用Java虚拟机,对于潜在的编译器编写者以及试图理解Java虚拟机本身的人来说都是有帮助的。本章中的带编号的部分不是规范性的。
请注意,有时在提到从Java虚拟机的指令集翻译到特定CPU的指令集时,会使用“编译器”这个词。这种翻译器的一个例子是即时(JIT)代码生成器,它仅在Java虚拟机代码被加载后生成特定平台的指令。本章不涉及与代码生成相关的问题,仅涉及将用Java编程语言编写的源代码编译为Java虚拟机指令相关的问题。
示例格式
本章主要由示例源代码以及 Oracle JDK 1.0.2 发行版中的 javac 编译器为这些示例生成的 Java 虚拟机代码的带注释列表组成。Java 虚拟机代码采用 Oracle 的 javap 工具输出的非正式“虚拟机汇编语言”编写,该工具随 JDK 发行版分发。您可以使用 javap 生成更多编译方法的示例。
示例的格式对于任何阅读过汇编代码的人来说都应该很熟悉。每条指令的形式为:
< index> < opcode> [< operand1> [ < operand2>... ]] [< comment>
< index> 是包含此方法的 Java 虚拟机代码字节的数组中指令操作码的索引。或者,可以将 < index> 视为从方法开头的字节偏移量。< opcode> 是指令操作码的助记符,零个或多个 < operandN> 是指令的操作数。可选的 < comment> 以行尾注释语法给出。
8 bipush 100 // Push int constant 100
注释中的部分内容由javap生成;其余部分由作者提供。每个指令前的< index>可以用作控制转移指令的目标。例如,goto 8 指令将控制转移到索引8处的指令。请注意,Java虚拟机控制转移指令的实际操作数是从这些指令的操作码地址计算的偏移量;javap显示这些操作数(本章中也显示)为更易读的方法内部偏移量。
我们在表示运行时常量池索引的操作数前加上一个井号,并在指令后跟随一个注释,以标识所引用的运行时常量池项,例如:
10 ldc #1 // Push float constant 100.0
or:
9 invokevirtual #4 // Method Example.addTwo(II)I
为了本章的目的,我们不必担心指定诸如操作数大小等细节。
常量、局部变量和控制结构的使用
Java虚拟机代码表现出由Java虚拟机的设计和类型使用所决定的一组通用特性。在第一个例子中,我们会遇到许多这些特性,并将详细考虑它们。
一个空的循环方法循环一百次:
void spin() {
int i;
for (i = 0; i < 100; i++) {
; // Loop body is empty
}
}
编译器可能会将spin方法编译为:
0 iconst_0 // Push int constant 0
1 istore_1 // Store into local variable 1 (i=0)
2 goto 8 // First time through don't increment
5 iinc 1 1 // Increment local variable 1 by 1 (i++)
8 iload_1 // Push local variable 1 (i)
9 bipush 100 // Push int constant 100
11 if_icmplt 5 // Compare and loop if less than (i < 100)
14 return // Return void when done
Java虚拟机是堆栈导向的,大多数操作都会从Java虚拟机当前帧的操作数堆栈中获取一个或多个操作数,或将结果推回操作数堆栈。每次调用方法时都会创建一个新的帧,并且会随之创建一个新的操作数堆栈和一组局部变量供该方法使用。在计算的任何一点上,可能因此存在许多帧和同样多的操作数堆栈,对应于许多嵌套的方法调用。只有当前帧中的操作数堆栈是活跃的。
Java虚拟机的指令集通过为各种数据类型的操作使用不同的字节码来区分操作数类型。 方法 spin() 仅对类型为 int 的值进行操作。 其编译代码中的指令(iconst_0、istore_1、iinc、iload_1、if_icmplt)都是针对 int 类型专门化的。
自旋中的两个常量,0和100,使用两条不同的指令推送到操作数栈。0是通过iconst_0指令推送的,这是iconst_< i>指令家族的一员。100是通过bipush指令推送的,该指令将其推送的值作为即时操作数获取。
Java虚拟机经常利用某些操作数的可能性(在iconst_< i>指令的情况下,int常量-1、0、1、2、3、4和5),通过将这些操作数隐含在操作码中。由于iconst_0指令知道它将推送一个int 0,因此iconst_0不需要存储操作数来告诉它要推送的值,也不需要获取或解码操作数。将推送0编译为bipush 0虽然是正确的,但会使spin的编译代码长一个字节。一个简单的虚拟机每次循环时还会花费额外的时间获取和解码显式操作数。使用隐式操作数可以使编译后的代码更紧凑和高效。
在spin方法中,整数i被存储为Java虚拟机的本地变量1。由于大多数Java虚拟机指令对操作数栈中弹出的值进行操作,而不是直接对本地变量进行操作,因此在编译为Java虚拟机的代码中,常见的是在本地变量和操作数栈之间传输值的指令。这些操作在指令集中也有专门的支持。在spin方法中,使用istore_1和iload_1指令在本地变量和操作数栈之间传输值,这两个指令都隐式地对本地变量1进行操作。istore_1指令从操作数栈中弹出一个整数并将其存储在本地变量1中。iload_1指令将本地变量1中的值压入操作数栈。
局部变量的使用(和重用)是编译器编写者的责任。专用的加载和存储指令应鼓励编译器编写者在可行的情况下尽可能重复使用局部变量。这样生成的代码更快、更紧凑,并且在堆栈帧中占用的空间更少。
某些对局部变量的非常频繁的操作被Java虚拟机特别处理。iinc指令将局部变量的内容通过一个一字节的有符号值进行递增。在spin方法中的iinc指令将第一个局部变量(它的第一个操作数)通过1(它的第二个操作数)进行递增。当实现循环结构时,iinc指令非常方便。
自旋的for循环主要通过这些指令来完成:
5 iinc 1 1 // Increment local variable 1 by 1 (i++)
8 iload_1 // Push local variable 1 (i)
9 bipush 100 // Push int constant 100
11 if_icmplt 5 // Compare and loop if less than (i < 100)
bipush指令将值100作为int推送到操作数栈中,然后if_icmplt指令从操作数栈中弹出该值并与i进行比较。如果比较成功(变量i小于100),控制权转移到索引5,并开始for循环的下一次迭代。否则,控制权传递给if_icmplt之后的指令。
如果自旋示例使用了除int以外的其他数据类型作为循环计数器, 则编译后的代码必然会更改以反映不同的数据类型。例如, 如果自旋示例使用double而不是int,如所示:
void dspin() {
double i;
for (i = 0.0; i < 100.0; i++) {
// Loop body is empty
}
}
编译后的代码: Method void dspin()
0 dconst_0 // Push double constant 0.0
1 dstore_1 // Store into local variables 1 and 2
2 goto 9 // First time through don't increment
5 dload_1 // Push local variables 1 and 2
6 dconst_1 // Push double constant 1.0
7 dadd // Add; there is no dinc instruction
8 dstore_1 // Store result in local variables 1 and 2
9 dload_1 // Push local variables 1 and 2
10 ldc2_w #4 // Push double constant 100.0
13 dcmpg // There is no if_dcmplt instruction
14 iflt 5 // Compare and loop if le
操作带类型数据的指令现在专用于double类型。
请记住,双精度值占用两个局部变量,尽管它们仅通过两个局部变量中较小的索引访问。长整型值的情况也是如此。再次例如:
double doubleLocals(double d1, double d2) {
return d1 + d2;
}
编译后:
Method double doubleLocals(double,double)
0 dload_1 // First argument in local variables 1 and 2
1 dload_3 // Second argument in local variables 3 and 4
2 dadd
3 dreturn
请注意,在用于存储双精度值的局部变量对(doubleLocals 中)的局部变量中,绝不能单独对其进行操作。
Java 虚拟机的指令码大小为 1 字节,这使得其编译后的代码非常紧凑。然而,指令码大小为 1 字节意味着 Java 虚拟机的指令集必须保持小巧。作为一种折中方案,Java 虚拟机并未对所有数据类型提供同等的支持:它并非完全相互独立
例如,在示例 spin 中 for 语句中整型变量值的比较可以通过使用单个 if_icmplt 指令来实现;然而,存在这种情况, Java 虚拟机指令集中没有一条指令会对 double 类型的值执行条件分支操作。因此,dspin 必须使用 dcmpg 指令对 double 类型的值进行比较,然后紧跟一个 iflt 指令。
Java 虚拟机为 int 类型的数据提供了最直接的支持。 这在一定程度上是出于对 Java 虚拟机操作数栈和局部变量数组高效实现的预期。这也是出于对 int 类型数据在典型程序中出现频率较高的考虑。其他整型类型的支持则相对不那么直接。例如,没有 byte、char 或 short 版本的存储、加载或加法指令。下面是使用 short 类型编写的 spin 示例:
void sspin() {
short i;
for (i = 0; i < 100; i++) {
; // Loop body is empty
}
}
必须针对 Java 虚拟机进行编译,如下所示,使用操作另一种类型(很可能是 int 类型)的指令,必要时在短整型值和 int 型值之间进行转换,以确保对短整型数据的操作结果保持在适当范围内:
Method void sspin()
0 iconst_0
1 istore_1
2 goto 10
5 iload_1 // The short is treated as though an int
6 iconst_1
7 iadd
8 i2s // Truncate int to short
9 istore_1
10 iload_1
11 bipush 100
13 if_icmplt
Java 虚拟机对于 byte、char 和 short 类型缺乏直接支持这一点本身并不是特别令人困扰的,因为这些类型的数据值在内部会被提升为 int 类型(byte 和 short 类型会进行符号扩展到 int 类型,char 类型则进行零扩展)。因此,对 byte、char 和 short 类型数据的操作可以通过 int 指令来完成。 唯一需要额外考虑的成本是将 int 操作的结果截断到有效范围之内。在 Java 虚拟机中,长整型和浮点型拥有一定程度的支持,只是缺少完整的条件控制转移指令。
计算
Java 虚拟机通常在其操作数栈上进行算术运算。(iinc 指令是个例外,它直接对局部变量的值进行递增操作。)例如,align2grain 方法会将一个 int 值对给定的 2 的幂次方进行对齐:
int align2grain(int i, int grain) {
return ((i + grain-1) & ~(grain-1));
}
算术运算的操作数是从操作数栈中弹出的,并且运算的结果被推回到操作数栈中。因此,算术子计算的结果可以作为其嵌套计算的操作数而被提供出来。例如,对 ~(grain-1) 的计算是通过这些操作来完成的。说明:
5 iload_2 // Push grain
6 iconst_1 // Push int constant 1
7 isub // Subtract; push result
8 iconst_m1 // Push int constant -1
9 ixor // Do XOR; push result
首先,使用局部变量 2 的值以及立即整数值 1 来计算 first grain-1。这些操作数从操作数栈中弹出,并且它们的差值被推回操作数栈中。因此,差值立即可用作 ixor 指令的一个操作数。(请注意,~x == -1^x。)同样地,ixor 指令的结果成为后续 iand 指令的一个操作数。
整个方法的代码如下所示:
Method int align2grain(int,int)
0 iload_1
1 iload_2
2 iadd
3 iconst_1
4 isub
5 iload_2
6 iconst_1
7 isub
8 iconst_m1 9 ixor 10 iand 11 ireturn
访问运行时常量池
许多数值常量以及对象、字段和方法都是通过当前类的运行时常量池来访问的。对象访问将在后面进行讨论。int、long、float 和 double 类型的数据,以及 String 类实例的引用,都是通过 ldc、ldc_w 和 ldc2_w 指令进行管理的。
ldc 和 ldc_w 指令用于访问运行时常量池(包括 String 类实例)中除 double 和 long 类型之外的其他类型的值。只有在运行时常量池中有大量项且需要更大的索引来访问项时,才会使用 ldc_w 指令代替 ldc 指令。ldc2_w 指令用于访问 double 和 long 类型的所有值;没有非宽变体。
byte、char 或 short 类型的整数常量,以及小 int 值,可以使用 bipush、sipush 或 iconst_< i> 指令进行编译。某些小浮点常量可以使用 fconst_< f> 和 dconst_< d> 指令进行编译。
在所有这些情况下,编译都很简单。例如,以下常量的编译:
void useManyNumeric() {
int i = 100;
int j = 1000000;
long l1 = 1;
long l2 = 0xffffffff;
double d = 2.2;
...do some calculations...
}
如下所示进行设置:
Method void useManyNumeric()
0 bipush 100 // Push small int constant with bipush
2 istore_1
3 ldc #1 // Push large int constant (1000000) with ldc
5 istore_2
6 lconst_1 // A tiny long value uses small fast lconst_1
7 lstore_3
8 ldc2_w #6 // Push long 0xffffffff (that is, an int -1)
// Any long constant value can be pushed with ldc2_w
11 lstore 5
13 ldc2_w #8 // Push double constant 2.200000 // Uncommon double values are also pushed with ldc2_w
16 dstore 7
...do those calculations...
更多控制示例
在前面的章节中已经展示了 for 语句的编译过程。Java 编程语言中的其他大多数控制结构(如 if-then-else、do、while、break 和 continue)也是以显而易见的方式进行编译的。switch 语句的编译在单独的章节中处理,异常的编译以及 finally 子句的编译)也是如此。
作为进一步的例子,while 循环是以显而易见的方式编译的,尽管 Java 虚拟机提供的特定控制转移指令会因数据类型而异。像往常一样,对于 int 类型的数据,有更多支持
例如:
void whileInt() {
int i = 0;
while (i < 100) {
i++;
}
}
编译为:
Method void whileInt()
0 iconst_0
1 istore_1
2 goto 8
5 iinc 1 1
8 iload_1
9 bipush 100
11 if_icmplt 5
14 return
注意,while语句的测试(使用if_icmplt指令实现)位于循环的Java虚拟机代码的底部。(在前面的自旋例子中也是如此。)由于测试位于循环的底部,因此在循环的第一轮迭代之前需要使用goto指令跳转到测试位置。如果该测试失败,且循环体从未进入,则此额外的指令就被浪费了。然而,通常情况下,当预期循环体会被执行时会使用while循环,而且通常是执行多次迭代。对于后续的迭代,在循环底部放置测试每次都可以节省一条Java虚拟机指令:如果测试在循环顶部,则循环体需要一个尾随的goto指令才能返回到循环顶部。
涉及其他数据类型的控制结构采用类似的方式进行编译,但必须使用针对这些数据类型可用的指令。这会导致代码效率有所降低,因为需要更多的 Java 虚拟机指令,例如:
void whileDouble() {
double i = 0.0;
while (i < 100.1) {
i++;
}
}
编译为:
Method void whileDouble()
0 dconst_0
1 dstore_1
2 goto 9
5 dload_1
6 dconst_1
7 dadd
8 dstore_1
9 dload_1
10 ldc2_w #4 // Push double constant 100.1
13 dcmpg // To compare and branch we have to use...
14 iflt 5 // ...two instructions
17 return
每种浮点类型都有两个比较指令:对于 float 类型为 fcmpl 和 fcmpg,对于 double 类型为 dcmpl 和 dcmpg。这些变体之间的区别仅在于它们对 NaN 的处理方式。NaN 是无序的,因此如果其任一操作数为 NaN,则所有的浮点比较都会失败。编译器会选择比较指令的变体,使得无论比较在非 NaN 值上失败还是遇到 NaN 值时都能产生相同的结果。例如:
int lessThan100(double d) {
if (d < 100.0) {
return 1;
} else {
return -1;
}
}
编译为:
Method int lessThan100(double)
0 dload_1
1 ldc2_w #4 // Push double constant 100.0
4 dcmpg // Push 1 if d is NaN or d > 100.0;
// push 0 if d == 100.0
5 ifge 10 // Branch on 0 or 1
8 iconst_1
9 ireturn
10 iconst_m
11 ireturn
如果 d 不是 NaN 值且小于 100.0,那么 dcmpg 指令会在操作数栈中压入一个整数 -1,而 ifge 指令则不会进行分支。如果 d 大于 100.0 或者是 NaN 值,那么 dcmpg 指令会在操作数栈中压入一个整数 1,此时 ifge 指令会进行分支。如果 d 等于 100.0,那么 dcmpg 指令会在操作数栈中压入一个整数 0,此时 ifge 指令也会进行分支。
如果将比较的方向反过来,dcmpl 指令也能达到同样的效果:
int greaterThan100(double d) {
if (d > 100.0) {
return 1;
} else {
return -1;
}
}
变成为:
Method int greaterThan100(double)
0 dload_1
1 ldc2_w #4 // Push double constant 100.0
4 dcmpl // Push -1 if d is NaN or d < 100.0;
// push 0 if d == 100.0
5 ifle 10 // Branch on 0 or -1
8 iconst_1
9 ireturn
10 iconst_m1
11 ireturn
再次强调,无论是由于非 NaN 值导致比较失败,还是因为传递了一个 NaN 值,dcmpl 指令都会将一个整数值压入操作数栈,从而导致 ifle 指令进行分支。如果不存在 dcmp 指令,那么其中一个示例方法就必须做更多的工作来检测 NaN 值。
接收参数
如果向实例方法传递了 n 个参数,那么按照惯例, 这些参数会被接收进为新方法调用所创建的帧中的编号为 1 至 n 的局部变量中。 这些参数是按照传递的顺序接收的。例如:
int addTwo(int i, int j) {
return i + j;
}
编译为:
Method int addTwo(int,int)
0 iload_1 // Push value of local variable 1 (i)
1 iload_2 // Push value of local variable 2 (j)
2 iadd // Add; leave int result on operand stack
3 ireturn // Return int result
按照惯例,实例方法会接收到一个指向其实例的局部变量 0 的引用。在 Java 编程语言中,可以通过 this 关键字来访问该实例。
类(静态)方法没有实例,所以对于这类方法而言,使用本地变量 0 是不必要的。类方法从索引 0 开始使用本地变量。如果 addTwo 方法是类方法,那么它的参数传递方式会与第一种方式类似:
static int addTwoStatic(int i, int j) {
return i + j;
}
编译为:
Method int addTwoStatic(int,int)
0 iload_0
1 iload_1
2 iadd
3 ireturn
唯一的区别在于方法参数是从局部变量 0 开始出现的,而非从 1 开始。
方法调用
对于实例方法而言,其常规的调用方式是依据对象的运行时类型来进行的。(在 C++ 的术语中,它们是虚方法。)这种调用是通过使用 invokevirtual 指令来实现的,该指令的参数是一个指向运行时常量池条目索引的值,该条目给出了对象所属类类型的二进制名称的内部形式、要调用的方法的名称以及该方法的描述符。要调用前面定义的作为实例方法的 addTwo 方法,我们可以这样写:
int add12and13() {
return addTwo(12, 13);
}
编译为:
Method int add12and13()
0 aload_0 // Push local variable 0 (this)
1 bipush 12 // Push int constant 12
3 bipush 13 // Push int constant 13
5 invokevirtual #4 // Method Example.addtwo(II)I
8 ireturn // Return int on top of operand stack;
// it is the int result of addTwo()
调用操作首先通过将当前实例(即 this)的引用压入操作数栈来设置。然后,将方法调用的参数(整数值 12 和 13)压入栈中。当 addTwo 方法的帧被创建时,传递给该方法的参数将成为新帧局部变量的初始值。也就是说,由调用者压入操作数栈的 this 引用和两个参数将成为被调用方法的局部变量 0、1 和 2 的初始值。
最后,调用 addTwo。当 addTwo 返回时,其整数值返回值被压入调用者帧的操作数栈中,即 add12and13 方法的帧中。因此,返回值被放置在立即返回给 add12and13 的调用者的位置。add12and13 的返回值由 add12and13 中的 ireturn 指令处理。ireturn 指令从当前帧的操作数栈中获取 addTwo 返回的整数值,并将其压入调用者帧的操作数栈中。然后,它将控制权返回给调用者,使调用者的帧成为当前帧。Java 虚拟机为许多指令提供了不同的返回指令。其数值型和引用型数据类型,以及对于无返回值的方法而言的返回指令。对于所有类型的方法调用,都使用同一组返回指令。
invokespecial 指令的操作数(在示例中,即运行时常量池索引 #4)并非类实例中方法的偏移量。编译器并不知晓类实例的内部布局。相反,它会生成对实例方法的符号引用,这些引用存储在运行时常量池中。这些运行时常量池项在运行时会被解析以确定实际的方法位置。对于所有其他访问类实例的 Java 虚拟机指令来说,情况也是如此。
调用 addTwoStatic(这是一个类(静态)版本的 addTwo 函数)与调用 addTwo 类似,如下所示:
int add12and13() {
return addTwoStatic(12, 13);
}
尽管使用的是不同的 Java 虚拟机方法调用指令:
Method int add12and13()
0 bipush 12
2 bipush 13
4 invokestatic #3 // Method Example.addTwoStatic(II)I
7 ireturn
编译一个类(静态)方法的调用过程与编译一个实例方法的调用过程非常相似,只是调用者不会传递参数。因此,方法参数将从局部变量 0 开始接收。调用静态方法总是使用 invokestatic 指令。
调用实例初始化方法必须使用 invokespecial 指令。在调用超类(super)中的方法以及调用私有方法时也会使用该指令。例如,给定声明了 Near 和 Far 两个类的如下代码:
class Near {
int it;
public int getItNear() {
return getIt();
}
private int getIt() {
return it;
}
}
class Far extends Near {
int getItFar() {
return super.getItNear();
}
}
Near.getItNear(该方法通过调用一个私有方法来实现)这一方法变得能够实现以下功能:
Method int getItNear()
0 aload_0
1 invokespecial #5 // Method Near.getIt()I
4 ireturn
Far.getItFar 方法(它调用了超类方法)的实现方式变为:
Method int getItFar()
0 aload_0
1 invokespecial #4 // Method Near.getItNear()I
4 ireturn
请注意,使用 invokespecial 指令调用的方法总是将 this 作为其第一个参数传递给被调用的方法。通常情况下,它会作为局部变量 0 被接收。 要调用方法柄的目标,编译器必须形成一个方法描述符,该描述符记录实际的参数和返回类型。编译器不能对参数进行方法调用转换;相反,它必须根据自身未转换的类型将它们压入栈中。编译器通常会安排将方法柄对象的引用压入栈之前压入参数,就像往常一样。编译器会发出一个 invokevirtual 指令,该指令引用一个描述符,该描述符描述了参数和返回类型。通过与方法解析的特殊安排,如果方法描述符语法正确且描述符中命名的类型可以解析,则 invokespecial 指令调用 java.lang.invoke.MethodHandle 的 invokeExact 或 invoke 方法将始终链接。
使用类实例进行操作
Java 虚拟机类实例是通过 Java 虚拟机的 new 指令来创建的。请回想一下,在 Java 虚拟机层面,构造函数表现为具有编译器自动生成名称 < init> 的方法。这个具有特殊名称的方法被称为实例初始化方法。对于给定的类,可能会存在多个实例初始化方法,对应于多个构造函数。一旦类实例被创建,并且其实例变量(包括该类及其所有超类的实例变量)已被初始化为其默认值,就会调用新类实例的实例初始化方法。例如:
Object create() {
return new Object();
}
编译为:
Method java.lang.Object create()
0 new #1 // Class java.lang.Object
3 dup
4 invokespecial #4 // Method java.lang.Object.<init>()V
7 areturn
类实例的传递和返回(作为引用类型)与数值传递和返回非常相似,尽管类型引用也有其自身的一套指令集,例如:
int i; // An instance variable
MyObj example() {
MyObj o = new MyObj();
return silly(o);
}
MyObj silly(MyObj o) {
if (o != null) {
return o;
} else {
return o;
}
}
编译后:
Method MyObj example()
0 new #2 // Class MyObj
3 dup
4 invokespecial #5 // Method MyObj.<init>()V
7 astore_1
8 aload_0
9 aload_1
10 invokevirtual #4 // Method Example.silly(LMyObj;)LMyObj;
13 areturn
Method MyObj silly(MyObj)
0 aload_1
1 ifnull 6
4 aload_1
5 areturn
6 aload_1
7 areturn
类实例的字段(实例变量)是通过 getfield 和 putfield 指令来访问的。如果 i 是一个类型为 int 的实例变量,那么定义了 setIt 和 getIt 这两个方法,其作用如下:
void setIt(int value) {
i = value
}
int getIt() {
return i;
}
编译为:
Method void setIt(int)
0 aload_0
1 iload_1
2 putfield #4 // Field Example.i I
5 return
Method int getIt()
0 aload_0
1 getfield #4 // Field Example.i I
4 ireturn
与方法调用指令的操作数一样,putfield 和 getfield 指令的操作数(运行时常量池索引 #4)并非类实例中字段的偏移量。编译器会生成对实例字段的符号引用,这些引用存储在运行时常量池中。这些运行时常量池项在运行时被解析,以确定所引用对象中字段的位置。
数组
Java 虚拟机中的数组也是对象。数组的创建和操作使用一组特定的指令来完成。newarray 指令用于创建一个数值类型的数组。代码:
void createBuffer() {
int buffer[];
int bufsz = 100;
int value = 12;
buffer = new int[bufsz];
buffer[10] = value;
value = buffer[11];
}
可能编译为:
Method void createBuffer()
0 bipush 100 // Push int constant 100 (bufsz)
2 istore_2 // Store bufsz in local variable 2
3 bipush 12 // Push int constant 12 (value)
5 istore_3 // Store value in local variable 3
6 iload_2 // Push bufsz...
7 newarray int // ...and create new int array of that length
9 astore_1 // Store new array in buffer
10 aload_1 // Push buffer
11 bipush 10 // Push int constant 10
13 iload_3 // Push value
14 iastore // Store value at buffer[10]
15 aload_1 // Push buffer
16 bipush 11 // Push int constant 11
18 iaload // Push value at buffer[11]...
19 istore_3 // ...and store it in value
20 return
“anewarray”指令用于创建一个对象引用构成的一维数组,例如:
void createThreadArray() {
Thread threads[];
int count = 10;
threads = new Thread[count];
threads[0] = new Thread();
}
编译为:
Method void createThreadArray()
0 bipush 10 // Push int constant 10
2 istore_2 // Initialize count to that
3 iload_2 // Push count, used by anewarray
4 anewarray class #1 // Create new array of class Thread
7 astore_1 // Store new array in threads
8 aload_1 // Push value of threads
9 iconst_0 // Push int constant 0
10 new #1 // Create instance of class Thread
13 dup // Make duplicate reference...
14 invokespecial #5 // ...for Thread's constructor
// Method java.lang.Thread.<init>()V
17 aastore // Store new Thread in array at 0
18 return
newarray 指令还可用于创建多维数组的第一维。另外,multianewarray 指令可用于一次性创建多个维度。例如,以下是一个三维数组:
int[][][] create3DArray() {
int grid[][][];
grid = new int[10][5][];
return grid;
}
编译为:
Method int create3DArray()[][][]
0 bipush 10 // Push int 10 (dimension one)
2 iconst_5 // Push int 5 (dimension two)
3 multianewarray #1 dim #2 // Class [[[I, a three-dimensional
// int array; only create the
// first two dimensions
7 astore_1 // Store new array...
8 aload_1 // ...then prepare to return it
9 areturn
多维数组指令(multianewarray)的第一个操作数是用于创建数组类类型的运行时常量池索引。第二个操作数是实际要创建的该数组类型的维度数量。多维数组指令可用于创建该类型的全部维度,正如 create3DArray 的代码所示。请注意,多维数组只是一个对象,因此通过 aload_1 和 areturn 指令分别加载和返回。
所有数组都有关联的长度,可通过 arraylength 指令访问。
编译Switches
switch 语句的编译使用了 tableswitch 和 lookupswitch 指令。 当 switch 语句的各个 case 可以有效地表示为一个目标偏移量表中的索引时,就会使用 tableswitch 指令。如果 switch 语句表达式的值超出有效索引范围,则使用 switch 的默认目标。例如:
int chooseNear(int i) {
switch (i) {
case 0: return 0;
case 1: return 1;
case 2: return 2;
default: return -1;
}
}
编译为:
Method int chooseNear(int)
0 iload_1 // Push local variable 1 (argument i)
1 tableswitch 0 to 2: // Valid indices are 0 through 2
0: 28 // If i is 0, continue at 28
1: 30 // If i is 1, continue at 30
2: 32 // If i is 2, continue at 32
default:34 // Otherwise, continue at 34
28 iconst_0 // i was 0; push int constant 0...
29 ireturn // ...and return it
30 iconst_1 // i was 1; push int constant 1...
31 ireturn // ...and return it
32 iconst_2 // i was 2; push int constant 2...
33 ireturn // ...and return it
34 iconst_m1 // otherwise push int
Java 虚拟机的 tableswitch 和 lookupswitch 指令仅对 int 类型的数据进行操作。由于对 byte、char 或 short 类型的值进行操作时,其内部会被提升为 int 类型,因此当表达式的结果为这些类型之一时,switch 语句会被编译为类似于结果为 int 类型时的编译方式。如果 chooseNear 方法是使用 short 类型编写的,那么生成的 Java 虚拟机指令与使用 int 类型时相同。其他数值类型在使用 switch 语句时必须转换为 int 类型。
当 switch 的情况较少时,tableswitch 指令的表表示在空间方面会变得效率低下。可以使用 lookupswitch 指令代替。lookupswitch 指令将 int 类型的键(即 case 标签的值)与表中的目标偏移量进行配对。当执行 lookupswitch 指令时,会将 switch 语句表达式的值与表中的键进行比较。如果其中一个键与表达式的值匹配,则执行继续在关联的目标偏移量处继续。如果没有键匹配,则执行继续在默认目标处继续。例如,对于以下编译代码:
int chooseFar(int i) {
switch (i) {
case -100: return -1;
case 0: return 0;
case 100: return 1;
default: return -1;
}
}
看起来就跟 chooseNear 函数的代码一样,只是少了 lookupswitch 指令这一部分:
Method int chooseFar(int)
0 iload_1
1 lookupswitch 3:
-100: 36
0: 38
100: 40
default: 42
36 iconst_m1
37 ireturn
38 iconst_0
39 ireturn
40 iconst_1
41 ireturn
42 iconst_m1
43 ireturn
Java 虚拟机规定,lookupswitch 指令的查找表必须按键值排序,以便实现方式能够采用比线性扫描更高效的搜索方式。即便如此,lookupswitch 指令仍需搜索其键值以匹配目标,而非像 tableswitch 那样简单地进行边界检查并索引到表中。因此,在空间限制允许的情况下,tableswitch 指令可能比 lookupswitch 指令更高效。
操作栈上的操作
Java 虚拟机拥有大量指令,它们能够以未类型化的值形式操作操作数栈中的内容。之所以这些指令有用,是因为 Java 虚拟机依赖于对操作数栈的巧妙操作。例如:
public long nextIndex() {
return index++;
}
private long index = 0;
编译为:
Method long nextIndex()
0 aload_0 // Push this
1 dup // Make a copy of it
2 getfield #4 // One of the copies of this is consumed
// pushing long field index,
// above the original this
5 dup2_x1 // The long on top of the operand stack is
// inserted into the operand stack below the
// original this
6 lconst_1 // Push long constant 1
7 ladd // The index value is incremented...
8 putfield #4 // ...and the result stored in the field
11 lreturn // The original value of index is on top of
// the operand stack, ready to be returned
请注意,Java 虚拟机从不允许其操作栈操作指令去修改或拆分操作栈上的单个值。
抛出和处理异常
异常是由使用 throw 关键字的程序抛出的。它的编译过程很简单:
void cantBeZero(int i) throws TestExc {
if (i == 0) {
throw new TestExc();
}
}
编译为:
Method void cantBeZero(int)
0 iload_1 // Push argument 1 (i)
1 ifne 12 // If i==0, allocate instance and throw
4 new #1 // Create instance of TestExc
7 dup // One reference goes to its constructor
8 invokespecial #7 // Method TestExc.<init>()V
11 athrow // Second reference is thrown
12 return // Never get here if we threw TestExc
try-catch 结构的编译是相当简单的。例如:
void catchOne() {
try {
tryItOut();
} catch (TestExc e) {
handleExc(e);
}
}
编译为:
Method void catchOne()
0 aload_0 // Beginning of try block
1 invokevirtual #6 // Method Example.tryItOut()V
4 return // End of try block; normal return
5 astore_1 // Store thrown value in local var 1
6 aload_0 // Push this
7 aload_1 // Push thrown value
8 invokevirtual #5 // Invoke handler method:
// Example.handleExc(LTestExc;)V
11 return // Return after handling TestExc
Exception table:
From To Target Type
0 4 5 Class TestExc
仔细观察就会发现,try 块的编译过程与 try 块不存在时的情况完全相同:
Method void catchOne()
0 aload_0 // Beginning of try block
1 invokevirtual #6 // Method Example.tryItOut()V
4 return // End of try block; normal return
如果在 try 块的执行过程中没有抛出任何异常,那么它就会表现得好像 try 块不存在一样:此时会调用 tryItOut 函数并返回 catchOne 函数的结果。
紧跟在 try 块之后的是实现单个 catch 子句的 Java 虚拟机代码:
5 astore_1 // Store thrown value in local var 1
6 aload_0 // Push this
7 aload_1 // Push thrown value
8 invokevirtual #5 // Invoke handler method:
// Example.handleExc(LTestExc;)V
11 return // Return after handling TestExc
Exception table:
From To Target Type
0 4 5 Class TestExc
调用 handleExc 时,catch 子句中的内容也会像普通方法调用那样进行编译。然而,由于存在 catch 子句,编译器会生成一个异常表条目。catchOne 方法的异常表有一个条目对应于 catchOne 的 catch 子句能够处理的一个参数(TestExc 类的一个实例)。如果在 catchOne 的指令在索引 0 到 4 之间的执行过程中抛出一个 TestExc 类的实例值,控制会转移到索引 5 处的 Java 虚拟机代码,该代码实现了 catch 子句的块。如果 抛出的值并非 TestExc 类型的实例,catchOne 的捕获子句无法处理它。相反,该值会被重新抛出给 catchOne 的调用方。
一个 try 块可以包含多个 catch 子句:
void catchTwo() {
try {
tryItOut();
} catch (TestExc1 e) {
handleExc(e);
} catch (TestExc2 e) {
handleExc(e);
}
}
给定的 try 语句的多个 catch 子句是通过简单地将每个 catch 子句的 Java 虚拟机代码一个接一个地拼接起来,并添加到异常表中而编译出来的,如所示:
Method void catchTwo()
0 aload_0 // Begin try block
1 invokevirtual #5 // Method Example.tryItOut()V
4 return // End of try block; normal return
5 astore_1 // Beginning of handler for TestExc1;
// Store thrown value in local var 1
6 aload_0 // Push this
7 aload_1 // Push thrown value
8 invokevirtual #7 // Invoke handler method:
// Example.handleExc(LTestExc1;)V
11 return // Return after handling TestExc1
12 astore_1 // Beginning of handler for TestExc2;
// Store thrown value in local var 1
13 aload_0 // Push this
14 aload_1 // Push thrown value
15 invokevirtual #7 // Invoke handler method:
// Example.handleExc(LTestExc2;)V
18 return // Return after handling TestExc2
Exception table:
From To Target Type
0 4 5 Class TestExc1
0 4 12 Class TestExc2
如果在 try 子句的执行过程中(在索引 0 到 4 之间)抛出了一个值,该值与一个或多个 catch 子句的参数相匹配(该值是这些参数中一个或多个的实例),那么会选择第一个(最内层的)这样的 catch 子句。控制会转移到该 catch 子句块的 Java 虚拟机代码中。如果抛出的值与 catchTwo 的任何 catch 子句的参数都不匹配,Java 虚拟机会直接将该值重新抛出,而不调用 catchTwo 中任何 catch 子句中的代码。
嵌套的 try-catch 语句的编译方式与带有多个 catch 子句的 try 语句非常相似:
void nestedCatch() {
try {
try {
tryItOut();
} catch (TestExc1 e) {
handleExc1(e);
}
} catch (TestExc2 e) {
handleExc2(e);
}
}
编译为:
Method void nestedCatch()
0 aload_0 // Begin try block
1 invokevirtual #8 // Method Example.tryItOut()V
4 return // End of try block; normal return
5 astore_1 // Beginning of handler for TestExc1;
// Store thrown value in local var 1
6 aload_0 // Push this
7 aload_1 // Push thrown value
8 invokevirtual #7 // Invoke handler method:
// Example.handleExc1(LTestExc1;)V
11 return // Return after handling TestExc1
12 astore_1 // Beginning of handler for TestExc2;
// Store thrown value in local var 1
13 aload_0 // Push this
14 aload_1 // Push thrown value
15 invokevirtual #6 // Invoke handler method:
// Example.handleExc2(LTestExc2;)V
18 return // Return after handling TestExc2
Exception table:
From To Target Type
0 4 5 Class TestExc1
0 12 12 Class TestExc2
捕获子句的嵌套仅在异常表中体现。Java 虚拟机并不强制要求异常表条目的嵌套或任何特定的顺序。然而,由于 try-catch 结构是结构化的,编译器总是能够对异常处理表条目进行排序,使得对于任何抛出的异常以及该方法中的任何程序计数器值,第一个与抛出异常匹配的异常处理程序都对应于最内层匹配的 catch 子句。
例如,如果 tryItOut(在索引 1 处的调用)抛出了 TestExc1 的实例,它将由调用 handleExc1 的 catch 子句处理。即使异常发生在外层 catch 子句(捕获 TestExc2)的范围之内,即使该外层 catch 子句原本可能能够处理抛出的值,这种情况也是如此。
作为一个细微的点要注意,catch 子句的范围在“从”端是包含的,在“到”端是不包含的。因此,捕获 TestExc1 的 catch 子句的异常表条目不涵盖偏移量为 4 的返回指令。然而,捕获 TestExc2 的 catch 子句的异常表条目则涵盖了。覆盖位于偏移量 11 处的返回指令。嵌套 catch 子句中的返回指令包含在由嵌套 catch 子句所覆盖的指令范围内。嵌套的 try-catch 语句的编译方式非常类似于带有多个 catch 子句的 try 语句。
编译finally
(本节假定编译器生成的类文件版本号为 50.0 或更低,这样就可以使用 jsr 指令。)
try-finally 语句的编译过程与 try-catch 语句的编译过程类似。在将控制转移到 try 语句之外之前(无论这种转移是正常的还是突然的),由于已经抛出了异常,所以必须首先执行 finally 子句。对于这个简单的示例:
void tryFinally() {
try {
tryItOut();
} finally {
wrapItUp();
}
}
编译为:
Method void tryFinally()
0 aload_0 // Beginning of try block
1 invokevirtual #6 // Method Example.tryItOut()V
4 jsr 14 // Call finally block
7 return // End of try block
8 astore_1 // Beginning of handler for any throw
9 jsr 14 // Call finally block
12 aload_1 // Push thrown value
13 athrow // ...and rethrow value to the invoker
14 astore_2 // Beginning of finally block
15 aload_0 // Push this
16 invokevirtual #5 // Method Example.wrapItUp()V
19 ret 2 // Return from finally block
Exception table:
From To Target Type
0 4 8 any
控制流有四种方式能够脱离 try 语句块而继续执行:要么直接从该块的底部向下执行,要么返回,要么执行 break 或 continue 语句,要么抛出异常。如果 tryItOut 没有抛出异常返回,那么控制会通过 jsr 指令转移到 finally 块中。索引为 4 的 jsr 指令对索引为 14 的 finally 块中的代码进行“子程序调用”(即执行嵌入式子程序)。当 finally 块执行完毕后,索引为 4 的 ret 2 指令会将控制权返回给索引为 4 的 jsr 指令之后的指令。
更详细地说,子程序调用的工作方式如下:jsr 指令在跳转之前将后续指令(索引为 7 的 return 指令)的地址压入操作数栈。跳转的目标是 astore_2 指令,它将操作数栈中的地址存储到局部变量 2 中。finally 块的代码(在本例中是 aload_0 和 invokevirtual 指令)被执行。假设该代码正常执行完毕,ret 指令会从局部变量 2 中获取地址并从该地址继续执行。然后执行 return 指令。并且 finally 语句块会正常返回。
带有 finally 子句的 try 语句会被编译为具有一个特殊的异常处理程序,该处理程序能够处理 try 语句块内抛出的任何异常。如果 tryItOut 引发异常,那么会搜索 tryFinally 的异常表以查找合适的异常处理程序。找到了这个特殊的处理程序,执行就会继续到索引 8 处。索引 8 处的 astore_1 指令将抛出的值存储到局部变量 1 中。索引 12 处的 following jsr 指令对 finally 块的代码进行子程序调用。假设该代码正常返回,那么索引 12 处的 aload_1 指令会将抛出的值推回到操作数栈中,然后索引 13 处的 athrow 指令会重新抛出该值。
同步
同步 Java 虚拟机中的同步是通过监视器进入和退出来实现的,其方式要么是显式(通过使用 monitorenter 和 monitorexit 指令),要么是隐式(通过方法调用和返回指令)。
对于用 Java 编程语言编写的代码而言,也许最常见的同步形式是同步方法。同步方法通常不会使用 monitorenter 和 monitorexit 来实现,而是通过运行时常量池中的 ACC_SYNCHRONIZED 标志来加以区分,该标志由方法调用指令(§2.11.10)进行检查。
monitorenter 和 monitorexit 指令使得同步语句能够进行编译。例如:
void onlyMe(Foo f) {
synchronized(f) {
doSomething();
}
}
编译为:
Method void onlyMe(Foo)
0 aload_1 // Push f
1 dup // Duplicate it on the stack
2 astore_2 // Store duplicate in local variable 2
3 monitorenter // Enter the monitor associated with f
4 aload_0 // Holding the monitor, pass this and...
5 invokevirtual #5 // ...call Example.doSomething()V
8 aload_2 // Push local variable 2 (f)
9 monitorexit // Exit the monitor associated with f
10 goto 18 // Complete the method normally
13 astore_3 // In case of any throw, end up here
14 aload_2 // Push local variable 2 (f)
15 monitorexit // Be sure to exit the monitor!
16 aload_3 // Push thrown value...
17 athrow // ...and rethrow value to the invoker
18 return // Return in the normal case
Exception table:
From To Target Type
4 10 13 any
13 16 13 any
编译器会确保在任何方法调用完成后,对于自该方法调用开始执行的所有 monitorenter 指令,都会执行相应的 monitorexit 指令。无论该方法调用是正常完成(§2.6.4)还是突然中断,情况都是如此。为了在方法调用突然中断时确保 monitorenter 和 monitorexit 指令的正确配对,编译器会生成异常处理程序,这些处理程序能够匹配任何异常,并且其关联的代码会执行必要的 monitorexit 指令。
注解
类文件中注释的表示方式在第 4.7.16 至 4.7.22 节中有描述。 这些章节清楚地说明了如何表示类、接口、字段、方法、方法参数和类型参数的声明中的注释,以及在这些声明中使用的类型上的注释。对于包声明上的注释,需要遵循额外的规则,这里给出的是这些规则。
当编译器遇到必须在运行时可用的带注释的包声明时,它会生成具有以下属性的类文件:
- 这个类文件表示一个接口,也就是说,ClassFile 结构中的 ACC_INTERFACE 和 ACC_ABSTRACT 标志会被设置(第 4.1 节)。
- 如果类文件版本号小于 50.0,则 ACC_SYNTHETIC 标志会被取消;如果类文件版本号为 50.0 或更高,则 ACC_SYNTHETIC 标志会被设置。
- 接口具有包访问权限(JLS 第 6.6.1 节)。
- 接口的名称是 package-name.packageinfo 的内部形式(第 4.2.1 节)。
- 接口没有超接口。
- 接口的唯一成员是 Java 语言规范(Java SE 8 版本)中所暗示的那些成员(JLS 第 9.2 节)。
- 包声明上的注释会被存储为在 ClassFile 结构的属性表中存在 RuntimeVisibleAnnotations 和 RuntimeInvisibleAnnotations 属性。