Java SE基礎鞏固(十一):異常

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異常處理機制

極客時間: 深入拆解 Java 虛擬機第6節 :JVM是如何處理異常的?

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,527評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內容