Java虚拟机规范 Java SE 8版 - Java虚拟机编译器
Java虚拟机是为支持Java编程语言而设计的。Oracle的JDK软件包括两部分内容:一部分是将Java源代码编译成Java虚拟机的指令集的编译器,另一部分是用于实现Java虚拟机的运行时环境。理解编译器是如何与Java虚拟机协同工作的,对编译器开发人员来说很有好处,同样也有助于理解Java虚拟机本身。本章内容用于示意,并不属于规范内容。
请注意,术语 “编译器”(Compiler) 在某些场景中专指把Java虚拟机的指令集转换为特定CPU指令集的翻译器。例如,即时代码生成器(Just-In-Time/JIT code generator)就是一种在class文件中的代码被Java虚拟机代码加载后,生成与平台相关的特定指令的编译器。但是本章讨论的编译器不考虑这类代码生成问题,只涉及那种把Java语言编写的源代码编译为Java虚拟机指令集的编译器。
3.1 示例的格式说明
本章中的示例主要包括源代码和带注解的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
本章主要目的是描述虚拟机的编译过程,我们将忽略一些诸如操作数容量等的细节问题。
3.2 常量、局部变量和控制结构的使用㊀
㊀ 控制结构(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类型相同。
3.3 算术运算
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.4 访问运行时常量池
很多数值常量,以及对象、字段和方法,都是通过当前类的运行时常量池进行访问的。 对象的访问将在稍后的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;
i