Java虚拟机是为支持Java编程语言而设计的。Oracle的JDK软件包括两部分内容:一部分是将Java源代码编译成Java虚拟机的指令集的编译器,另一部分是用于实现Java虚拟机的运行时环境。理解编译器是如何与Java虚拟机协同工作的,对编译器开发人员来说很有好处,同样也有助于理解Java虚拟机本身。本章内容用于示意,并不属于规范内容。
请注意,术语 “编译器”(Compiler) 在某些场景中专指把Java虚拟机的指令集转换为特定CPU指令集的翻译器。例如,即时代码生成器(Just-In-Time/JIT code generator)就是一种在class文件中的代码被Java虚拟机代码加载后,生成与平台相关的特定指令的编译器。但是本章讨论的编译器不考虑这类代码生成问题,只涉及那种把Java语言编写的源代码编译为Java虚拟机指令集的编译器。
本章中的示例主要包括源代码和带注解的Java虚拟机指令清单,其中,指令清单由Oracle的1.0.2版本的JDK的javac编译器生成。Java虚拟机指令代码将使用Oracle的 javap工具所生成的非正式 “虚拟机汇编语言”(virtual machine assembly language)来描述。 读者可以自行使用 javap命令,根据已编译好的方法再生成一些例子。
如果读者阅读过汇编代码,应该很熟悉示例中的格式。所有指令的格式如下:
<index><opcode> [<operandl> [<operand2>...]] [<comment>]
< index >是指令操作码在数组中的下标,该数组以字节形式来存储当前方法的Java虚拟机代码(见4.7.3小节)。也可以认为< index >是相对于方法起始处的字节偏移量。< opcode > 为指令的操作码的助记符号,< operandN >是指令的操作数,一条指令可以有0至多个操作数。< vcomment >为行尾的注释,比如:
8 bipush 100 // Push int constant 100
注释中的某些部分由javap自动加入,其余部分由作者手动添加。每条指令之前的 < index >可以作为控制转移指令(control transfer instruction) 的跳转目标。例如,goto 8
指令表示跳转到索引为8的指令上继续执行。需要注意的是,Java虚拟机的控制转移指令的实际操作数是在当前指令的操作码集合中的地址偏移量,但这些操作数会被javap工具按照更容易阅读的方式来显示(本章也以这种方式来表示)。
每一行中,表示运行时常量池索引的操作数前,会有井号(’#’),在指令后的注释中,会带有对这个操作数的描述,比如:
10 ldc #1 // Push float constant 100.0
或
9 invokevirtual #4 // Method Example.addTwo(II)I
本章主要目的是描述虚拟机的编译过程,我们将忽略一些诸如操作数容量等的细节问题。
㊀ 控制结构(control construct)是指控制程序执行路径的语句体。例如for、while等循环、条件分支等。
——译者注
Java虚拟机的代码展示了 Java虚拟机的设计和类型使用所遵循的一些通用特性。从第一个例子我们就可以感受到许多这类特性,现详解如下。
spin是很简单的方法,它进行了100次空循环:
void spin() {
int i;
for (i = 0; i < 100; i++) {
; // Loop body is empty
}
}
编译后可能会产生如下代码:
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 line 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虚拟机的大多数操作是从当前栈帧的操作数栈取出1个或多个操作数,或将结果压入操作数栈中。每调用一个方法,都会创建一个新的栈帧,并创建对应方法所需的操作数栈和局部变量表(见2.6节)。每个线程在运行的任意时刻,都会包含若干个由嵌套的方法调用而产生的栈帧,同时也会包含等量操作数栈,但是只有当前栈帧中的操作数栈才是活动的。
Java虚拟机指令集使用不同的字节码来区分不同的操作数类型,以操作各种类型的数据。在spin方法中,只有针对int类型的运算。因此在编译码里面,对类型数据进行操作的指令(iconst_0、istore_1、iinc、iload_1、if_icmplt)都是针对 int 类型的。
在spin方法中,0和100两个常量分别使用了两条不同的指令压入操作数栈。对于0采用了 iconst_0指令,它属于iconst_< i >指令族。而对于100则采用bipush指令,这个指令会获取它的**直接操作数(immediate operand)**㊀,并将其压入操作数栈中。
㊀在指令流中直接跟随在指令后面,而不是在操作数栈中的操作数称为直接操作数(也直译为立即操作
数)。——译者注
Java虚拟机经常利用操作码来隐式地包含某些操作数,例如指令iconst_< i >可以压入int 常量-1、0、1、2、3、4或5。iconst_0表示把int类型的0值压入操作数栈,这样iconst_0就不需要专门为入栈操作保存直接操作数的值了,而且也避免了操作数的读取和解析步骤。在本例中,把压入0这个操作的指令由iconst_0改为bipush 0也能获取正确的结果,但是spin 的编译码会因此额外增加1个字节的长度。简单实现的虚拟机可能要在每次循环时消耗更多的 时间用于获取和解析这个操作数。因此使用隐式操作数可让编译后的代码更简洁、更高效。
在spin方法中,int类型的i保存在第一个局部变量中㊁。因为大部分Java虚拟机指令操作的都是从栈中弹出的值,而不是局部变量本身,所以在针对Java虚拟机所编译的代码中,经常见到在局部变量表和操作数栈之间传递值的指令。在指令集里,这类操作也有特殊的支持。spin方法第一个局部变量的传递由istore_1和iload_1指令完成,这两个指令都默认是对第一个局部变量进行操作的。istore_1指令的作用是从操作数栈中弹出一个int类型的值,并保存在第一个局部变量中。iload_1指令作用是将第一个局部变量的值压入操作数栈。
㊁ 请注意,局部变量的编号从0开始。——译者注
如何使用(以及重用)局部变量是由编译器的开发者来决定的。由于有了专门定制的load和store指令,所以编译器的开发者应尽可能多地重用局部变量,这样会使得代码更高效、更简洁,占用的内存(当前栈帧的空间)更少。
某些频繁处理局部变量的操作在Java虚拟机中也有特别的指令来处理。iinc指令的作用是对局部变量加上一个长度为1字节的有符号递增量。比如spin方法中的iinc指令,它的作用是对第一个局部变量(第一个操作数)的值增加1 (第二个操作数)。iinc指令很适合实现循环结构。
spin方法的循环部分由这些指令完成:
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 指令将int类型的100压入操作数栈,然后 if_icmplt 指令将100从操作数栈中弹出并与i进行比较,如果满足条件(即i的值小于100),将转移到索引为5的指令继续执行,开始下一轮循环的迭代。否则,程序将执行 if_icmplt 的下一条指令,即 return 指令。
如果在spin例子中的循环的计数器使用了非int类型,那么编译码也需要重新调整,以反映类型上面的区别。比如,在spin例子中使用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_l // Store into local variables 1 and 2
2 goto 9 // First time through don't increment
5 dload_l // Push local variables 1 and 2
6 dconst_l // Push double constant 1.0
7 dadd // Add; there is no dine instruction
8 dstore_l // Store result in local variables 1 and 2
9 dload_l // 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 less than (i < 100.0)
17 return // Return void when done
操作特定数据类型的指令已变成针对double类型数值的指令了。(ldc2_w 指令将在本章后面内容中讨论)。
前面提到过double类型数值占用两个局部变量的空间,但是只能通过两个局部变量中索引较小的一个进行访问(这种情况对long类型也一样)。例如,下面的例子展示了double类型数值的访问:
double doubleLocals(double d1, double d2){
return d1 + d2;
}
编译后代码如下:
Method double doubleLocals(double,double)
0 dload_1 // I First argument in local variables 1 and 2
1 dload_3 // Second argument in local variables 3 and 4
2 dadd
3 dreturn
注意,局部变量表中使用了一对局部变量来存储doubleLocals方法中的double值,这一对局部变量不能分开操作。
在Java虚拟机中,操作码长度为1字节,这使得编译后的代码显得很紧凑。但是同样意味着Java虚拟机指令集必须保持一个较小的数量㊀。作为妥协,Java虚拟机无法对每一种数据类型都提供相同的支持。换句话说,这套指令集并不能完全涵盖每一种数据类型的每一种操作(见第二章表2-2)。
㊀ 不超过256条,即1字节所能表示的范围。一译者注
举例来说,在spin方法的for循环语句中,对于int类型数值的比较可以统一用 if_icmplt 指令实现;但是,在Java虚拟机指令集中,对于double类型数值则没有这样的指令。所以,在dspin方法中,对于double类型数值的操作就必须通过在 dcmpg 指令后面追加 iflt 指令来实现。
Java虚拟机支持直接对int类型的数据进行大部分操作。这在一定程度上是为了提高Java虚拟机操作数栈和局部变量表的实现效率。当然,也考虑了大多数程序都会对int类型数据进行频繁操作这一原因。Java虚拟机对其余整型数据类型的直接支持比较少,在Java虚拟机指令集中,没有对byte、char和short类型的store、load和add等指令。譬如,用short类型来实现spin中的循环时:
void sspin(){
short i;
for (i = 0; i < 100; i++){
; // Loop body is empty
}
}
针对Java虚拟机来编译上述代码时,必须使用操作其他数据类型的指令才行。我们很可能会使用操作int类型的指令来操作short,并于必要的时候在short与int之间转换,以确保对short数据的操作结果能够处于适当的范围内:
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 5
16 return
在Java虚拟机中,因缺乏对byte、char和short类型数据的直接操作指令而带来的问题并不大,因为这些类型的值都会自动提升为int类型(byte和short带符号扩展为 int类型,char零位扩展为int类型)。因此,对于byte、char和short类型的数据均可以用int的指令来操作。唯一额外的代价是要将操作结果截短到它们的有效范围内。
Java虚拟机对于long和浮点类型(float和double)提供了中等程度的支持,比起 int类型数据所支持的操作,它们仅缺少了条件转移指令部分,其他操作的支持程度都与int类型相同。
Java虚拟机通常基于操作数栈进行算术运算(只有iinc指令例外,它直接对局部变量进行自增操作)。譬如,下面的align2grain方法,它的作用是将int值对齐到某个2的幂:
int align2grain(int i, int grain){
return ((i + grain-1) & ~(grain-1));
}
算术运算使用到的操作数是从操作数栈中弹出的,运算结果被压回操作数栈中。在内部运算时,中间运算(arithmetic subcomputation)的结果可以被当做操作数使用。例如, -(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
首先,grain-1的结果由第2个局部变量和int型的直接操作数1计算得出。参与运算的操作数会从操作数栈中弹出,然后它们的差会压回操作数栈中,并用作 ixor 指令的一个操作数(因为 ~x == -1 ^ x)。相类似,ixor指令的结果接下来也将作为iand指令的操作数使用。
整个方法的编译代码如下:
Method int align2grain(int, int)
0 iload_l
1 iload_2
2 iadd
3 iconst_l
4 isub
5 iload_2
6 icons
7 isub
8 iconst_ml
9 ixor
10 land
11 ireturn
很多数值常量,以及对象、字段和方法,都是通过当前类的运行时常量池进行访问的。 对象的访问将在稍后的3.8节中讨论。int、long、float和double类型的数据,以及表示String实例的引用,将由ldc、ldc_w 和 ldc2_w指令来管理。
ldc 和 ldc_w 指令用于访问运行时常量池中的值,这包括类String的实例,但不包括double和long类型的值。当运行时常量池中的条目过多时㊀,需要使用ldc_w指令取代ldc指令来访问常量池。ldc2_w指令用于访问类型为double和long的运行时常量池项,这个指令没有非宽索引的版本㊁ 。
㊀ 多于256个,即1个字节能表示的范围。——译者注
㊁ 即没有ldc2指令。——译者注
byte、char和short型的整数常量,以及比较小的int,可以使用bipush、sipush 或 iconst_< i > 指令(参见3.2节)来编译。某些比较小的浮点常量可以用fconst_< f > 及 dconst_< d >指令来编译。
上述各情况的编译都很简单。下面这个例子将这些规则汇总起来:
void useManyNumeric(){
int i = 100;
int j = 1000000;
long 11 = 1;
long 12 = Oxffffffff;
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 Oxffffffff (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.
3.2节展示了for控制结构是如何编译的。在Java语言中还有很多其他的控制结构 (if-then-else、do、while、break以及continue)也有明确的编译方式。本规范将在3.10节、3.12节和3.13节中分别讨论关于switch语句块、异常和finally语句块的编译规则。
下面这个例子明确演示了while循环的编译规则,Java虚拟机会根据数据类型的变化而生成不同的条件跳转语句,与通常情况一样,Java虚拟机对int类型数据所提供的支持依然是最为完善的。
void whileInt(){
int i = 0;
while (i < 100){
i++;
}
}
编译后代码如下:
Method void whileInt()
0 iconst_0
1 istore_1
2 goto 8
5 line 1 1
8 iload_1
9 bipush 100
11 if_icmplt 5
14 return
注意,while语句的条件判断(由 if_icmplt 指令实现) 在Java虚拟机编译代码中位于循环最底部,这和3.2节中spin方法的条件判断位置一致。由于条件判断指令处在循环底部,所以在执行首轮迭代之前,必须先用一条goto指令跳转到这条 if_icmplt 指令才行。但如果初次判断即告失败,那么程序根本就不会进入循环体,也就等于浪费了这条goto指令。不过,while循环通常都使用在会执行很多次迭代的场景之中(而不是用来当做 if 语句使用)。 所以在接下来的迭代中,由于条件判断操作位于循环体底部,因此相当于在每次执行循环体时能够少运行一条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 dempg // To compare and branch we have to use
14 iflt 5 // ...two instructions
17 return
每个浮点类型数据都有两条比较指令:对于float类型是 fcmpl 和 fcmpg 指令,对于double是 dcmpl 和 dcmpg 指令。这些指令语义相似,仅仅在对待NaN变量时有所区别。 NaN是无序的(见2.3.2节),所以如果其中一个操作数为NaN,则所有浮点型的比较指令都失败㊀。编译器应该选用合适的比较指令来实现Java源代码中的比较操作,以确保该操作无论是在非NaN的值上面失败,还是因为遇到NaN而失败,程序都能得出相同结果,譬如:
㊀ 请注意,“失败”(fail)的意思是:当操作数中有NaN时,比较指令返回“fail”(对于fcmpl为-1,而 fcmpg为1)的结果到操作数栈,而不是抛出异常。在Java虚拟机指令集中,所有的算术比较指令都不会抛出异常。一译者注
int lessThanlOO(double d) {
if (d < 100.0) {
return 1;
} else {
return -1;
}
}
编译后代码如下:
Method int lessThanlOO(double)
0 dload_1
1 ldc2_w #4 // Push double constant 100.0
4 dcmpg // Push 1 if d is NaN or d > 100
// push 0 if d == 100.0
5 ifge 10 // Branch on 0 or 1
8 iconst_1
9 ireturn
10 iconst_m1
11 ireturn
如果d不是NaN并且小于100.0,那么dcmpg指令会将 int 类型值-1压入操作数栈,ifge 指令不会转入分支。如果d大于100.0或者d是NaN,那么dcmpg指令会将 int 类型值1压入操作数栈,而 ifge 则会转入分支。如果d等于100.0,那么dcmpg指令将 int 类型值0压入操作数栈,此时 ifge 指令也将转入分支。
如果比较逻辑相反,那么可以用出如少指令来实现正确的效果,譬如:
int greaterThanlOO(double d) {
if (d > 100.0) {
return 1;
} else {
return -1;
}
}
编译后代码如下:
Method int greaterThanlOO(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
本例与上例相似:Java源代码中的 if (d > 100.0) 判断语句无论是因为d小于等于 100而失败,还是因为d是NaN而失败,dcmpl 指令都会把适当的int值推入操作数栈,使得 iflt 指令能够转入分支。假如要在没有 dcmpl 和 dcmpg指令的前提下实现本例,那么编译器必须做更多的工作来检测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关键字来访问。
由于类(static)方法不需要传递实例引用,所以它们不需要使用第0个局部变量来保存this关键字,而是会用它来保存方法的首个参数。如果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 ire turn
两段代码唯一的区别是,后一种方法在保存参数到局部变量表中时,是从编号为0的局部变量开始而不是1。
普通实例方法调用是在运行时根据对象类型进行分派的(相当于C++中所说的“虚方法” )。这类方法调用通过invokevirtual指令实现,invokevirtual指令都会带有一个表示索引的参数,运行时常量池在该索引处的项为某个方法的符号引用,这个符号引用可以提供方法所在对象的类型的内部二进制名称、方法名称和方法描述符(见4.3.3小节)。下面这个例子定义了一个实例方法add12and13()来调用前面的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压入操作数栈中。传递给方法的int类型参数值12和13随后入栈。当调用addTwo方法时,Java虚拟机会创建一个新的栈帧,传递给addTwo方法的参数值会成为新栈帧中对应局部变量的初始值。即由 add12and13方法推入操作数栈的this和两个传递给addTwo方法的参数12与13,会作为addTwo方法栈帧的第0、1、2个局部变量。
最后,当addTwo方法执行结束、方法返回时,int类型的返回值被压入方法调用者的栈帧的操作数栈,即addl2andl3方法的操作数栈中。而这个返回值又会立即返回给addl2andl3的调用者。
addl2andl3方法的返回过程由addl2andl3方法中的ireturn指令实现。由 addTwo方法所返回的int类型值会压入当前操作数栈的栈顶,而ireturn指令则会把当前操作数栈的栈顶值(此处就是addTwo的返回值)压入addl2andl3方法的调用者的操作数栈。然后跳转至调用addl2andl3的那个方法的下一条指令继续执行,并将调用者的栈帧重新设为当前栈帧。Java虚拟机对不同数据类型(包括声明为void,即没有返回值的方法) 的返回值提供了不同的方法返回指令,各种不同返回值类型的方法都使用这一组返回指令来返回。
invokevirtual指令的操作数(前面示例中的运行时常量池索引#4 )不是类实例中方法指令的偏移量。编译器并不需要了解类实例的内部布局,它只需要产生方法的符号引用并保存于运行时常量池即可,这些运行时常量池项将会在执行时转换成调用方法的实际地址。在 Java虚拟机指令集中,访问类实例的其他指令也采用相同的方式。
如果前一个例子所调用的实例方法addTwo变成类(static)方法,那么编译代码会有略微变化,代码如下:
int add12and13() {
return addTwoStatic(12, 13);
}
编译代码中使用了另一个Java虚拟机调用指令 invokestatic:
Method int add12and13()
0 bipush 12
2 bipush 13
4 invokestatic #3 // Method Example.addTwoStatic(11)1
7 ireturn
类(static)方法调用和实例方法调用的编译代码很类似,两者的区别仅仅是实例方法需要调用者传递this参数而类方法不用。所以在两种方法的局部变量表中,序号为0的 (首个)局部变量会有所区别(见3.6节)。invokestatic指令用于调用类方法。
invokespecial 指令用于调用实例初始化方法(见3.8节),它也用来调用父类(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类的getltNear方法会(调用私有方法)被编译为:
Method int getItNear()
0 aload_0
1 invokespecial #5 // Method Near.getIt()I
4 ireturn
Far类的getltFar方法会(调用父类方法)被编译为:
Method int getItFar()
0 aload_0
1 invokespecial #4 // Method Near.getItNear()I
4 ireturn
请注意,所有使用 invokespecial 指令调用的方法都需要以this作为首个参数,保存在首个局部变量之中(通常是编号为0的局部变量)。
如果编译器要调用某个方法句柄的目标,那么必须先产生这个方法描述符,描述符记录了方法的实际参数和返回类型。编译器在方法调用时不会处理参数的类型转换问题,只是简单地将参数压入操作数栈,且不改变其类型。通常,编译器会先把指向方法句柄对象的引用压入操作数栈,方法参数则按顺序跟随这个对象之后入栈。编译器在生成 invokevirtual指令时,也会生成这个指令所引用的描述符,这个描述符提供了方法参数和返回值的信息。由于在解析方法的时候有特殊设计(参见5.4.3.3小节),所以如果方法描述符的语法正确,并且描述符中的类型名称可以正确解析,那么invokevirtual指令总是能通过调用 java.lang.invoke.MethodHandle 的 invokeExact 或 invoke 方法而链接到正确的目标。
Java虚拟机类实例通过Java虚拟机的new指令来创建。之前提到过,在Java虚拟机层面,构造函数会以一个由编译器提供的名为< init >的方法出现。这个名字特殊的方法也称作实例初始化方法(见2.9节)。一个类可以有多个构造函数,对应地也就会有多个实例初始化方法。在把类实例新建好,并将其实例变量(包括本类及其全部父类所定义的每个实例变量)都初始化为各自的默认值之后,接下来需要调用这个类实例的实例初始化方法。例如:
Object create() {
return new Object();
}
编译后代码如下:
Method java.lang.Object create()
0 new #2 // Class java.lang.Object
3 dup
4 invokespecial #4 // Method java.lang.Object.()V
7 areturn
在参数传递和方法返回时,类实例(作为reference类型)与普通的数值类型没有太大区别,不过reference类型有它自己专用的Java虚拟机指令,譬如:
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.()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类型的实例变量,且方法getlt和setlt的定义如下:
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.()V
17 aastore // Store new Thread in array at 0
18 return
anewarray 指令也可以用于创建多维数组的第一维。不过我们也可以选择采用 multianewarray 指令一次性创建多维数组。比如三维数组:
int [] [] [] create3DArray () {
int grid [][][];
grid = new int[10] [5][];
return grid;
}
编译后代码如下:
Method void 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 指令的第1个操作数是运行时常量池索引,它表示将要创建的数组类型。 第2个操作数是需要创建的数组的实际维数。multianewarray 指令可以用于创建所有类型的多维数组,当然也包括create3DArray中所展示的数组。注意,多维数组也只是一个对象,所以使用aload_1指令加载,使用areturn指令返回。数组类的命名,请参见4.4.1小节。
所有的数组都有一个与之关联的长度属性,可通过arraylength指令访问。
编译器会使用 tableswitch 和 lookupswitch 指令来生成switch语句的编译代码。 tableswitch指令用于表示switch结构中的case语句块,它可以高效地从索引表中确 定case语句块的分支偏移量。当switch语句中的条件值不能对应于索引表中任何一个 case语句块的分支偏移量时,default 分支将起作用。
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 constant -1...
35 ireturn // ・.•and return it
Java虚拟机的 tableswitch 和 lookupswitch 指令都只能支持int类型的条件值。选择支持int类型,是因为byte、char和short类型的值都会自行提升为int类型。如果 chooseNear 方法中使用short类型作为条件值,那编译出来的代码与使用int类型时是完全相同的。如果在switch中使用其他数值类型的条件值,那就必须窄化转换成int类型。
当switch语句中的case分支条件值比较稀疏时,tableswitch指令的空间使用率偏低。 这种情况下可以使lookupswitch指令来替代。lookupswitch指令的索引表项由int类型的键(来源于case语句块后面的数值)与对应的目标语句偏移量所构成。当lookupswitch指令执行时,switch语句的条件值将和索引表中的键进行比较,如果某个键和条件值相符,那么将转移到这个键所对应的分支偏移量继续执行,如果没有键值符合,将执行default分支。例如:
int chooseFar(int i) {
switch (i) {
case -100 : return -1;
case 0 : return 0;
case 100 : return 1;
default: return -1;
}
}
编译后的代码如下,相比chooseNear方法的编译代码,仅仅是把 tableswitch 指令换成了 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.()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);
}
}
编译后代码如下:
仔细观察可发现,编译后 try 语句块似乎没有生成任何指令,就像它没有出现过一样。
如果在 try 语句块执行过程中没有异常抛出,那么程序犹如没有使用 try 结构一样: 在 tryltOut 调用 catchOne 方法后就返回了。
在 try 语句块之后的Java虚拟机代码实现的那个catch语句:
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:
// Examp1e.handleExc(LTes tExc;)V
11 return // Return after handling TestExc
Exception table:
From To Target Type
0 4 5 Class TestExc
在catch语句块里,调用handleExc方法的指令和正常的方法调用完全一样。不过,每个catch语句块的会使编译器在异常表中增加一个成员(即一个异常处理器,见2.10节和4.7.3小节)。catchOne方法的异常表中有一个成员,这个成员对应于catchOne方法catch语句块中一个可捕获的异常参数(本例中为TestExc的实例)。在catchOne的执行过程中,如果编译好的代码里面第0~4句之间有TestExc异常实例被抛出,那么操作将转移至第5句继续执行,即进入catch语句块的实现步骤。如果抛出的异常不是TestExc实例,那么catchOne的catch语句块则不能捕获它,这个异常将被抛出给 catchOne方法的调用者。
一个 try 结构中可包含多个 catch 语句块,例如:
void catchTwo() {
try {
tryltOut();
} catch (TestExcl e) {
handleExc(e);
} catch (TestExc2 e) {
handleExc(e);
}
}
如果给定的 try 语句包含多个 catch 语句块,那么在编译好的代码中,多个 catch 语句块的内容将连续排列,在异常表中也会有对应的连续排列的成员,它们的排列顺序和源码中 catch 语句块的出现顺序一致。
Method void catchTwo()
0 aload_0 // Begin try block
1 invokevirtual #5 // Method Example.tryltOut()V
4 return // End of try block; normal return
5 astore_1 // Beginning of handler for TestExcl
// 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(LTestExcl;)V
11 return // Return after handling TestExcl
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 TestExcl
0 4 12 Class TestExc2
catchTwo 在执行时,如果 try 语句块中(编译代码的第0~4句)抛出了一个异常,这个异常可以被多个catch语句块捕获(即这个异常的实例是一个或多个catch语句块的参数),则Java虚拟机将选择第1个(最上层)catch语句块来处理这个异常。程序将转移至这个catch语句块对应的Java虚拟机代码块中继续执行。如果抛出的异常不是任何 catch 语句块的参数(即不能被捕获),那么Java虚拟机将把这个异常抛出给catchTwo方法的调用者,catchTwo方法里所有catch语句块中的编译代码都不会执行。
try-catch语句可以嵌套使用,编译后产生的编译代码和一个 try 语句对应多个 catch 语句的结构很相似,例如:
void nestedCatch() {
try {
try {
tryltOut();
} catch (TestExcl e) {
handleExcl(e);
}
} catch (TestExc2 e) {
handleExc2(e);
}
}
编译后代码如下:
Method void nestedCatch()
0 aload_0 // Begin try block
1 invokevirtual #8 // Method Example.tryltOut()V
4 return // End of try block; normal return
5 astore_1 // Beginning of handler for TestExcl
// 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.handleExcl(LTestExcl;)V
11 return // Return after handling TestExcl
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 TestExcl
0 12 12 Class TestExc2
catch语句的嵌套关系只体现在异常表之中。Java虚拟机本身并不强制规定异常表中成员(见2.10节)的顺序,但由于try-catch语句是有结构顺序的,所以编译器总能根据catch语句在代码中的顺序对异常处理表进行排序,以保证在代码任何位置抛出的任何异常,都会被最里层且可以处理该异常的catch语句块所处理。
例如,如果在编译代码第1句,即tryltOut方法执行过程中抛出一个TestExc1异常的实例,这个异常实例将被调用handleExc1方法的catch语句块处理。这个异常固然发生在外层catch语句(捕获TestExc2异常的catch语句)的处理范围内,但即使外层catch语句同样声明能处理这类异常,程序也依然不会把它交给外部的catch语句来处理,因为它在异常处理表中的顺序在内部异常之后。
还有一个微妙之处需要注意,catch语句块的处理范围包括from但不包括to所表示的偏移量本身(见4.7.3小节)。即catch语句块中,TestExcl在异常表所对应的异常处理器并没有覆盖到字节偏移量为4的返回指令。不过,TestExc2在异常表中对应的异常处理器却覆盖了偏移量为11处的返回指令。因此在此场景下,如果内部catch语句块中的返回指令抛出了异常,则将由外层的异常处理器进行处理。
本节假定:编译器所生成的class文件版本必定小于或等于50.0,也就是说,可以使用jsr指令来编译finally语句块。更多内容请参见4.10.2.5节㊀。
㊀ 很早之前的javac就已经不再为finally语句生成 jsr 和 ret 指令了,而是改为在每个分支之后以冗余代码
的形式来实现finally语句,所以在本节开头作者进行了特别说明。在版本号为51.0(JDK7的class
文件)的class文件中,甚至还明确禁止了指令流中出现jsr、jsr_w指令。——译者注
编译 try-finally 语句和编译 try-catch 语句基本相同。把控制权转移到 try 结构外围之前,首先要执行finally里的内容,无论 try 部分是正常执行完毕,还是在执行过程中抛出异常,都要如此。例如:
void tryFinally() {
try {
tryItOut();
} finally {
wrapItUp();
}
}
编译后代码如下:
Method void tryFinally()
0 aload_0 // Beginning of try block
1 invokevirtual #6 // Method Example.tryltOut()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
048 any
有四种方式可以让程序退出try语句:①语句块内的所有语句正常执行结束;②通过 return 语句退出方法;③执行break 或 continue 语句;④抛出异常。如果tryltOut 正常结束(没有抛出异常)并返回,那么后面的 jsr 指令会使程序跳转到finally语句块继续执行。编译代码中的第4句指令 jsr 14 意思是 “调用程序子片段" (subroutine call),这个指令使程序跳转至第14句的finally语句块 (finally语句块的内容被编译为一段程序子片段) 的实现代码之中。当finally语句块运行结束,使用 ret 2 指令将程序返回至 jsr 指令(即第4句)的下一句继续执行。
调用程序子片段的过程详述如下:在本例中,jsr 指令将其下一条指令的地址(即第7句的 return 指令)在程序跳转前压入操作数栈。程序跳转后使用astore _2 指令将栈顶的元素 (即 return 指令的地址)保存 在第2个局部变量中。然后,执行finally语句块(在这个例子中,finally语句块的内容包括 aload_0 和 invokevirtual 两条指令)。当finally语句块的代码正常执行结束后,ret 指令使程序跳转至第2个局部变量所保存的地址(即return指令的地址)继续执行,至此tryFinally方法正常返回。
一个带有finally语句块的try语句,在编译时会生成一个特殊的异常处理器,这个异常处理器可以捕获try语句中抛出的所有异常。当tryltOut抛出异常时,Java虚拟机会在tryFinally方法的异常处理器表中寻找合适的异常处理器。找到这个特殊的处理器后,会转到异常处理器的实现代码处(这个例子中为编译代码的第8句)继续执行。编译代码第8句的astore_1 指令用于将抛出的异常保存在第1个局部变量中。接下来的 jsr 指令调用finally语句块的程序子片段。如果正常返回(finally语句块正常运行结束),那么位于编译代码第12句的 aload_1 指令就会将抛出的异常压入操作数栈顶,接下来的athrow指令会将异常抛出给tryFinally方法的调用者。
同时带有catch和finally语句块的try语句编译起来更为复杂:
void tryCatchFinally() {
try {
tryltOut();
} catch (TestExc e) {
handleExc(e);
} finally {
wrapItUp();
}
}
编译后代码如下:
Method void tryCatchFinally()
0 aload_0 // Beginning of try block
1 invokevirtual #4 // Method Example.tryltOut()V
4 goto 16 // Jump to finally block
7 astore_3 // Beginning of handler for TestExc;
// Store thrown value in local var 3
8 aload_0 // Push this
9 aload_3 // Push thrown value
10 invokevirtual #6 // Invoke handler method:
// Example.handleExc(LTestExc;)V
13 goto 16 // This goto is unnecessary, but was
// generated by javac in JDK 1.0.2
16 jsr 26 // Call finally block
19 return // Return after handling TestExc
20 astore_1 // Beginning of handler for exceptions
// other than TestExc, or exceptions
// thrown while handling TestExc
21 jsr 26 // Call finally block
24 aload_1 // Push thrown value
25 athrow // ...and rethrow value to the invoker
26 astore_2 // Beginning of finally block
27 aload_0 // Push this
28 invokevirtual #5 // Method Example.wrapItUp()V
31 ret 2 // Return from finally block
Exception table:
From To Target Type
0 4 7 Class TestExc
0 16 20 any
如果try语句块中所有指令都正常执行结束,那么第4句的goto指令将使程序跳转至第16句的finally语句块之中。从第26句开始的finally语句块执行完之后,程序将跳转回第19句的return指令,至此tryCatchFinally方法运行结束。
如果tryltOut方法中抛出了TestExc类型的异常实例,那么异常表中第一个(最里层那个)可处理该异常的处理器将会被用于处理该异常。这个异常处理器的处理代码开始于第7句,该处理器将把抛出的异常对象传递给handleExec方法,并在该方法正常返回的时候调用与刚才那种情况相同的程序子片段,使程序跳转到第26句的finally语句块,如果在 handleExec方法中没有再抛出异常,那么tryCatchFinally方法将能够正常完成。
如果tryltOut方法中抛出了并非TestExc异常的异常实例,或者catch语句块中的handleExc抛出异常,那么异常处理器表中的第2个异常处理器就会生效(该处理器处理索引在0~16范围内的异常)。此时程序将跳转至第20句,进入这个异常处理器的代码,并将前面抛出的异常保存在第1个局部变量中,然后以程序子片段的形式调用第26句的 finally语句块。在在finally块结束时,程序会获取1号局部变量中的异常,并通过 athrow 指令抛给方法调用者。如果在finally子句中有异常抛出,则finally语句块将停止运行,tryCatchFinally方法也将异常退出,并把新异常抛给tryCatchFinally方法的调用者。
Java虚拟机中的同步(synchronization)是用monitor的进入和退出来实现的。无论显式同步(有明确的monitorenter和指令),还是隐式同步(依赖方法调用和返回指令实现)都是如此。
在Java语言中,同步用得最多的地方可能是经synchronized所修饰的同步方法。同步方法并不是用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小节)还是异常结束(见2.6.5小节)都应如此。为了保证在方法异常完成时,monitorenter和指令依然可以正确配对执行,编译器会自动产生一个异常处理器(见2.10节),这个异常处理器宣称自己可以处理所有的异常,它的代码用来执行monitorexit指令。
本书 4.7.16 小节至 4.7.22 小节将描述注解(annotation)在class文件中的表示方式。 那些小节将会明确指出应该如何表示添加于类、接口、字段、方法、方法参数及类型参数声明上面的注解,也会明确指出应该如何表示针对这些声明所用到的类型所写的注解。不过,给包声明所加的注解㊀还需遵循下面一些规则。
㊀ annotations on package declarations, 这种注解通常应该添加在包下的package-info.java文件里。详情可参
阅JLS §741。————译者注
如果遇到某个添加了注解的包声明,而注解又必须能够在运行时得以访问,那么编译器要生成一份具备下列特征的class文件: