java虚拟机规范(se8)——java虚拟机的编译(一)
本文翻译自:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html
第三章 java虚拟机的编译
java虚拟机是设计用来支持java编程语言的。Oracle的JDK软件包含了一个将Java源代码编译成java虚拟机指令集的编译器,以及一个用于java虚拟机本身的运行时系统。了解编译器如何使用java虚拟机对编译器作者来说是有用的,同样也有助于理解java虚拟机本身。本章中编号的部分不是规范性的。
注意,术语“编译器”有时用于指从Java虚拟机的指令集到特定CPU的指令集的转换程序。这种转换器的一个例子是即时(just-in-time, JIT)代码生成器,它只在加载Java虚拟机代码之后生成特定于平台的指令。本章不讨论与代码生成相关的问题,只讨论与将用Java编程语言编写的源代码编译为Java虚拟机指令相关的问题。
3.1 示例的格式
本章主要由源代码示例和带注释的Java虚拟机代码清单组成,这些代码是由Oracle的JDK 1.0.2版本中的javac编译器为这些示例生成的。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
或者:
9 invokevirtual #4 // Method Example.addTwo(II)I
本章节主要目的是描述虚拟机的编译过程,我们将忽略一些诸如操作数容量等细节问题。
3.2 常量、局部变量和控制结构的使用
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 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类型的值。它编译后的代码中的指令都选择了针对int型的数据类型操作指令(iconst_0, istore_1, iinc, iload_1, if_icmplt)。
spin方法中的两个常量0和100,使用两个不同的指令压入操作数栈。压入0使用了iconst_0指令,是iconst_<i>指令家族之一。压入100使用了bipush指令,这个指令获取它的立即数压入栈中。
java虚拟机经常使用操作码隐式的包含操作数(如整型常量-1,0,1,2,3,4和5在iconst_<i>指令的例子)。因为iconst_0指令知道它将要压入一个整数0,iconst_0不再需要存储一个操作数来告诉它应该压入哪个值,也不需要获取和解析一个操作数。将压入0编译成bipush 0也是正确的,凡是会导致spin编译后的代码长度增加一个字节。一个简单的虚拟机也会在每次循环中花费额外的时间来获取和解码显式的操作数。使用隐含的操作数使得编译后的代码更加紧凑和高效。
spin方法中的i存在java虚拟机局部变量1中。因为大多数java虚拟机指令操作从操作数栈中弹出的值,而不是直接使用局部变量,在为Java虚拟机编译的代码中,在局部变量和操作数堆栈之间传输值的指令很常见。这些操作同样被指令集特殊的支持。在spin方法中,值在局部变量表中传输使用istore_1和iload_1指令,每个指令都隐式的操作局部变量表中位置为1的值。istore_1指令从操作数栈中弹出一个int值,然后存入局部变量1中。iload_1指令将局部变量1的值压入操作数栈。
使用和重用局部变量是编译器作者决定的。特殊的load和store指令应该鼓励编译器作者尽可能的重用局部变量。这样编译后的代码会更快,更紧凑,并且使用栈帧更少的空间。
对局部变量的某些非常频繁的操作由Java虚拟机专门处理。iinc指令为局部变量增加一个长度为1字节有符号的值。spin中的iinc指令将第一个局部变量(这个指令的第一个操作数)加1(这个指令的第二个操作数)。iinc指令很适合实现循环结构。
spin中的fou循环主要由以下指令来实现:
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指令后面的指令。
如果spin例子中的循环计数器使用了int职位的数据类型,那么编译后的代码也会随之改成相应的类型。例如,将spin例子int改成double:
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 less than (i < 100.0) 17 return // Return void when done
现在指令操作的数据类型是专门针对double的(ldc2_w指令稍后会在本章讨论)。
回想一下,double类型的值将占据两个局部变量,尽管只使用最小的索引值去访问这两个局部变量。这同样对longleix生效。再看一个例子:
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中的double值,这对变量绝不能单独操作。
java虚拟机使用一字节大小的操作码的结果是编译后代码非常紧凑。但是一字节操作码也意味着java虚拟机的指令集非常小。作为折中,java虚拟机并不为每种数据类型提供相等的支持:他们并非完全正交的。
例如,在spin的例子中使用了单独的if_icmplt指令来实现for语句中的int值的比较;然而,java虚拟机指令集中对于double类型并没有单独的指令来实现同样的效果。因此在dspin中比较double类型的值,必须在iflt指令之后使用dcmpg指令。
java虚拟机对于int类型中的大多操作提供了直接支持。这在一定程度上是考虑到了java虚拟机操作数栈和局部变量表的实现效率。当然也考虑了大多数程序都会对int进行频繁操作的原因。对于其他的整型数据只有很少的直接支持。例如,没有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和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指令对字节,字符和短数据执行操作。 唯一的额外成本是将int操作的值截断为有效范围。
Java虚拟机对于long和浮点类型(float和double)提供了中等程度的支持,仅缺少条件转移指令部分。