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 中的說明