JVM 異常表及 try-catch-finally 字節碼分析

語言: CN / TW / HK

作為一個“有經驗”的 Java 工程師,你一定知道什麼是try-catch-finally代碼塊。但是你知道 JVM 是如何處理異常的嗎?今天我們就來講講異常在 JVM 中的處理機制,以及字節碼中異常表。

希望在這之後,不會有人再將下面這張表情包發給你……

環境介紹

  • jdk 1.8.0_151
  • IntelliJ IDEA 及 jclasslib 插件

字節碼中的 try-catch

Talk is cheap, show you my code!

反編譯後的字節碼

首先我編寫了第一段測試代碼,這裏有一個 try-catch 代碼塊,每個代碼塊中都有一行輸出,在 catch 代碼塊中捕獲的是 Exception 異常。

 public static void main(String[] args) {
        try {
            System.out.println("enter try block");
        } catch (Exception e) {
            System.out.println("enter catch block");
        }
    }
複製代碼

然後在命令行中先定位到這個類的字節碼文件目錄中,執行主方法後敲下javap -c 類名進行反編譯,或者直接在編譯器中選擇Build Project,然後打開 jclasslib 工具就可以看到這個類的字節碼。

我選擇了第二個方法,主方法的字節碼如下圖:

258D7340-1258-4DFE-A8FF-4D97CE07AB95.png

可以看到0~3行是 try 代碼塊中的輸出語句,12~17行是 catch 代碼塊中的輸出語句。然後重點來了。

timg.jpeg

第8行的字節碼是8 goto 20,這是什麼意思呢?沒錯,盲猜就能猜到,這個字節碼指令就是跳轉到第20行的意思。這一行是説,如果 try 代碼塊中沒有出現異常,那麼就跳轉到第20行,也就是整個方法行完成後 return 了。

這是正常的代碼執行流程,那麼如果出現異常了,虛擬機是如何知道應該“監控” try 代碼塊?它又是怎麼知道該捕獲何種異常呢?

答案就是——異常表。

異常表

在一個類被編譯成字節碼之後,它的每個方法中都會有一張異常表。異常表中包含了“監控”的範圍,“監控”何種異常以及拋出異常後去哪裏處理。比如上述的示例代碼,在 jclasslib 中它的異常表如下圖。

81B9AED6-8E67-4A5E-853D-ACD295D7D504.png

或者在javap -c命令下異常表是這樣的:

Exception table:
   from    to  target type
       0     8    11   Class java/lang/Exception
複製代碼

無論是哪種形式的異常表,我們可以知道的是,異常表中每一行就代表一個異常處理器。

  • Nr. 代表異常處理器的序號
  • Start PC (from),代表異常處理器所監控範圍的起始點
  • End PC (to),代表異常處理器所監控範圍的結束點(該行不被包括在監控範圍內,一般是 goto 指令)
  • Handler PC (target),指向異常處理器的起始位置,在這裏就是 catch 代碼塊的起始位置
  • Catch Type (type),代表異常處理器所捕獲的異常類型

如果程序觸發了異常,Java 虛擬機會按照序號遍歷異常表,當觸發的異常在這條異常處理器的監控範圍內(from 和 to),且異常類型(type)與該異常處理器一致時,Java 虛擬機就會跳轉到該異常處理器的起始位置(target)開始執行字節碼。

如果程序沒有觸發異常,那麼虛擬機會使用 goto 指令跳過 catch 代碼塊,執行 finally 語句或者方法返回。

字節碼中的 finally

接下來在上述的代碼中再加入一個 finally 代碼塊,然後再次執行反編譯的命令看看有什麼不一樣。

// 源代碼
public static void main(String[] args) {
        try {
            // dosomething
            System.out.println("enter try block");
        } catch (Exception e) {
            System.out.println("enter catch block");
        } finally {
            System.out.println("enter finally block");
        }
    }
複製代碼
// 字節碼
 0 getstatic #2     <java/lang/System.out>
 3 ldc #3           <enter try block>
 5 invokevirtual #4 <java/io/PrintStream.println>
 8 getstatic #2     <java/lang/System.out>
