Java 異常表與異常處理原理

JavaDog發表於2019-01-27

最近寫程式碼的時候遇到一些try catch的問題。

try {
    程式碼塊1
} catch (Exception e) {
    程式碼塊2
} finally {
    程式碼塊3
}
複製程式碼

在程式碼塊1執行的時候發生異常,但是程式碼塊2沒有執行,程式碼塊3執行了,排查半天發現程式碼塊1中丟擲的並不是Exception及其子類。那麼沒有catch住的try catch流程到底是怎麼樣的呢?

之前也簡單看過一些jvm try catch原理,這裡嘗試記錄總結一下。

Java 在程式碼中通過使用 try{}catch(){}finally{} 塊來對異常進行捕獲或者處理。但是對於 JVM 來說,是如何處理 try/catch 程式碼塊與異常的呢。

實際上 Java 編譯後,會在程式碼後附加異常表的形式來實現 Java 的異常處理及 finally 機制(在 JDK1.4.2之前,javac 編譯器使用 jsr 和 ret 指令來實現 finally 語句,但是1.4.2之後自動在每段可能的分支路徑後將 finally 語句塊內容冗餘生成一遍來實現。JDK1.7及之後版本,則完全禁止在 Class 檔案中使用 jsr 和 ret 指令)。

異常表

屬性表(attribute_info)可以存在於 Class 檔案、欄位表、方法表中,用於描述某些場景的專有資訊。屬性表中有個 Code 屬性,該屬性在方法表中使用,Java 程式方法體中的程式碼被編譯成的位元組碼指令儲存在 Code 屬性中。而異常表(exception_table)則是儲存在 Code 屬性表中的一個結構,這個結構是可選的。

異常表結構

異常表結構如下表所示。它包含四個欄位:如果當位元組碼在第 start_pc 行到 end_pc 行之間(即[start_pc, end_pc))出現了型別為 catch_type 或者其子類的異常(catch_type 為指向一個 CONSTANT_Class_info 型常量的索引),則跳轉到第 handler_pc 行執行。如果 catch_type 為0,表示任意異常情況都需要轉到 handler_pc 處進行處理。

型別名稱數量
u2start_pc1
u2end_pc1
u2handler_pc1
u2catch_type1

處理異常機制

如上面所說,每個類編譯後,都會跟隨一個異常表,如果發生異常,首先在異常表中查詢對應的行(即程式碼中相應的 try{}catch(){} 程式碼塊),如果找到,則跳轉到異常處理程式碼執行,如果沒有找到,則返回(執行 finally 之後),並 copy 異常的應用給父呼叫者,接著查詢父呼叫的異常表,以此類推。

異常處理例項

對於 Java 原始碼:

public class Test {
    public int inc() {
        int x;
        try {
            x = 1;
            return x;
        } catch (Exception e) {
            x = 2;
            return x;
        } finally {
            x = 3;
        }
    }
}
複製程式碼

將其編譯為 ByteCode 位元組碼(JDK版本1.8):

public int inc();
    Code:
       0: iconst_1          #try中x=1入棧
       1: istore_1          #x=1存入第二個int變數
       2: iload_1           #將第二個int變數推到棧頂
       3: istore_2          #將棧頂元素存入第三個變數,即儲存try中的返回值
       4: iconst_3          #final中的x=3入棧
       5: istore_1          #棧頂元素放入第二個int變數,即final中的x=3
       6: iload_2           #將第三個int變數推到棧頂,即try中的返回值
       7: ireturn           #當前方法返回int,即x=1
       8: astore_2          #棧頂數值放入當前frame的區域性變數陣列中第三個
       9: iconst_2          #catch中的x=2入棧
      10: istore_1          #x=2放入第二個int變數
      11: iload_1           #將第二個int變數推到棧頂
      12: istore_3          #將棧頂元素存入第四個變數,即儲存catch中的返回值
      13: iconst_3          #final中的x=3入棧
      14: istore_1          #final中的x=3放入第一個int變數
      15: iload_3           #將第四個int變數推到棧頂,即儲存的catch中的返回值
      16: ireturn           #當前方法返回int,即x=2
      17: astore        4   #棧頂數值放入當前frame的區域性變數陣列中第五個
      19: iconst_3          #final中的x=3入棧
      20: istore_1          #final中的x=3放入第一個int變數
      21: aload         4   #當前frame的區域性變數陣列中第五個放入棧頂
      23: athrow            #將棧頂的數值作為異常或錯誤丟擲
    Exception table:
       from    to  target type
           0     4     8   Class java/lang/Exception
           0     4    17   any
           8    13    17   any
          17    19    17   any
