讀鄭雨迪《深入拆解Java虛擬機器》 -- 第六講 JVM是如何處理異常的
眾所周知,異常處理的兩大組成要素是丟擲異常和捕獲異常。這兩大要素共同實現程式控制流的非正常轉移。
丟擲異常可分為顯示和隱式兩種。顯示拋異常的主體是應用程式,它指的是在程式中使用“throw”關鍵字,手動將異常例項丟擲。隱式拋異常的主體則是Java虛擬機器,它指的是Java虛擬機器在執行過程中,碰到無法繼續執行的異常狀態,自動丟擲異常。比如,Java虛擬機器在執行讀取陣列操作時,發現輸入的索引值是負數,故而丟擲陣列索引越界異常(ArrayIndexOutOfBoundsException)。
捕獲異常這涉及瞭如下三種程式碼塊:
- try程式碼塊:用來標記需要進行異常監控的程式碼。
- catch程式碼塊:跟在try程式碼塊之後,用來捕獲在try程式碼塊中觸發的某種指定型別的異常。除了宣告所捕獲異常的型別之外,catch程式碼塊還定義了針對該異常型別的異常處理器。在Java中,try程式碼塊後面可以跟著多個catch程式碼塊,來捕獲不同型別的異常。Java虛擬機器會從上至下匹配異常處理器
- finally程式碼塊:跟在try程式碼塊和catch程式碼塊之後,用來宣告一段必定執行的程式碼。它的設計初衷是為了避免跳過某些關鍵的清理程式碼,例如關閉已開啟的系統資源。
在程式正常執行的情況下,這段程式碼會在try程式碼塊之後執行。否則,也就是try程式碼塊觸發異常的情況下,如果該異常沒有被捕獲,finally程式碼塊會直接執行,並且在執行之後重新丟擲該異常。
如果該異常被catch程式碼塊捕獲,finally程式碼塊則在catch程式碼塊之後執行。在某些不幸的情況下,catch程式碼同樣也觸發了異常,那麼finally程式碼塊同樣會執行,並會丟擲catch程式碼塊觸發的異常。在某些極端不幸的情況下,finally程式碼塊也觸發了異常,那麼只好中斷當前finally程式碼塊的執行,並往外拋異常、
異常的基本概念
在Java語言規範中,所有異常都是Throwable類或者其子類的例項。Throwable有兩大直接子類。
- Error,涵蓋程式不應捕獲的異常。當程式觸發Error時,它的執行狀態無法恢復,需要中止執行緒甚至是中止虛擬機器。
- Exception,涵蓋程式可能需要捕獲並且處理的異常。Exception有一個特殊的子類RuntimeException,用來表示“程式雖然無法繼續執行,但是還能搶救一下”的情況。前邊提到的陣列索引越界異常便是其中的一種。
RuntimeException和Error屬於Java裡的非檢查異常(unchecked exception)。其他異常則屬於檢查異常(checked exception)
異常例項的構造十分昂貴。這是由於在構造異常例項時,Java虛擬機器便需要生成該異常的棧軌跡(stack trace)。該操作會逐一訪問當前執行緒的Java棧幀,並且記錄下各種除錯資訊,包括棧幀所指向的方法的名字,方法所在的類名、檔名,以及在程式碼中的第幾行觸發該異常。
當然,在生成棧軌跡時,Java虛擬機器會忽略掉異常構造器以及填充棧幀的Java方法(Throwable.fillInStackTrace),直接從新建異常位置開始算起。此外,Java虛擬機器還會忽略標記為不可見的Java方法棧幀。
既然異常例項的構造十分昂貴,我們是否可以快取異常例項,在需要用到的時候直接丟擲呢?從語法角度上來看,這是允許的。然而,該異常對應的棧軌跡並非throw語句的位置,而是新建異常的位置。因此,這種做法可能會誤導開發人員,使其定位到錯誤的位置。這也是為什麼在實踐中,我們往往選擇丟擲新建異常例項的原因。
Java虛擬機器是如何捕獲異常的
在編譯生成的位元組碼中,每個方法都附帶一個異常表。異常表中的每一個條目都代表一個異常處理器,並且由from指令、to指令、target指令以及所捕獲的異常型別構成。這些指標的值是位元組碼索引(bytecode index, bci),用以定位位元組碼。
其中,from指標和to指標標示了該異常處理器所監控的範圍,例如try程式碼所覆蓋的範圍。target指標則指向異常處理器的起始位置。
public static void main(String[] args){
try{
mayThrowException();
} catch(Exception e){
e.printStackTrace();
}
}
//對應的Java位元組碼
public static void main(java.lang.String[]);
Code:
0: invokestatic mayThrowException:()V
3: goto 11
6: astore_1
7: aload_1
8: invokevirtual java.lang.Exception.printStackTrace
11: return
Exception table:
from to target type
0 3 Class java/lang/Exception //異常表條目
編譯過後,該方法的異常表擁有一個條目。其from指標和to指標分別為0和3,代表它的監控範圍從索引為0的位元組碼開始,到索引為3的位元組碼結束(不包括3)。該條目的target指標是6,代表這個異常處理器從索引6的位元組碼開始。條目的最後一列,代表該異常處理器所捕獲的異常型別正是Exception。
當程式觸發異常是,Java虛擬機器會從上至下遍歷異常表中的所有條目。當觸發異常的位元組碼的索引值在某個異常表監控範圍內,Java虛擬機器會判斷所丟擲的異常和該條目想要捕獲的異常是否匹配。如果匹配,Java虛擬機器會將控制流轉移至該條目target指標指向的位元組碼。
如果遍歷完所有異常表條目,Java虛擬機器仍未匹配到異常處理器,那麼它會彈出當前方法對應的Java棧幀,並且在呼叫者(caller)中重複上述操作。在最壞情況下,Java虛擬機器需要遍歷當前執行緒Java棧上所有方法的異常表。
finally程式碼塊的編譯比較複雜。當前版本Java編譯器的做法,是復制finally程式碼塊的內容,分別放在try-catch程式碼塊所有正常執行路徑以及異常執行路徑的出口中。
針對異常執行路徑,Java編譯器會生成一個或多個異常表條目,監控整個try-catch程式碼塊,並且捕獲所有種類的異常(在javap中以any指代)。這些異常表條目的target指標將指向另一份複製的finally程式碼塊。並且,在這個finally程式碼塊的最後,Java編譯器會重新丟擲所捕獲的異常。
我們可以使用javap工具來檢視下面這段包含了try-catch-finally程式碼塊的編譯結果。為了更好地區分每個程式碼塊,我們定義了四個例項欄位:tryBlock、catchBlock、finallyBlock以及methodExit,並且僅在對應的程式碼塊中訪問這些欄位。
public class Foo{
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test(){
try{
tryBlock = 0;
} catch(Exception e){
catchBlock = 1;
} finally{
finallyBlock = 2;
}
methodExit = 3;
}
}
然後編譯並檢視其位元組碼
javac Foo.java
javap -c Foo
Compiled from "Foo.java"
public class Foo {
public Foo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void test();
Code:
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
}
可以看到,便以結果包含三份finally程式碼塊。其中,前兩份分別位於try程式碼塊和catch程式碼塊的正常執行路徑出口。最後一份則作為異常處理器,監控try程式碼塊以及catch程式碼塊。它將捕獲try程式碼塊觸發的、未被catch程式碼塊捕獲的異常,以及catch程式碼塊觸發的異常。
如果catch程式碼塊捕獲了異常,並且觸發了另一個異常,那麼finally捕獲並且重拋的異常是哪個呢?答案是後者。也就是說原本的異常便會被忽略掉,這對於程式碼除錯來說十分不利。
Java 7 的 Supressed 異常以及語法糖
Java 7 引入了Supressed異常來解決這個問題。這個新特性允許開發人員將一個異常附於另一個異常之上。因此,丟擲的異常可以附帶多個異常資訊。
然而,Java層面的finally程式碼塊缺少指向所捕獲異常的引用,所以這個新特性使用起來十分繁瑣。
為此,Java 7 專門構造了一個名為try-with-resources的語法糖,在位元組碼層面自動使用Supressed異常。當然,該語法糖的主要目的並不是使用Supressed異常,而是精簡資源開啟關閉的用法。
在Java 7 之前,對於開啟的資源,我們需要定義一個finally程式碼塊,來確保該資源在正常或者異常執行狀況下都能關閉。
資源的關閉操作本身容易觸發異常。因此,如果同時開啟多個資源,那麼每一個資源都要對應一個獨立的try-finally程式碼塊,以保證每個資源都能夠關閉。這樣一來,程式碼將會變得十分繁瑣。
FileInputStream in0 = null;
FileInputStream in1 = null;
FileInputStream in2 = null;
...
try{
in0 = new FileInputStream(new File("in0.txt"));
...
try{
in1 = new FileInputStream(new File("in1.txt"));
...
try{
in2 = new FileInputStream(new File("in2.txt"));
...
} finally{
if(in2 != null)
in2.close();
}
}
finally{
if(in1 != null)
in1.close();
}
} finally{
if(in0 != null)
in0.close();
}
Java 7 的try-with-resources語法糖極大地簡化了上述程式碼。程式可以在try關鍵字後宣告並例項化實現了AutoCloseable介面的類,編譯器將自動新增對應的close()操作。在宣告多個AutoCloseable 例項的情況下,編譯生成的位元組碼類似於上面手工編寫程式碼的編譯結果。與手工程式碼相比,try-with-resources還會使用Supressed異常的功能,來避免原異常“被消失”。
public class Foo implements AutoCloseable {
private final String name;
public Foo(String name) {
this.name = name;
}
@Override
public void close(){
throw new RuntimeException(name);
}
public static void main(String[] args) {
try (Foo foo0 = new Foo("Foo0"); //try-with-resources
Foo foo1 = new Foo("Foo1");
Foo foo2 = new Foo("Foo2")) {
throw new RuntimeException("Initial");
}
}
}
執行結果如下:
javac Foo.java
java Foo
Exception in thread "main" java.lang.RuntimeException: Initial
at Foo.main(Foo.java:16)
Suppressed: java.lang.RuntimeException: Foo2
at Foo.close(Foo.java:9)
at Foo.main(Foo.java:17)
Suppressed: java.lang.RuntimeException: Foo1
at Foo.close(Foo.java:9)
at Foo.main(Foo.java:17)
Suppressed: java.lang.RuntimeException: Foo0
at Foo.close(Foo.java:9)
at Foo.main(Foo.java:17)
除了 try-with-resources語法糖之外,Java 7 還支援在同一catch程式碼塊中捕獲多種異常。實際實現非常簡單,生成多個異常表條目即可。
//在同一 catch 程式碼塊中捕獲多種異常
try {
...
} catch (SomeException | OtherException e) {
...
}
編寫Java原始碼
public class Foo{
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test(){
for (int i = 0; i < 100; i++){
try{
tryBlock = 0;
if(i < 50){
continue;
}else if(i < 80){
break;
}else {
return;
}
} catch(Exception e){
catchBlock = 1;
} finally{
finallyBlock = 2;
}
}
methodExit = 3;
}
}
編譯並檢視其位元組碼
javac Foo.java
javap -c Foo
Compiled from "Foo.java"
public class Foo {
public Foo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void test();
Code:
0: iconst_0
1: istore_1
2: iload_1
3: bipush 100
5: if_icmpge 75
8: aload_0
9: iconst_0
10: putfield #2 // Field tryBlock:I
13: iload_1
14: bipush 50
16: if_icmpge 27
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 69
27: iload_1
28: bipush 80
30: if_icmpge 41
33: aload_0
34: iconst_2
35: putfield #3 // Field finallyBlock:I
38: goto 75
41: aload_0
42: iconst_2
43: putfield #3 // Field finallyBlock:I
46: return
47: astore_2
48: aload_0
49: iconst_1
50: putfield #5 // Field catchBlock:I
53: aload_0
54: iconst_2
55: putfield #3 // Field finallyBlock:I
58: goto 69
61: astore_3
62: aload_0
63: iconst_2
64: putfield #3 // Field finallyBlock:I
67: aload_3
68: athrow
69: iinc 1, 1
72: goto 2
75: aload_0
76: iconst_3
77: putfield #6 // Field methodExit:I
80: return
Exception table:
from to target type
8 19 47 Class java/lang/Exception
27 33 47 Class java/lang/Exception
8 19 61 any
27 33 61 any
47 53 61 any
}
由此可見,finally程式碼塊被拷貝到了 if語句的每個分支之後(如果分支中有return語句,則在該語句之前)
此文從極客時間專欄《深入理解Java虛擬機器》搬運而來,撰寫此文的目的:
-
對自己的學習總結歸納
-
此篇文章對想深入理解Java虛擬機器的人來說是非常不錯的文章,希望大家支援一下鄭老師。