11 ldc #5           <enter finally block>
13 invokevirtual #4 <java/io/PrintStream.println>
16 goto 50 (+34)
19 astore_1
20 getstatic #2     <java/lang/System.out>
23 ldc #7           <enter catch block>
25 invokevirtual #4 <java/io/PrintStream.println>
28 getstatic #2     <java/lang/System.out>
31 ldc #5           <enter finally block>
33 invokevirtual #4 <java/io/PrintStream.println>
36 goto 50 (+14)
39 astore_2
40 getstatic #2     <java/lang/System.out>
43 ldc #5           <enter finally block>
45 invokevirtual #4 <java/io/PrintStream.println>
48 aload_2
49 athrow
50 return
複製代碼

finally 代碼塊在當前版本(jdk 1.8)的 JVM 中的處理機制是比較特殊的。從上面的字節碼中也可以明顯看到,只是加了一個 finally 代碼塊而已,字節碼指令增加了很多行。

如果再仔細觀察一下,我們可以發現,在字節碼指令中,有三塊重複的字節碼指令,分別是8~13行、28~33行和40~45行,如果對字節碼有些瞭解的同學或許已經知道了,這三塊重複的字節碼就是 finally 代碼塊對應的代碼。

出現三塊重複字節碼指令的原因是在 JVM 中,所有異常路徑(如try、catch)以及所有正常執行路徑的出口都會被附加一份 finally 代碼塊。也就是説,在上述的示例代碼中,try 代碼塊後面會跟着一份 finally 的代碼,catch 代碼塊後面也是如此,再加上原本正常流程會執行的 finally 代碼塊,在字節碼中一共有三份 finally 代碼塊代碼塊。

而針對每一條可能出現的異常的路徑,JVM 都會在異常表中多生成一條異常處理器,用來監控整個 try-catch 代碼塊,同時它會捕獲所有種類的異常,並且在執行完 finally 代碼塊之後會重新拋出剛剛捕獲的異常。

上述示例代碼的異常表如下

Exception table:
   from    to  target type
       0     8    19   Class java/lang/Exception
       0     8    39   any
      19    28    39   any
複製代碼

可以看到與原來相比異常表增加了兩條,第2條異常處理器異常監控 try 代碼塊,第3條異常處理器監控 catch 代碼塊,如果出現異常則會跳轉到第39行的 finally 代碼塊執行。

這就是 finally 一定會在 try-catch 代碼塊之後執行的原因了(某些能中斷程序運行的操作除外)。

如果 finally 也拋出異常

上文説到虛擬機會對整個 try-catch 代碼塊生成一個或多個異常處理器,如果在 catch 代碼塊中拋出了異常,這個異常會被捕獲,並且在執行完 finally 代碼塊之後被重新拋出。

那麼在這裏有一個額外的問題需要提及,假設在 catch 代碼塊中拋出了異常 A,當執行 finally 代碼塊時又拋出了異常 B,那麼最後拋出的是什麼異常呢?

如果有同學自己嘗試過這個操作,就會知道最後拋出的異常 B。也就是説,在捕獲了 catch 代碼塊中的異常後,如果 finally 代碼塊中也拋出了異常,那麼最終將會拋出 finally 中拋出的異常,而原來 catch 代碼塊中的異常將會被忽略。

如果代碼塊中有 return

講完了異常在各個代碼塊中的情況,接下來再來考慮一下 return 關鍵字吧,如果 try 或者 catch 中有 return,finally 還會執行嗎?如果 finally 中也有 return,那麼最終返回的值是什麼?為了説明這個問題,我編寫了一段測試代碼,然後找到它的字節碼指令。

public static int get() {
    try {
        return 1;
    } catch (Exception e) {
        return 2;
    } finally {
        return 3;
    }
}

// 字節碼指令
 0 iconst_1
 1 istore_0
 2 iconst_3
 3 ireturn
 4 astore_0
 5 iconst_2
 6 istore_1
 7 iconst_3
 8 ireturn
 9 astore_2
10 iconst_3
11 ireturn
複製代碼

正如上文所述,finally 代碼塊會在所有正常及異常的路徑上都複製一份,在這段字節碼中,iconst_3 就是對應着 finally 代碼塊,共三份,所以即便在 try 或者 catch 代碼塊中有 return 語句,最終還是會會執行 finally 代碼塊中的內容。

也就是説,這個方法最終的返回結果是3。

小結

  1. try-catch 語句的字節碼指令
  2. 異常表的介紹及 JVM 中異常處理流程
  3. JVM 中關於 finally 代碼塊的特殊處理
  4. 關於 return 關鍵字在 try-catch-finally 中的説明