複製程式碼

首先可以看到,對於 finally,編譯器將每個可能出現的分支後都放置了冗餘。並且編譯器生成了三個異常表記錄,從 Java 程式碼的語義上講,執行路徑分別為:

  1. 如果 try 語句塊中出現了屬於 Exception 及其子類的異常,則跳轉到 catch 處理;
  2. 如果 try 語句塊中出現了不屬於 Exception 及其子類的異常,則跳轉到 finally 處理;
  3. 如果 catch 語句塊中出現了任何異常,則跳轉到 finally 處理。

由此可以分析此段程式碼可能的返回結果:

  1. 如果沒有出現異常,返回1;
  2. 如果出現 Exception 異常,返回2;
  3. 如果出現了 Exception 意外的異常,非正常退出,沒有返回;

我們來分析位元組碼:

首先,0-3行,就是把整數1賦值給 x,並且將此時 x 的值複製一個副本到本地變數表的 Slot 中暫存,這個 Slot 裡面的值在 ireturn 指令執行前會被重新讀到棧頂,作為返回值。這時如果沒有異常,則執行4-5行,把 x 賦值為3,然後返回前面儲存的1,方法結束。如果出現異常,讀取異常表發現應該執行第8行,pc 暫存器指標轉向8行,8-16行就是把2賦值給 x,然後把 x 暫存起來,再將 x 賦值為3,然後將暫存的2讀到操作棧頂返回。第17行開始是把 x 賦值為3並且將棧頂的異常丟擲,方法結束。

上面是一個比較簡單的Java程式,這裡稍微複雜化它,嘗試在finally中增加異常模組:

public class Test {
    public int inc() {
        int x;
        try {
            x = 1;
            return x;
        } catch (Exception e) {
            x = 2;
            return x;
        } finally {
            try{
                x = 3;
            } catch (Exception e) {
                x = 4;
            }
        }
    }
}
複製程式碼

將其編譯為 ByteCode 位元組碼:

public int inc();
    Code:
       0: iconst_1
       1: istore_1
       2: iload_1
       3: istore_2
       4: iconst_3
       5: istore_1
       6: goto          12
       9: astore_3
      10: iconst_4
      11: istore_1
      12: iload_2
      13: ireturn
      14: astore_2
      15: iconst_2
      16: istore_1
      17: iload_1
      18: istore_3
      19: iconst_3
      20: istore_1
      21: goto          28
      24: astore        4
      26: iconst_4
      27: istore_1
      28: iload_3
      29: ireturn
      30: astore        5
      32: iconst_3
      33: istore_1
      34: goto          41
      37: astore        6
      39: iconst_4
      40: istore_1
      41: aload         5
      43: athrow
    Exception table:
       from    to  target type
           4     6     9   Class java/lang/Exception
           0     4    14   Class java/lang/Exception
          19    21    24   Class java/lang/Exception
           0     4    30   any
          14    19    30   any
          32    34    37   Class java/lang/Exception
          30    32    30   any
複製程式碼

和上面一樣,0-3行為try內語句,儲存x=1並準備返回,如果發生異常則查詢異常表,跳轉執行14行;14-18行為catch部分語句,儲存x=2並準備返回;4-6行、19-21行、32-34行為finally中語句,首先設定x=3,如果沒有發生異常,則之後進行跳轉,否則往下執行,即執行astoreiconstistore,即保留之前的棧頂位置,對x賦值為4。

最後總結一下,Java通過異常表來捕捉異常,在表中針對發生的異常能夠獲取接下來執行到哪裡(從try跳轉到catch),除了指定的異常外,還會自動追加any異常,用來捕獲程式中沒有捕獲的異常。而finally會自動的追加到try、catch以及未捕獲到的異常後面執行。對於多層次的try{}catch{},同理。

ps. 最後有一個彩蛋,就是異常表後面會追加一個指向自己start_pc的條目,這裡有一些討論可以看看。

碼字不易,如有建議請掃碼Java 異常表與異常處理原理