1 什么是異常
Oracle官方對異常給出了如下定義:
Definition: An exception is an event, which occurs during the execution of a program, that disrupts the normal flow of the program's instructions.
簡單翻譯就是一個異常是在程序在執行過程中出現的事件,它擾亂了正常的指令流(翻譯的不好,見諒)。程序在運行的過程中會因為各種各樣的因素導致程序無法繼續執行,例如找不到文件、網絡連接超時、解析文件失敗等等,Java將這種導致程序無法正常執行的因素抽象成“異常”,并以此細分各種各樣的“異常”,再結合“異常處理”構成了整個異常體系,所謂“異常處理”指的就是當程序發生異常的時候,程序能自己處理異常,并嘗試恢復異常,使程序能繼續正常的運行而不需要外界認為的干預。下面我將逐步深入的介紹Java異常體系中幾個重要的點,包括但不限于:
- Java異常類繼承體系結構
- 異常的分類
- 異常處理機制
實際上,異常和異常處理機制在計算機硬件上就有的機制,各種編程語言對其做了抽象,使得異常的檢測、處理更加方便、高效。
2 Java異常類繼承體系結構
上圖是Java異常類結構圖,從圖中可以看到Throwable是整個異常類體系的父類,它有兩個最主要的子類,分別是Error和Exception。
2.1 Exception
Exception即異常,是應用程序本身可以處理的,Java將其分為兩大類:
- 非受檢異常。可以理解為運行時異常,即運行時會發生的異常,這種類型的異常不強求程序必須捕獲或者拋出,實際上也非常不建議在程序中捕獲運行時異常,因為運行時異常往往指代了某種系統異常,難以處理,如果捕獲了還很有可能導致程序不打印錯誤堆棧,使得錯誤難以排查。
- 受檢異常。除了RuntimeException及其子類,其他Exception都是受檢異常,Java編譯器要求程序必須捕獲(是否處理取決于需求)或者在方法簽名上加上throws聲明拋出該類型異常。
2.2 Error
Error即錯誤,因為Error往往是虛擬機相關的比較嚴重的錯誤,應用程序一般是沒有能力恢復的,例如StackOverflowError(棧溢出)、OutOfMemoryError(內存溢出)等,虛擬機對這種錯誤的處理方法一般是直接停止相關線程(也就是說,如果應用程序是多線程并發程序,那么即使出現了Error,應用程序也很可能不會直接退出)。實際上,Java雖然沒有禁止應用程序捕獲Error,但我們也應該盡量不要去做這事,因為這種錯誤并不是程序邏輯錯誤,而是虛擬機發生的錯誤,基本是不可修復的,如果捕獲了但無法處理的話,我們將無法得到錯誤堆棧,導致難以排查問題。
3 異常處理機制
Java中異常處理機制包含三個方面:檢測異常,捕獲異常以及處理異常。
3.1 try-catch塊檢測、捕獲并處理異常
我們可以使用try關鍵字來指定一個范圍,該范圍就是異常檢測的范圍,然后使用catch創建一個異常處理塊(在Java中,如果只有try而沒有catch則無法通過通過編譯)假設有如下代碼:
public static void method1() {
try {
method2();
} catch (IOException e) {
System.out.println("catch io exception");
}
}
先用javac將其編譯,然后使用javap -verbose XXX.class 將字節碼信息翻譯并打印出來,結果如下所示:
public static void method1() throws java.io.IOException;
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=0
0: invokestatic #6 // Method method2:()V
3: goto 15
6: astore_0
7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #8 // String catch io exception
12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: return
Exception table: //異常表
from to target type
0 3 6 Class java/io/IOException
LineNumberTable:
line 19: 0
line 22: 3
line 20: 6
line 21: 7
line 23: 15
StackMapTable: number_of_entries = 2
frame_type = 70 /* same_locals_1_stack_item */
stack = [ class java/io/IOException ]
frame_type = 8 /* same */
主要看看 Exception table(即異常表)標簽,發現只有一行數據,有from、to、target、type等字段。from、to即構成了異常檢測的范圍(例子中即0~3),target代表異常處理開始的字節碼索引(例子中即索引為6的字節碼),type表示異常處理器所處理的異常類型(例子中是IOException)。
現在來看看 Exception table(異常表),異常表里的一行數據表示一個異常處理器,每行數據有from、to、target、type四個字段,前三個字段的值都是字節碼的索引,type的值是一個符號引用,代表了異常處理器所處理的類型。每個方法都會有一個異常表,但有時候我們沒有在javap的打印結果中看到,這是因為對應的方法沒有異常處理器,即異常表中沒有任何數據,javap只是將其省略了而已。
當有異常發生的時候,虛擬機會遍歷異常表,首先檢查出現異常的位置是否在異常表中某個條目的檢測范圍內(from-to字段),如果有這樣的一個條目,將繼續檢查所拋出的異常是否是和type字段描述的異常匹配,如果匹配,就跳轉到target值所指向的字節碼進行異常處理。如果遍歷完整個表也沒有找到匹配的行,那么就會彈出棧,并在此時的棧幀上繼續執行如上操作,最壞的情況就是虛擬機需要遍歷整個方法調用棧中所有的異常表,如果最后還是沒有找到匹配的異常表條目,虛擬機將直接將異常拋出,并打印異常堆棧信息。
上面的文字描述可能會有點繞,不用擔心,看看下面這張邏輯流程圖,結合文字描述,應該就可以理解異常處理的流程了。
其實從上面的流程描述中,還隱含了一個重要的知識點:異常傳播機制。即當前方法無法處理的時候,異常會傳播到調用方,繼續嘗試處理異常,如此往復,知道最頂層的調用方,如果還是沒有合適的異常處理,那么就直接停止線程,拋出異常并打印異常堆棧。下面的代碼演示了異常傳播機制:
public class Main {
public static void main(String[] args) {
method1();
System.out.println("continue...");
}
public static void method1() {
try {
method2();
} catch (IOException e) {
System.out.println("catch io exception");
}
}
public static void method2() throws IOException{
method3();
}
public static void method3() throws IOException {
throw new IOException("method3");
}
}
代碼中,main方法調用method1,method1調用method2,method2調用method3,在method3中拋出了一個IOEception,因為IOException是一個受檢異常,所以method2要么使用try-catch構建一個異常處理器,要么使用throws關鍵字將異常繼續往上拋,method2選擇的是往上拋出異常,method1則是構建了一個異常處理器,如果該異常處理器能正確的捕獲并處理異常,則不會再往上拋異常了,所以main方法不需要做特殊處理。運行一下,結果大致如下所示:
catch io exception
continue...
發現continue能正確輸出,說明main線程沒有被停止,即異常已經被正確處理了。現在來修改一下代碼,如下所示:
public static void method1() throws IOException {
method2();
}
//其他部分代碼沒有變化
此時再次運行,結果大致如下:
Exception in thread "main" java.io.IOException: method3
at top.yeonon.exception.Main.method3(Main.java:26)
at top.yeonon.exception.Main.method2(Main.java:22)
at top.yeonon.exception.Main.method1(Main.java:18)
at top.yeonon.exception.Main.main(Main.java:12)
發現打印了異常堆棧,但是沒有打印continue,說明main線程并虛擬機停止了,沒能繼續執行。這是因為在整個方法調用棧中,沒有在任何一個方法的異常表找到匹配的異常表條目,即沒有找到合適的異常處理器,最終沒有辦法了,只能停止線程并拋出異常,指望程序員能處理了。
3.2 finally
到現在為止,我一直沒有提到finally,但其實finally也是一個很重要的組件。finally可以結合try-catch塊,無論是否發生異常,都會執行finally里的邏輯。finally的設計初衷是為了避免程序員忘記寫上一些清理操作的代碼,例如關閉網絡連接、文件IO連接等。
finally代碼塊的編譯也是比較復雜的,編譯器(當前版本的編譯器)并不是直接使用跳轉指令來實現“無論是否發生異常都會執行finally”功能的。而是采用“復制”的方法,將finally塊的代碼復制到try-catch塊所有正常執行路徑以及異常執行路徑的出口位置。如下圖所示(圖來自極客時間上關于JVM的一門課程,在最后我會標注):
變種1和變種2的邏輯其實是一樣的,只是finally塊所在的位置不太一樣而已。現在假設有如下代碼:
public class Main {
public static void main(String[] args) {
try {
method3();
} catch (IOException e) {
System.out.println("catch io exception");
} finally {
System.out.println("execute finally block");
}
System.out.println("continue...");
}
public static void method3() throws IOException {
throw new IOException("method3");
}
}
同樣編譯后,使用javap來輸出可閱讀的字節碼,如下所示:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: invokestatic #2 // Method method3:()V
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: ldc #4 // String execute finally block
8: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
11: goto 45
14: astore_1
15: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
18: ldc #7 // String catch io exception
20: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
23: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
26: ldc #4 // String execute finally block
28: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
31: goto 45
34: astore_2
35: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
38: ldc #4 // String execute finally block
40: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
43: aload_2
44: athrow
45: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
48: ldc #8 // String continue...
50: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
53: return
Exception table:
from to target type
0 3 14 Class java/io/IOException
0 3 34 any
14 23 34 any
注意一下6、26、38號指令和其前后兩條指令,發現其實就是finally塊代碼的內容,即輸出 execute finally block字符串。而且恰好有3份,和之前所描述的已知。然后來看看異常表,重點看看后面兩行,這里比較特殊的就是type字段,該字段的值是any,javap用這個來指代所有異常,即這兩個條目要處理的就是所有異常。其中的第一條form-to的范圍是03,發現是try塊的的范圍,第二條from-to的范圍是1423,發現其實是catch塊。為什么會這樣呢?
首先說try塊的,如果我們自己定義的異常處理器無法和發生的異常匹配,那么就會被捕獲所有異常的異常處理器捕獲,并跳轉到異常處理器所在的位置,例如這里的34號指令,我們發現其實34號指令就是finally塊原本所在的位置,也就是說,即使發現了沒有捕獲到的異常,也會走到finally塊的邏輯中。對于正常的情況,則是不會走到34號開始的代碼塊的,而是直接goto(11號指令)到45號指令處。
然后就是catch塊,因為在catch塊里也有可能發生異常的,所以加上這么一個異常捕獲器,并且和上面的一樣,跳轉到34號指令處執行finally代碼,如果在catch塊里沒有發生異常,和try塊那里一樣,繼續執行復制過來的finally塊的代碼,執行完畢后直接goto(31號指令)到45號指令處,也沒有執行最后的從34號開始的finally塊。
這也就是為什么在整個try-catch-finally結構中,無論是否發生異常,總是會執行finally里的邏輯。
4 小結
本文簡單介紹了異常的概念、分類以及異常處理機制。尤其是異常處理機制,我們深入到字節碼層面去查看整個處理機制的執行流程,相信大家會對異常處理有更深刻的認識。finally也是一個很重要的組件,其作用就是在整個try-catch-finally結構中,無論是否發生異常,都會執行finally塊里的邏輯,并且我也嘗試深入到字節碼中分析這個功能是如何實現的。
5 參考資料
極客時間: 深入拆解 Java 虛擬機第6節 :JVM是如何處理異常的?