書名: 深入理解JVM字節碼作者名: 張亞本章字數: 5342字更新時間: 2020-06-02 18:27:54
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的字段除外);
? 初始化某個類的子類。
- MySQL數據庫管理實戰
- Learn Type:Driven Development
- 深入理解Django:框架內幕與實現原理
- UVM實戰
- Django實戰:Python Web典型模塊與項目開發
- Apache Camel Developer's Cookbook
- Python Interviews
- 交互式程序設計(第2版)
- 基于GPU加速的計算機視覺編程:使用OpenCV和CUDA實時處理復雜圖像數據
- jQuery Mobile Web Development Essentials(Second Edition)
- 關系數據庫與SQL Server 2012(第3版)
- 啊哈C語言?。哼壿嫷奶魬穑ㄐ抻啺妫?/a>
- 從零開始學UI設計·基礎篇
- Linux Networking Cookbook
- Learning Scrapy