Java虚拟机(JVM)是为Java语言设计的,Oracle JDK 中包含了能将Java代码转换为JVM指令的编译器及运行JVM的系统。深入了解编译器和JVM对想要编写编译器或理解JVM结构的人来说很有帮助。编译器一词也指代其他类型的翻译工具,如即时编译器(JIT)
常量、局部变量和控制结构的使用
void spin() {
int i;
for (i = 0; i < 100; i++) {
; // Loop body is empty
}
}
0 iconst_0 // 将整数常量0压入栈顶
1 istore_1 // 将栈顶整数存入本地变量1中(即 i=0)
2 goto 8 // 首次通过时不增加
5 iinc 1 1 // 本地变量1自增1(即 i++)
8 iload_1 // 将本地变量1(即 i)压入栈顶
9 bipush 100 // 将整型常量100压入栈顶
11 if_icmplt 5 // 比较栈顶两int值大小,并如果小于(i < 100),则跳转
14 return // 执行完毕后返回void
Java虚拟机是基于栈的架构,大部分操作都涉及从操作数栈中取出一个或多个操作数,或者把结果推回操作数栈中。每当调用一个方法时,都会创建一个新的帧,为该方法创建一个新的操作数栈和一组本地变量(局部变量)。在任何一次计算过程中,由于众多嵌套的方法调用,可能会存在许多帧和同等数量的操作数栈。每个线程控制下,只有当前帧的操作数栈是活动的。
Java虚拟机指令集能够识别不同数据类型的操作数,通过使用不同的字节码来操作这些操作数。比如方法spin只操作int类型的值。它所采用的指令(如iconst_0、istore_1、iinc、iload_1、if_icmplt等)专门用于操作int类型。
在spin方法中,两个常量0和100通过两种不同的指令被推入操作数栈。0是通过iconst_0指令推入的,这属于iconst_<i>系列。而100是通过bipush指令推入的,这个指令将其推送值作为立即数携带。
Java虚拟机通过将一些操作数值(如iconst_<i>指令中的int常量-1、0、1、2、3、4、5)隐含在操作码中,充分利用这些值的可能性。比如iconst_0指令隐含了它会推送一个int类型的0,不需要存储或解析任何额外的信息。虽然用bipush 0编译推送0的操作是正确的,但这会让spin的编译代码多一个字节,并且在虚拟机执行时引入额外的获取和解码操作数的时间开销。使用隐含操作数可以使编译后的代码更加简洁高效。
在spin中,int变量i被存储在本地变量1里。由于Java虚拟机的大部分指令操作的是从操作数栈弹出的值,而不是直接在本地变量上进行操作,所以在Java虚拟机编译的代码中,经常可以看到在本地变量和操作数栈间传输值的指令。在spin方法中,istore_1和iload_1指令用来在本地变量1与操作数栈之间传递值,这些指令隐式地作用于本地变量1。istore_1从操作数栈中弹出一个int值并存储到本地变量1中,iload_1则将本地变量1的值推入操作数栈。
局部变量的使用和重复使用是编译器作者的责任,专门的加载和存储指令鼓励编译器尽可能地重用本地变量,这样生成的代码不仅运行更快,代码更紧凑,而且在帧中占用的空间也更小。
Java虚拟机对频繁进行的操作提供了特别的支持,例如iinc指令就通过一个字节的有符号值来增加本地变量的内容。在spin方法中,iinc指令增加了第一个本地变量(第一个操作数)1(第二个操作数),这在实现循环结构时特别便捷。
spin的for循环主要由这些指令完成:
5 iinc 1 1 // 本地变量1自增1(即 i++)
8 iload_1 // 将本地变量1(即 i)压入栈顶
9 bipush 100 // 将整型常量100压入栈顶
11 if_icmplt 5 // 比较栈顶两int值大小,并如果小于(i < 100),则跳转
bipush指令将整数100推入到操作数栈中,然后if_icmplt指令将这个值从操作数栈中弹出,并与i进行比较。如果比较成功(即变量i小于100),则执行流转移到索引5,开始下一次的for循环迭代。否则,执行流会继续到if_icmplt指令后的指令。
如果在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 // 将双精度浮点数0.0推送至栈顶
1 dstore_1 // 将栈顶双精度浮点数存入局部变量1和2
2 goto 9 // 第一次通过时不递增
5 dload_1 // 将局部变量1和2中的双精度浮点数推送至栈顶
6 dconst_1 // 将双精度浮点数1.0推送至栈顶
7 dadd // 执行加法操作;没有对应的dinc指令
8 dstore_1 // 将加法操作结果存入局部变量1和2
9 dload_1 // 将局部变量1和2中的双精度浮点数推送至栈顶
10 ldc2_w #4 // 将双精度浮点数100.0推送至栈顶
13 dcmpg // 执行比较操作;没有对应的if_dcmplt指令
14 iflt 5 // 如果比较结果小于0(即i < 100.0),循环跳转至指令5
17 return // 完成后返回_void
双精度值占用两个局部变量,尽管只能使用两个局部变量中较小的索引来访问它们。对于 long 类型的值也是如此。再举个例子,
double doubleLocals(double d1, double d2) {
return d1 + d2;
}
编译成这样
Method double doubleLocals(double,double)
0 dload_1 // 局部变量 1 和 2 中的第一个参数
1 dload_3 // 局部变量 3 和 4 中的第二个参数
2 dadd
3 dreturn
请注意,用于存储双精度值的局部变量对应的变量,不应当被单独操作。
由于Java虚拟机的操作码仅有1个字节,其编译后的代码非常紧凑。然而,1字节的操作码同时也意味着Java虚拟机指令集必须保持较小的大小。作为一种折中,Java虚拟机对所有数据类型的支持并不完全相同:它并非完全正交(见表2.11.1-A)。
例如,在示例spin中,对于类型为int的值的比较,可以使用一个单独的if_icmplt指令来实现;但是,Java虚拟机指令集中没有单个指令能够对类型为double的值执行条件分支。因此,dspin必须使用一个dcmpg指令,随后跟着一个iflt指令来实现对类型为double的值的比较。
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类型)的指令,并在必要时在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和浮点类型有中等程度的支持,它们仅缺少完整的条件控制转移指令集。
算术运算
Java虚拟机通常在其操作数栈上执行算术操作。(例外是iinc指令,它直接增加局部变量的值。)例如,align2grain方法将一个int值与给定的2的幂对齐:
int align2grain(int i, int grain) {
return ((i + grain-1) & ~(grain-1));
}
align2grain的方法,它接收两个整型参数i和grain,并返回一个按照grain指定的2的幂进行对齐的整数。
下面是这个方法的逻辑解释:
i + grain - 1:首先将输入的i加上grain(2的幂)减去1。这一步是为了确保在进行位运算时能够上舍入
到最近的grain的倍数。比如,如果grain是8并且i是任意整数,如果i本身不是8的倍数,加上7之后肯定
会超过下一个8的倍数。
~(grain - 1):这是一个位取反操作。先对grain减去1得到一个数,这个数的二进制表示中,低位上
grain代表的2的幂次方位全是1,其余位是0。比如,如果grain是8,则grain-1为7,其二进制为0111。
对它取反得到~(grain-1)的结果是1000,即二进制的高位是1,其余都是0。
((i + grain - 1) & ~(grain - 1)):最终结果通过之前得到的上舍入值和位取反的值进行位与运算。
位与运算保留了两个操作数中均为1的位。这段代码通过与操作去除了除grain的最小幂次方以上的位,
确保了结果是小于或等于原来的i且为grain的倍数。
算术运算的操作数是从操作数栈中取出的,而运算的结果则会被推入操作数栈。这样,算术子计算的结果就能作为它们所在嵌套计算的操作数使用。例如,计算~(grain-1)这个操作就是通过这些指令来实现的:
5 iload_2 // 将变量grain的值压入操作数栈
6 iconst_1 // 将整数常量1压入操作数栈
7 isub // 执行减法操作,并将结果压入操作数栈
8 iconst_m1 // 将整数常量-1(即位反码表示的1)压入操作数栈
9 ixor // 执行异或操作,并将结果压入操作数栈
首先,使用本地变量2的内容和一个直接给定的整数值1来计算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
访问运行时常量池
在Java里,要通过当前类的运行时常量池去访问一大堆数值常量、对象、类中的字段和方法。至于int、long、float和double这几种类型的数据,还有对String类实例的引用,都是用ldc、ldc_w、ldc2_w这几个指令来搞定的。
说到ldc和ldc_w指令,它们主要用来从运行时常量池里拿到非double和long类型的值的,String类的实例也包括在内。如果常量池里东西太多,需要用更大的索引号去访问某个东西的时候,就用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;
}
编译如下:
Method void useManyNumeric()
0 bipush 100 // 使用bipush指令推入小int常量
2 istore_1
3 ldc #1 // 使用ldc指令推入大int常量(1000000)
5 istore_2
6 lconst_1 // 微小的long值使用快速的lconst_1指令
7 lstore_3
8 ldc2_w #6 // 推入long 0xffffffff(也就是,一个int -1)
// 任何long常量值可以使用ldc2_w推入
11 lstore 5
13 ldc2_w #8 // 推入double常量 2.200000
// 不常见的double值也可用ldc2_w推入
16 dstore 7
本文暂时没有评论,来添加一个吧(●'◡'●)