2.3 字节码指令
本节首先介绍加载、存储指令,这一部分知识是后面章节的基础,随后介绍条件跳转、for循环、switch-case、try-catch-finally底层实现原理,最后介绍对象相关的字节码指令。
2.3.1 加载和存储指令
加载(load)和存储(store)相关的指令是使用得最频繁的指令,分为load类、store类、常量加载这三种。
1)load类指令是将局部变量表中的变量加载到操作数栈,比如iload_0将局部变量表中下标为0的int型变量加载到操作数栈上,根据不同的数据变量类型还有lload、fload、dload、aload这些指令,分别表示加载局部变量表中long、float、double、引用类型的变量。
2)store类指令是将栈顶的数据存储到局部变量表中,比如istore_0将操作数栈顶的元素存储到局部变量表中下标为0的位置,这个位置的元素类型为int,根据不同的数据变量类型还有lstore、fstore、dstore、astore这些指令。
3)常量加载相关的指令,常见的有const类、push类、ldc类。const、push类指令是将常量值直接加载到操作数栈顶,比如iconst_0是将整数0加载到操作数栈上,bipush 100是将int型常量100加载到操作数栈上。ldc指令是从常量池加载对应的常量到操作数栈顶,比如ldc #10是将常量池中下标为10的常量数据加载到操作数栈上。
为什么同是int型常量,加载需要分这么多类型呢?这是为了使字节码更加紧凑,int型常量值根据值 n 的范围,使用的指令按照如下的规则。
❏ 若n在[-1, 5] 范围内,使用iconst_n的方式,操作数和操作码加一起只占一个字节。比如iconst_2对应的十六进制为0 x05。-1比较特殊,对应的指令是iconst_m1(0x02)。
❏ 若n在[-128, 127] 范围内,使用bipush n的方式,操作数和操作码一起只占两个字节。比如 n 值为100(0x64)时,bipush 100对应十六进制为0 x1064。
❏ 若n在[-32768, 32767] 范围内,使用sipush n的方式,操作数和操作码一起只占三个字节,比如 n 值为1024(0x0400)时,对应的字节码为sipush 1024(0x110400)。
❏ 若n在其他范围内,则使用ldc的方式,这个范围的整数值被放在常量池中,比如 n值为40000时,40000被存储到常量池中,加载的指令为ldc #i, i为常量池的索引值。完整的加载存储指令见表2-2所示。
表2-2 存储指令列表
字节码指令的别名很多是使用简写的方式,比如ldc是load constant的简写,bipush对应byte immediate push, sipush对应short immediate push。
2.3.2 操作数栈指令
常见的操作数栈指令有pop、dup和swap。pop指令用于将栈顶的值出栈,一个常见的场景是调用了有返回值的方法,但是没有使用这个返回值,比如下面的代码。
public String foo() { return ""; } public void bar() { foo(); }
对应字节码如下所示。
0: aload_0 1: invokevirtual #13 // Method foo:()Ljava/lang/String; 4: pop 5: return
第4行有一个pop指令用于弹出调用bar方法的返回值。
dup指令用来复制栈顶的元素并压入栈顶,后面讲到创建对象的时候会用到dup指令。swap用于交换栈顶的两个元素,如图2-8所示。
图2-8 dup、pop、swap指令
还有几个稍微复杂一点的栈操作指令:dup_x1、dup2_x1和dup2_x2。下面以dup_x1为例来讲解。dup_x1是复制操作数栈栈顶的值,并插入栈顶以下2个值,看起来很绕,把它拆开来看其实分为了五步,如图2-9所示。
图2-9 dup_x1示意
v1 = stack.pop(); // 弹出栈顶的元素,记为v1 v2 = stack.pop(); // 再次弹出栈顶的元素,记为v2 state.push(v1); // 将v1 入栈 state.push(v2); // 将v2 入栈 state.push(v1); // 再次将v1 入栈
接下来看一个dup_x1指令的实际例子,代码如下。
public class Hello { private int id; public int incAndGetId() { return ++id; } }
incAndGetId方法对应的字节码如下。
public int incAndGetId(); 0: aload_0 1: dup 2: getfield #2 // Field id:I 5: iconst_1 6: iadd 7: dup_x1 8: putfield #2 // Field id:I 11: ireturn
假如id的初始值为42,调用incAndGetId方法执行过程中操作数栈的变化如图2-10所示。
图2-10 调用incAndGetId方法示例操作数栈变化过程
第0行:aload_0将this加载到操作数栈上。
第1行:dup指令将复制栈顶的this,现在操作数栈上有两个this,栈上的元素是 [this, this]。
第2行:getfield #2指令将42加载到栈上,同时将一个this出栈,栈上的元素变为[this, 42]。第5行:iconst_1将常量1加载到栈上,栈中元素变为[this, 42, 1]。
第6行:iadd将栈顶的两个值出栈相加,并将结果43放回栈上,现在栈中的元素是[this, 43]。
第7行:dup_x1将栈顶的元素43插入this之下,栈中元素变为 [43, this, 43]。
第8行:putfield #2将栈顶的两个元素this和43出栈,现在栈中元素只剩下栈顶的[43],最后的ireturn指令将栈顶的43出栈返回。
完整的操作数栈指令介绍如表2-3所示。
表2-3 操作数栈指令
2.3.3 运算和类型转换指令
Java中有加减乘除等相关的语法,针对字节码也有对应的运算指令,如表2-4所示。
表2-4 运算指令
如果需要进行运算的数据类型不一样,会涉及类型转换(cast),比如下面的浮点数1 .0与整数1相加的运算。
1.0 + 1
按照直观的想法,加法操作对应的字节码指令如下所示。
fconst_1 // 将 1.0 入栈 iconst_1 // 将 1 入栈 fadd
但fadd指令值只支持对两个float类型的数据做相加操作,为了支持这种运算,JVM会先把两个数据类型转换为一样,但精度可能出问题。为了能将1.0和1相加,int型数据需要转为float型数据,然后调用fadd指令进行相加,如下面的代码所示。
fconst_1 // 将 1.0 入栈 iconst_1 // 将 1 入栈 i2f // 将栈顶的 1 的int转为float fadd // 两个float值相加
虽然在Java语言层面,boolean、char、byte、short是不同的数据类型,但是在JVM层面它们都被当作int来处理,不需要显式转为int,字节码指令上也没有对应转换的指令。
有多种类型数据混合运算时,系统会自动将数据转为范围更大的数据类型,这种转换被称为宽化类型转换(widening)或自动类型转换,如图2-11所示。
图2-11 宽化类型转换
自动类型转换并不意味着不丢失精度,比如下面代码中将int值“123456789”转为float就出现了精度丢失的情况。
int n = 123456789; float f = n; // f = 1.23456792E8
相对的,如果把大范围数据类型的数据强制转换为小范围数据类型,这种转换称为窄化类型转换(narrowing),比如把long转为int, double转为float,如图2-12所示。
图2-12 窄化类型转换
可想而知,这种强制类型转换的数值如果超过了目标类型的表示范围,可能会截断成完全不同的数值,比如300(byte)等于44。数值类型转换指令的完整列表如表2-5所示。
表2-5 数值类型转换指令
2.3.4 控制转移指令
控制转移指令用于有条件和无条件的分支跳转,常见的if-then-else、三目表达式、for循环、异常处理等都属于这个范畴。对应的指令集包括:
❏ 条件转移:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt, if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
❏ 复合条件转移:tableswitch、lookupswitch。
❏ 无条件转移:goto、goto_w、jsr、jsr_w、ret。
以下面代码中的isPositive方法为例,它的作用是判断一个整数是否为正数。
public int isPositive(int n) { if (n > 0) { return 1; } else { return 0; } }
对应的字节码如下所示。
0: iload_1 1: ifle 6 4: iconst_1 5: ireturn 6: iconst_0 7: ireturn
根据我们之前的分析,isPositive方法局部变量表的大小为2,第一个元素是this,第二个元素是参数n,接下来逐行解释上面的字节码。
第0行:iload_1的作用是将局部变量表中下标为1的整型变量加载到操作数栈上,也就是加载参数n。其中iload_1中的i表示要加载的变量是一个int类型。同时注意到iload_1后面跟了一个数字1,它们的作用都是把栈顶元素存入局部变量表的下标为1的位置,它属于iload_<i> 指令组,其中i只能是0 、1、2、3。其实把iload_1写成iload 1也能获取正确的结果,但是编译的字节码会变长,在字节码执行时也需要获取和解析1这个额外的操作数。
第1行:ifle指令的作用是将操作数栈顶元素出栈跟0进行比较,如果小于等于0则跳转到特定的字节码处,如果大于0则继续执行接下来的字节码。ifle可以看作“if less or equal”的缩写,比较的值是0。如果想要比较的值不是0,需要用新的指令if_icmple表示“if int compare less or equal xx”。
第4~5行:对应代码“return 1;”, iconst_1指令的作用是把常量1加载到操作数栈上,ireturn指令的作用是将栈顶的整数1出栈返回,方法调用结束。
第6~7行:对应代码“return 0;”,第6行iconst_0指令的作用是将常量0加载到操作数栈上,ireturn指令的作用是将栈顶的整数1出栈返回,方法调用结束。
假设 n 等于20,调用isPositive方法操作数栈的变化情况如图2-13所示。
图2-13 if语句操作数栈变化过程
控制转移指令完整的列表如表2-6所示。
表2-6 控制转移指令
2.3.5 for语句的字节码原理
纵观所有的字节码指令,并没有与for名字相关的指令,那for循环是如何实现的呢?接下来以sum相加求和的例子来看for循环的实现细节,代码如下所示。
public int sum(int[] numbers) { int sum = 0; for (int number : numbers) { sum += number; } return sum; }
上面代码对应的字节码如下。
0: iconst_0 1: istore_2 2: aload_1 3: astore_3 4: aload_3 5: arraylength 6: istore 4 8: iconst_0 9: istore 5 11: iload 5 13: iload 4 15: if_icmpge 35 18: aload_3 19: iload 5 21: iaload 22: istore 6 24: iload_2 25: iload 6 27: iadd 28: istore_2 29: iinc 5, 1 32: goto 11 35: iload_2 36: ireturn
为了方便理解,这里先把对应的局部变量表的示意图画出来,如图2-14所示。
图2-14 for循环局部变量表示意图
下面以numbers数组内容 [10, 20, 30]为例来讲解上述的字节码的执行过程。
第0~1行:把常量0加载到操作数栈上,随后通过istore_2指令将0出栈赋值给局部变量表下标为2的元素,也就是给局部变量sum赋值为0,如图2-15所示。
图2-15 for循环执行细节(1)
第2~9行用来初始化循环控制变量,其伪代码如下所示。
$array = numbers; $len = $array.arraylength $i = 0
第2~3行:aload_1指令的作用是加载局部变量表中下标为1的变量(参数numbers), astore_3指令的作用是将栈顶元素存储到局部变量下标为3的位置上,记为 $array,如图2-16所示。
图2-16 for循环执行细节(2)
第4~6行:计算数组的长度,astore_3加载 $array到栈顶,调用arraylength指令获取数组长度存储到栈顶,随后调用istore 4将数组长度存储到局部变量表的第4个位置,这个变量是表示数组的长度值,记为 $len,过程如图2-17所示。
图2-17 for循环执行细节(3)
第8~9行:初始化数组遍历的下标初始值。iconst_0将0加载到操作数栈上,随后istore 5将栈顶的0存储到局部变量表中的第5个位置,这个局部变量是数组遍历的下标初始值,记为 $i,如图2-18所示。
图2-18 for循环执行细节(4)
11~32行是真正的循环体,详细介绍如下。
第11~15行的作用是判断循环能否继续。这部分的字节码如下所示。
11: iload 5 13: iload 4 15: if_icmpge 35
首先通过iload 5和iload 4指令加载 $i和 $len到栈顶,然后调用if_icmpge进行比较,如果 $i >= $len,直接跳转到第35行指令处,for循环结束;如果$i <$len则继续往下执行循环体,可以用如下伪代码表示。
if ($i >= $len) goto 35;
过程如图2-19所示。
图2-19 for循环执行细节(5)
第18~22行的作用是把 $array[$i] 赋值给number。aload_3加载 $array到栈上,iload 5加载 $i到栈上,然后iaload指令把下标为 $i的数组元素加载到操作数栈上,随后istore 6将栈顶元素存储到局部变量表下标为6的位置上,过程如图2-20所示。
图2-20 for循环执行细节(6)
第24~28行:iload_2和iload 6指令把sum和number值加载到操作数栈上,然后执行iadd指令进行整数相加,过程如图2-21所示。
图2-21 for循环执行细节(7)
第29行:“iinc 5, 1”指令对执行循环后的 $i加一。iinc指令比较特殊,之前介绍的指令都是基于操作数栈来实现功能,它则是直接对局部变量进行自增,不用先入栈、执行加一操作,再将结果出栈存储到局部变量表,因此效率非常高,适合循环结构,如图2-22所示。
图2-22 for循环执行细节(8)
第32行:goto 11指令的作用是跳转到第11行继续进行循环条件的判断。
上述字节码用伪代码表示就是:
@start: if ($i >= $len) return; $item = $array[$i]; sum += $item; ++ $i goto @start
整段代码的逻辑看起来非常熟悉,可以用下面的Java代码表示。
int sum = 0; for (int i = 0; i < numbers.length; i++) { sum += numbers[i]; } return sum;
由此可见,for(item : array)就是一个语法糖,字节码会让它现出原形,回归它的本质。
2.3.6 switch-case底层实现原理
如果让我们来实现一个switch-case语法,会如何做呢?是通过一个个if-else语句来判断吗?这样明显效率非常低。通过分析switch-case的字节码,可以知道编译器使用了tableswitch和lookupswitch两条指令来生成switch语句的编译代码。为什么会有两条指令呢?这是基于效率的考量,接下来进行详细分析。代码示例如下。
int chooseNear(int i) { switch (i) { case 100: return 0; case 101: return 1; case 104: return 4; default: return -1; } }
对应的字节码如下所示。
0: iload_1 1: tableswitch { // 100 to 104 100: 36 101: 38 102: 42 103: 42 104: 40 default: 42 } 42: iconst_m1 43: ireturn
细心的同学会发现,代码的case中并没有出现102、103,但字节码中却出现了。原因是编译器会对case的值做分析,如果case的值比较“紧凑”,中间有少量断层或者没有断层,会采用tableswitch来实现switch-case;如果case值有大量断层,会生成一些虚假的case帮忙补齐,这样可以实现O(1)时间复杂度的查找。case值已经被补齐为连续的值,通过下标就可以一次找到,这部分伪代码如下所示。
int val = pop(); // pop an int from the stack if (val < low || val > high) { // if its less than <low> or greater than <high>, pc += default; // branch to default } else { // otherwise pc += table[val - low]; // branch to entry in table }
再来看一个case值断层严重的例子,代码如下所示。
int chooseFar(int i) { switch (i) { case 1: return 1; case 10: return 10; case 100: return 100; default: return -1; } }
对应字节码如下所示。
0: iload_1 1: lookupswitch { // 3 1: 36 10: 38 100: 41 default: 44 }
如果还是采用前面tableswitch补齐的方式,就会生成上百个假case项,class文件会爆炸式增长,这种做法显然不合理。为了解决这个问题,可以使用lookupswitch指令,它的键值都是经过排序的,在查找上可以采用二分查找的方式,时间复杂度为O(log n)。
从上面的介绍可以知道,switch-case语句在case比较“稀疏”的情况下,编译器会使用lookupswitch指令来实现,反之,编译器会使用tableswitch来实现。我们在第4章会介绍编译器是如何来判断case值的稀疏程度的。
2.3.7 String的switch-case实现的字节码原理
前面我们已经知道switch-case依据case值的稀疏程度,分别由两个指令——tableswitch和lookupswitch实现,但这两个指令都只支持整型值,那编译器是如何让String类型的值也支持switch-case的呢?本节我们将介绍这背后的实现细节,以下面的代码为例。
public int test(String name) { switch (name) { case "Java": return 100; case "Kotlin": return 200; default: return -1; } }
对应的字节码如下所示。
0: aload_1 1: astore_2 2: iconst_m1 3: istore_3 4: aload_2 5: invokevirtual #2 // Method java/lang/String.hashCode:()I 8: lookupswitch { // 2 -2041707231: 50 // 对应 "Kotlin".hashCode() 2301506: 36 // 对应 "Java".hashCode() default: 61 } 36: aload_2 37: ldc #3 // String Java 39: invokevirtual #4 // Method java/lang/String.equals:(Ljava/ lang/Object; )Z 42: ifeq 61 45: iconst_0 46: istore_3 47: goto 61 50: aload_2 51: ldc #5 // String Kotlin 53: invokevirtual #4 // Method java/lang/String.equals:(Ljava/ lang/Object; )Z 56: ifeq 61 59: iconst_1 60: istore_3 61: iload_3 62: lookupswitch { // 2 0: 88 1: 91 default: 95 } // 88~90 88: bipush 100 90: ireturn 91: sipush 200 94: ireturn 95: iconst_m1 96: ireturn
为了方便理解,这里先画出了局部变量表的布局图,如图2-23所示。
图2-23 switch-case局部变量表
第0~3行:做初始化操作,把入参name赋值给局部变量表下标为2的变量,记为tmpName,初始化局部变量表中位置为3的变量为 -1,记为matchIndex。
第4~8行:调用tmpName的hashCode方法,得到一个整型值。因为哈希值一般都比较离散,所以没有选用tableswitch而是用lookupswitch来作为switch-case的实现。
第36~47行:如果hashCode等于字符串 "Java" 的hashCode会跳转到第36行继续执行。首先调用字符串的equals方法进行比较,看是否相等。判断是否相等使用的指令是ifeq,它的含义是如果等于0则跳转到对应字节码行处,实际上是等于false时跳转。这里如果相等则把matchIndex赋值为0。
第61~96行:进行最后的case分支执行。这一段比较好理解,不再继续分析。
结合上面的字节码解读,可以推演出对应的Java代码实现,如代码清单2-1所示。
代码清单2-1 String的switch-case等价实现代码
public int test_translate(String name) { String tmpName = name; int matchIndex = -1; switch (tmpName.hashCode()) { case -2041707231: if (tmpName.equals("Kotlin")) { matchIndex = 1; } break; case 2301506: if (tmpName.equals("Java")) { matchIndex = 0; } break; default: break; } switch (matchIndex) { case 0: return 100; case 1: return 200; default: return -1; } }
看到这里细心的读者可能会问,字符串的hashCode冲突时要怎样处理,比如“Aa”和“BB”的hashCode都是2112。以下面的代码为例,学习case值hashCode相同时编译器是如何处理的。
public int testSameHash(String name) { switch (name) { case "Aa": return 100; case "BB": return 200; default: return -1; } }
对应的字节码如代码清单2-2所示。
代码清单2-2相同hashCode值的String switch-case字节码
public int testSameHash(java.lang.String); descriptor: (Ljava/lang/String; )I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=2 0: aload_1 1: astore_2 2: iconst_m1 3: istore_3 4: aload_2 5: invokevirtual #2 // Method java/lang/String.hashCode:()I 8: lookupswitch { // 1 2112: 28 default: 53 } 28: aload_2 29: ldc #3 // String BB 31: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/ Object; )Z 34: ifeq 42 37: iconst_1 38: istore_3 39: goto 53 42: aload_2 43: ldc #5 // String Aa 45: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/ Object; )Z 48: ifeq 53 51: iconst_0 52: istore_3 53: iload_3 54: lookupswitch { // 2 0: 80 default: 87 } 80: bipush 100 82: ireturn 83: sipush 200 86: ireturn 87: iconst_m1 88: ireturn
可以看到34行在hashCode冲突的情况下,编译器的处理不过是多一次调用字符串equals判断相等的比较。与BB不相等的情况,会继续判断是否等于Aa,翻译为Java源代码如代码清单2-3所示。
代码清单2-3相同hashCode值的String switch-case等价实现
public int testSameHash_translate(String name) { String tmpName = name; int matchIndex = -1; switch (tmpName.hashCode()) { case 2112: if (tmpName.equals("BB")) { matchIndex = 1; } else if (tmpName.equals("Aa")) { matchIndex = 0; } break; default: break; } switch (matchIndex) { case 0: return 100; case 1: return 200; default: return -1; } }
前面介绍了String的swich-case实现,里面用到了字符串的hashCode方法,那如何快速构造两个hashCode相同的字符串呢?这要从hashCode的源码说起,String类hashCode的代码如代码清单2-4所示。
代码清单2-4 String的hashCode源码
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
假设要构造的字符串只有两个字符,用“ab”和“cd”,上面的代码就变成了如果两个hashCode相等,则满足下面的公式。
a * 31 + b = c * 31 + d 31*(a-c)=d-b
其中一个特殊解是a-c=1, d-b=31,也就是只有两个字符的字符串“ab”与“cd”满足a-c=1, d-b=31,这两个字符串的hashCode就一定相等,比如“Aa”和“BB”,“Ba”和“CB”,“Ca”和“DB”,依次类推。
2.3.8 ++i和i++的字节码原理
在面试中经常会被问到 ++i与i++ 相关的陷阱题,关于i++ 和 ++i的区别,我自己在刚学编程时是比较困惑的,下面我们从字节码的角度来看 ++i与i++ 到底是如何实现的。
首先来看一段i++ 的陷阱题,如代码清单2-5所示。
代码清单2-5 i++ 代码示例
public static void foo() { int i = 0; for (int j = 0; j < 50; j++) { i = i++; } System.out.println(i); }
执行上述代码输出结果是0,而不是50,源码“i = i++;”对应的字节码如下所示。
... 10: iload_0 11: iinc 0, 1 14: istore_0 ...
接下来逐行解释上面的字节码。
第10行:iload_0把局部变量表slot = 0的变量(i)加载到操作数栈上。
第11行:“iinc 0, 1”对局部变量表slot = 0的变量(i)直接加1,但是这时候栈顶的元素没有变化,还是0。
第14行:istore_0将栈顶元素出栈赋值给局部变量表slot = 0的变量,也就是i。此时,局部变量i又被赋值为0,前面的iinc指令对i的加一操作被覆盖,如图2-24所示。
图2-24 i=i++ 执行过程
可以用下面的伪代码来表示i = i++ 的执行过程。
tmp = i; i = i + 1; i = tmp;
因此可以得知,“j = i++;”在字节码层面是先把i的值加载到操作数栈上,随后才对局部变量i执行加一操作,留在操作数栈顶的还是i的旧值。如果把栈顶值赋值给j,则这个变量得到的是i加一之前的值。
把上面的代码稍作修改,将i++ 改为 ++i,如代码清单2-6所示。
代码清单2-6 ++i代码示例
public static void foo() { int i = 0; for (int j = 0; j < 50; j++) i = ++i; System.out.println(i); }
代码对应的字节码如下所示。
... 10: iinc 0, 1 13: iload_0 14: istore_0 ...
i = ++i对应的字节码还是第10~14行,可以看出“i = ++i;”先对局部变量表下标为0的变量加1,然后才把它加载到操作数栈上,随后又从操作数栈上出栈赋值给局部变量表中下标为0的变量。
整个过程的局部变量表和操作数栈的变化如图2-25所示。
图2-25 ++i字节码
i = ++i可以用下面的伪代码来表示。
i = i + 1; tmp = i; i = tmp;
因此可以得知,“j=++i;”实际上是对局部变量i做了加一操作,然后才把最新的i值加载到操作数上,随后赋值给变量j。
再来看一道难一点的题目,完整的代码如下所示。
public static void bar() { int i = 0; i = i++ + ++i; System.out.println("i=" + i); }
对应的字节码如下。
0: iconst_0 1: istore_0 2: iload_0 3: iinc 0, 1 6: iinc 0, 1 9: iload_0 10: iadd 11: istore_0
从第2行开始每一步操作数栈和局部变量表的变化过程见图2-26所示。
图2-26 iinc字节码
整个过程可以用下面的伪代码来表示。
i = 0; tmp1 = i; i = i + 1; i = i + 1 tmp2 = i; tmpSum = tmp1 + tmp2; i = tmpSum;
2.3.9 try-catch-finally的字节码原理
Java中有一个非常重要的内容是try-catch-finally的执行顺序和返回值问题,大部分书里说过finally一定会执行,但是为什么是这样?下面来看看try-catch-finally这个语法糖背后的实现原理。
1. try-catch字节码分析
下面是一个简单的try-catch的例子。
public class TryCatchFinallyDemo { public void foo() { try { tryItOut1(); } catch (MyException1 e) { handleException(e); } } }
对应的字节码如下所示。
0: aload_0 1: invokevirtual #2 // Method tryItOut1:()V 4: goto 13 7: astore_1 8: aload_0 9: aload_1 10: invokevirtual #4 // Method handleException:(Ljava/lang/Exception; )V 13: return Exception table: from to target type 0 4 7 Class MyException1
第0~1行:aload_0指令加载this,随后使用invokevirtual指令调用tryItOut1方法,关于invokevirtual的详细用法在第3章会介绍,这里只需要知道invokevirtual是方法调用指令即可。
第4行:goto语句是如果tryItOut1方法不抛出异常就会跳转到第13行继续执行return指令,方法调用结束。如果有异常抛出,将如何处理呢?
从第1章的内容可以知道,当方法包含try-catch语句时,在编译单元生成的方法的Code属性中会生成一个异常表(Exception table),每个异常表项表示一个异常处理器,由from指针、to指针、target指针、所捕获的异常类型type四部分组成。这些指针的值是字节码索引,用于定位字节码。其含义是在 [from, to)字节码范围内,如果抛出了异常类型为type的异常,就会跳转到target指针表示的字节码处继续执行。
上面例子中的Exception table表示,在0到4之间(不包含4)如果抛出了类型为MyException1或其子类异常,就跳转到7继续执行。
值得注意的是,当抛出异常时,Java虚拟机会自动将异常对象加载到操作数栈栈顶。
第7行:astore_1将栈顶的异常对象存储到局部变量表中下标为1的位置。
第8~10行:aload_0和aload_1分别加载this和异常对象到栈上,最后执行invokevirtual #4指令调用handleException方法。
异常处理逻辑的操作数栈和局部变量表变化过程如图2-27所示。
图2-27 try-catch字节码
下面我们来看在有多个catch语句的情况下,虚拟机是如何处理的,以代码清单2-7为例。
代码清单2-7 多catch语句
public void foo() { try { tryItOut2(); } catch (MyException1 e) { handleException1(e); } catch (MyException2 e) { handleException2(e); } }
对应字节码如下。
0: aload_0 1: invokevirtual #5 // Method tryItOut2:()V 4: goto 22 // 第一个catch部分内容 7: astore_1 8: aload_0 9: aload_1 10: invokevirtual #6 // Method handleException1:(Ljava/lang/Exception; )V 13: goto 22 // 第二个catch部分内容 16: astore_1 17: aload_0 18: aload_1 19: invokevirtual #8 // Method handleException2:(Ljava/lang/Exception; )V 22: return Exception table: from to target type 0 4 7 Class MyException1 0 4 16 Class MyException2
可以看到,多一个catch语句处理分支,异常表里面就会多一条记录,当程序出现异常时,Java虚拟机会从上至下遍历异常表中所有的条目。当触发异常的字节码索引值在某个异常条目的 [from, to)范围内,则会判断抛出的异常是否是想捕获的异常或其子类。
如果异常匹配,Java虚拟机会将控制流跳转到target指向的字节码继续执行;如果不匹配,则继续遍历异常表。如果遍历完所有的异常表还未找到匹配的异常处理器,那么该异常将继续抛到调用方(caller)中重复上述的操作。
2. finally字节码分析
很多Java学习资料中都有写:finally语句块保证一定会执行。这一句简单的规则背后却不简单,之前我一直以为finally的实现是用简单的跳转来实现的,实际上并非如此。接下来我们一步步分析finally语句的底层原理,以代码清单2-8为例。
代码清单2-8 finally语句示例
public void foo() { try { tryItOut1(); } catch (MyException1 e) { handleException(e); } finally { handleFinally(); } }
对应的字节码如下所示。
0: aload_0 1: invokevirtual #2 // Method tryItOut1:()V // 添加finally语句块 4: aload_0 5: invokevirtual #9 // Method handleFinally:()V 8: goto 31 --- 11: astore_1 12: aload_0 13: aload_1 14: invokevirtual #4 // Method handleException:(Ljava/lang/Exception; )V // 添加finally语句块 17: aload_0 18: invokevirtual #9 // Method handleFinally:()V 21: goto 31 --- 24: astore_2 25: aload_0 26: invokevirtual #9 // Method handleFinally:()V 29: aload_2 30: athrow 31: return Exception table: from to target type 0 4 11 Class MyException1 0 4 24 any 11 17 24 any
可以看到字节码中出现了三次调用handleFinally方法的invokevirtual #9,都是在程序正常return和异常throw之前,其中两处在try-catch语句调用return之前,一处是在异常抛出throw之前。
第0~3行:执行tryItOut1方法。如果没有异常,就继续执行handleFinally方法;如果有异常,则根据异常表的映射关系跳转到对应的字节码处执行。
第11~14行:执行catch语句块中的handleException方法,如果没有异常就继续执行handleFinally方法,如果有异常则跳转到第24行继续执行。
第24~30行:负责处理tryItOut1方法抛出的非MyException1异常和handleException方法抛出的异常。
不用finally语句,只用try-catch语句实现的等价代码如代码清单2-9所示。
代码清单2-9 finally语句等价实现
public void foo() { try { tryItOut1(); handleFinally(); } catch (MyException1 e) { try { handleException(e); } catch (Throwable e2) { handleFinally(); throw e2; } } catch (Throwable e) { handleFinally(); throw e; } }
由代码可知,现在的Java编译器采用复制finally代码块的方式,并将其内容插入到try和catch代码块中所有正常退出和异常退出之前。这样就解释了我们一直以来被灌输的观点,finally语句块一定会执行。
有了上面的基础,就很容易理解在finally语句块中有return语句会发生什么。因为finally语句块插入在try和catch返回指令之前,finally语句块中的return语句会“覆盖”其他的返回(包括异常),以代码清单2-10为例。
代码清单2-10 finally语句块中有return语句的情况
public int foo() { try { int a = 1 / 0; return 0; } catch (Exception e) { int b = 1 / 0; return 1; } finally { return 2; } }
catch语句对应的字节码如下所示。
... 8: astore_1 9: iconst_1 10: iconst_0 11: idiv 12: istore_2 13: iconst_1 14: istore_3 15: iconst_2 16: ireturn ... Exception table: from to target type 0 6 8 Class java/lang/Exception 0 6 17 any 8 15 17 any 17 19 17 any
第8~12行字节码相当于源码中的“int b = 1 / 0;”,第13行字节码iconst_1将整型常量值1加载到栈上,第14行字节码istore_3将栈顶的数值1暂存到局部变量中下标为3的元素中,第15行字节码iconst_2将整型常量2加载到栈上,第16行随后调用ireturn将其出栈值返回,方法调用结束。
可以看到,受finally语句return的影响,虽然catch语句中有“return 1;”,在字节码层面只是将1暂存到临时变量中,没有机会执行返回,本例中foo方法的返回值为2。
上面的代码在语义上与下面的代码清单2-11等价。
代码清单2-11 finally语句包含return的等价实现
public int foo() { try { int a = 1 / 0; int tmp = 0; return 2; } catch (Exception e) { try { int b = 1 / 0; int tmp = 1; return 2; } catch (Throwable e1) { return 2; } } catch (Throwable e) { return 2; } }
接下来看看,在finally语句中修改return的值会发生什么,可以想想代码清单2-12中foo方法返回值是100还是101。
代码清单2-12 finally语句修改了return值
public int foo() { int i = 100; try { return i; } finally { ++i; } }
前面介绍过,在finally语句中包含return语句时,会将返回值暂存到临时变量中,这个finally语句中的++i操作只会影响i的值,不会影响已经暂存的临时变量的值。foo返回值为100。foo方法对应的字节码如下所示。
0: bipush 100 2: istore_1 3: iload_1 4: istore_2 5: iinc 1, 1 8: iload_2 9: ireturn 10: astore_3 11: iinc 1, 1 14: aload_3 15: athrow Exception table: from to target type 3 5 10 any
第0~2行的作用是初始化i值为100, bipush 100的作用是将整数100加载到操作数栈上。第3~4行的作用是加载i值并将其存储到局部变量表中位置为2的变量中,这个变量在源代码中是没有出现的,是return之前的暂存值,记为tmpReturn,此时tmpReturn的值为100。第5行的作用是对局部变量表中i直接自增加一,这次自增并不会影响局部变量tmpReturn的值。第8~9行加载tmpReturn的值并返回,方法调用结束。第10~15行是第3~4行抛出异常时执行的分支。
整个过程如图2-28所示。
图2-28 finally中修改return值(1)
类似的陷阱题如代码清单2-13所示。
代码清单2-13 finally陷阱题
public String foo() { String s = "hello"; try { return s; } finally { s = null; } }
对应的字节码如下所示。
0: ldc #2 // String hello 2: astore_1 3: aload_1 4: astore_2 5: ldc #3 // String xyz 7: astore_1 8: aload_2 9: areturn 10: astore_3 11: ldc #3 // String xyz 13: astore_1 14: aload_3 15: athrow
可以看到,第0~2行字节码加载字符串常量“hello”的引用赋值给局部变量s。第3~4行将局部变量s的引用加载到栈上,随后赋值给局部变量表中位置为2的元素,这个变量在代码中并不存在,是一个临时变量,这里记为tmp。第5~7行将字符串常量“xyz”的引用加载到栈上,随后赋值给局部变量s。第8~9行加载局部变量tmp到栈顶,tmp指向的依旧是字符串“hello”,随后areturn将栈顶元素返回。上述过程如图2-29所示。
图2-29 finally中修改return值(2)
这个过程类似于下面的代码。
public String foo() { String s = "hello"; String tmp = s; s = "xyz"; return tmp; }
到这里try-catch-finally语法背后的实现细节就介绍完了,接下来我们学习与之很相似的try-with-resources语法的底层原理。
2.3.10 try-with-resources的字节码原理
try-with-resources是Java7Project Coin提案中引入的新的资源释放机制,Project Coin的提交者声称JDK源码中close用法在释放资源时存在bug。try-with-resources的出现既可以减少代码出错的概率,也可以使代码更加简洁。下面以代码清单2-14为例开始介绍这一小节的内容。
代码清单2-14 try-with-resources代码示例
public static void foo() throws IOException { try (FileOutputStream in = new FileOutputStream("test.txt")) { in.write(1); } }
在不用try-with-resources的情况下,我们很容易写出下面这种try-finally包裹起来的看似等价的代码。
public static void foo() throws IOException { FileOutputStream in = null; try { in = new FileOutputStream("test.txt"); in.write(1); } finally { if (in ! = null) { in.close(); } } }
看起来好像没有什么问题,但是仔细想一下,如果in.write()抛出了异常,in.close()也抛出了异常,调用者会收到哪个呢?我们回顾一下Java基础中try-catch-finally的内容,以代码清单2-15中的bar方法为例。
代码清单2-15 finally中有异常抛出的情况
public static void bar() { try { throw new RuntimeException("in try"); } finally { throw new RuntimeException("in finally"); } }
调用bar()方法会抛出的异常如下所示。
Exception in thread "main" java.lang.RuntimeException: in finally
也就是说,try中抛出的异常被finally抛出的异常淹没了,这也很好理解,从上一节介绍的内容可知finally中的代码块会在try抛出异常之前插入,即try抛出的异常被finally抛出的异常捷足先登先返回了。
因此在上面foo方法中in.write()和in.close()都抛出异常的情况下,调用方收到的是in.close()抛出的异常,in.write()抛出的重要异常消失了,这往往不是我们想要的,那么怎样在抛出try中的异常同时又不丢掉finally中的异常呢?
接下来,我们来学习try-with-resources是怎么解决这个问题的。使用javap查看foo方法的字节码,部分输出如代码清单2-16所示。
代码清单2-16 try-with-resources字节码
... 17: aload_0 18: invokeinterface #4, 1 // InterfaceMethod java/lang/AutoCloseable.close:()V 23: goto 86 ... 26: astore_2 27: aload_1 28: aload_2 29: invokevirtual #6 // Method java/lang/Throwable.addSuppressed:(Ljava/ lang/Throwable; )V 32: goto 86 ... 86: return Exception table: from to target type 17 23 26 Class java/lang/Throwable 6 9 44 Class java/lang/Throwable 6 9 49 any 58 64 67 Class java/lang/Throwable 44 50 49 any
可以看到,第29行出现了一个源代码中并没有出现的Throwable.addSuppressed方法调用,接下来我们来看这里面的玄机。
Java 7中为Throwable类增加了addSuppressed方法。当一个异常被抛出的时候,可能有其他异常因为该异常而被抑制,从而无法正常抛出,这时可以通过addSuppressed方法把被抑制的异常记录下来,这些异常会出现在抛出的异常的堆栈信息中;也可以通过getSuppressed方法来获取这些异常。这样做的好处是不会丢失任何异常,方便开发人员进行调试。
根据上述概念,对代码进行再次改写,如代码清单2-17所示。
代码清单2-17 try-with-resource代码改写
public static void foo() throws Exception { FileOutputStream in = null; Exception tmpException = null; try { in = new FileOutputStream("test.txt"); in.write(1); } catch (Exception e) { tmpException = e; throw e; } finally { if (in ! = null) { if (tmpException ! = null) { try { in.close(); } catch (Exception e) { tmpException.addSuppressed(e); } } else { in.close(); } } } }
上面的代码中如果in.close()发生异常,这个异常不会覆盖原来的异常,只是放到原异常的Suppressed异常中。
本节介绍了try-with-resources语句块的底层字节码实现,一起来回顾一下要点:第一,try-with-resources语法并不是简单地在finally中加入closable.close()方法,因为finally中的close方法如果抛出了异常会淹没真正的异常;第二,引入了Suppressed异常,既可以抛出真正的异常又可以调用addSuppressed附带上suppressed的异常。
接下来我们将介绍对象相关的字节码指令。
2.3.11 对象相关的字节码指令
本节我们将介绍<init> 对象初始化方法、对象创建的三条相关指令、<clinit> 类初始化方法以及对象初始化顺序。
1. <init> 方法
<init> 方法是对象初始化方法,类的构造方法、非静态变量的初始化、对象初始化代码块都会被编译进这个方法中。比如:
public class Initializer { // 初始化变量 private int a = 10; // 构造器方法 public Initializer() { int c = 30; } // 对象初始化代码块 { int b = 20; } }
对应的字节码为:
public Initializer(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: bipush 10 7: putfield #2 // Field a:I 10: bipush 20 12: istore_1 13: bipush 30 15: istore_1 16: return
javap输出的字节码中Initializer()方法对应<init> 对象初始化方法,其中5~7行将成员变量a赋值为10,10~12行将b赋值为10,13~15行将c赋值为30。
可以看到,虽然Java语法上允许我们把成员变量初始化和初始化语句块写在构造器方法之外,最终在编译以后都会统一编译进<init> 方法。为了加深印象,可以来看一个在变量初始化可能抛出异常的情况,如下面的代码所示。
public class Hello { private FileOutputStream outputStream = new FileOutputStream("test.txt"); public Hello() { } }
编译上面的代码会报如下的错误。
javac Hello.java Hello.java:8: error: unreported exception FileNotFoundException; must be caught or declared to be thrown private FileOutputStream outputStream = new FileOutputStream("test.txt"); ^
为了能使上面的代码编译通过,需要在默认构造器方法抛出FileNotFoundException异常,如下面的代码所示。
public class Hello { private FileOutputStream outputStream = new FileOutputStream("test.txt"); public Hello() throws FileNotFoundException { } }
这个例子可以从侧面印证我们前面介绍的观点,接下来我们来看对象创建相关的三条指令。
2. new、dup、invokespecial对象创建三条指令
在Java中new是一个关键字,在字节码中也有一个叫new的指令,但是两者不是一回事。当我们创建一个对象时,背后发生了哪些事情呢?以下面的代码为例。
ScoreCalculator calculator = new ScoreCalculator();
对应的字节码如下所示。
0: new #2 // class ScoreCalculator 3: dup 4: invokespecial #3 // Method ScoreCalculator."<init>":()V 7: astore_1
一个对象创建需要三条指令,new、dup、<init> 方法的invokespecial调用。在JVM中,类的实例初始化方法是<init>,调用new指令时,只是创建了一个类实例引用,将这个引用压入操作数栈顶,此时还没有调用初始化方法。使用invokespecial调用<init> 方法后才真正调用了构造器方法,那中间的dup指令的作用是什么?
invokespecial会消耗操作数栈顶的类实例引用,如果想要在invokespecial调用以后栈顶还有指向新建类对象实例的引用,就需要在调用invokespecial之前复制一份类对象实例的引用,否则调用完<init> 方法以后,类实例引用出栈以后,就再也找不回刚刚创建的对象引用了。有了栈顶的新建对象的引用,就可以使用astore指令将对象引用存储到局部变量表,如图2-30所示。
图2-30 对象创建的dup指令作用
从本质上来理解导致必须要有dup指令的原因是<init> 方法没有返回值,如果<init>方法把新建的引用对象作为返回值,也不会存在这个问题。
3. <clinit> 方法
<clinit> 是类的静态初始化方法,类静态初始化块、静态变量初始化都会被编译进这个方法中。以下面的代码清单2-18为例。
代码清单2-18 静态初始化代码示例
public class Initializer { private static int a = 0; static { System.out.println("static"); } }
对应的字节码如下所示。
static {}; descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: iconst_0 1: putstatic #2 // Field a:I 4: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 7: ldc #4 // String static 9: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/ String; )V 12: return
javap输出字节码中的static {} 表示<clinit> 方法。<clinit> 不会直接被调用,它在四个指令触发时被调用(new、getstatic、putstatic和invokestatic),比如下面的场景:
❏ 创建类对象的实例,比如new、反射、反序列化等;
❏ 访问类的静态变量或者静态方法;
❏ 访问类的静态字段或者对静态字段赋值(final的字段除外);
❏ 初始化某个类的子类。