1. 程式人生 > >讀鄭雨迪《深入拆解Java虛擬機器》 -- 第六講 JVM是如何處理異常的

讀鄭雨迪《深入拆解Java虛擬機器》 -- 第六講 JVM是如何處理異常的

眾所周知,異常處理的兩大組成要素是丟擲異常捕獲異常。這兩大要素共同實現程式控制流的非正常轉移

丟擲異常可分為顯示隱式兩種。顯示拋異常的主體是應用程式,它指的是在程式中使用“throw”關鍵字,手動將異常例項丟擲。隱式拋異常的主體則是Java虛擬機器,它指的是Java虛擬機器在執行過程中,碰到無法繼續執行的異常狀態,自動丟擲異常。比如,Java虛擬機器在執行讀取陣列操作時,發現輸入的索引值是負數,故而丟擲陣列索引越界異常(ArrayIndexOutOfBoundsException)

捕獲異常這涉及瞭如下三種程式碼塊:

  1. try程式碼塊:用來標記需要進行異常監控的程式碼。
  2. catch程式碼塊:跟在try程式碼塊之後,用來捕獲在try程式碼塊中觸發的某種指定型別的異常。除了宣告所捕獲異常的型別之外,catch程式碼塊還定義了針對該異常型別的異常處理器。在Java中,try程式碼塊後面可以跟著多個catch程式碼塊,來捕獲不同型別的異常。Java虛擬機器會從上至下匹配異常處理器
    。因此,前面的catch程式碼塊所捕獲的異常型別不能覆蓋後邊的,否則編譯器會報錯。
  3. finally程式碼塊:跟在try程式碼塊和catch程式碼塊之後,用來宣告一段必定執行的程式碼。它的設計初衷是為了避免跳過某些關鍵的清理程式碼,例如關閉已開啟的系統資源。

在程式正常執行的情況下,這段程式碼會在try程式碼塊之後執行。否則,也就是try程式碼塊觸發異常的情況下,如果該異常沒有被捕獲,finally程式碼塊會直接執行,並且在執行之後重新丟擲該異常。

如果該異常被catch程式碼塊捕獲,finally程式碼塊則在catch程式碼塊之後執行。在某些不幸的情況下,catch程式碼同樣也觸發了異常,那麼finally程式碼塊同樣會執行,並會丟擲catch程式碼塊觸發的異常。在某些極端不幸的情況下,finally程式碼塊也觸發了異常,那麼只好中斷當前finally程式碼塊的執行,並往外拋異常、

異常的基本概念

在Java語言規範中,所有異常都是Throwable類或者其子類的例項。Throwable有兩大直接子類。

  • Error,涵蓋程式不應捕獲的異常。當程式觸發Error時,它的執行狀態無法恢復,需要中止執行緒甚至是中止虛擬機器。
  • Exception,涵蓋程式可能需要捕獲並且處理的異常。Exception有一個特殊的子類RuntimeException,用來表示“程式雖然無法繼續執行,但是還能搶救一下”的情況。前邊提到的陣列索引越界異常便是其中的一種。

RuntimeException和Error屬於Java裡的非檢查異常(unchecked exception)其他異常則屬於檢查異常(checked exception)

。在Java語法中,所有的檢查異常都需要程式設計師先顯式地捕獲,或者在方法宣告中用throws關鍵字標註。通常情況下,程式中自定義的異常因為檢查異常,以便最大化利用Java編譯器的編譯時檢查。

異常例項的構造十分昂貴。這是由於在構造異常例項時,Java虛擬機器便需要生成該異常的棧軌跡(stack trace)。該操作會逐一訪問當前執行緒的Java棧幀,並且記錄下各種除錯資訊,包括棧幀所指向的方法的名字,方法所在的類名、檔名,以及在程式碼中的第幾行觸發該異常。

當然,在生成棧軌跡時,Java虛擬機器會忽略掉異常構造器以及填充棧幀的Java方法(Throwable.fillInStackTrace),直接從新建異常位置開始算起。此外,Java虛擬機器還會忽略標記為不可見的Java方法棧幀。

既然異常例項的構造十分昂貴,我們是否可以快取異常例項,在需要用到的時候直接丟擲呢?從語法角度上來看,這是允許的。然而,該異常對應的棧軌跡並非throw語句的位置,而是新建異常的位置。因此,這種做法可能會誤導開發人員,使其定位到錯誤的位置。這也是為什麼在實踐中,我們往往選擇丟擲新建異常例項的原因。

Java虛擬機器是如何捕獲異常的

在編譯生成的位元組碼中,每個方法都附帶一個異常表。異常表中的每一個條目都代表一個異常處理器,並且由from指令、to指令、target指令以及所捕獲的異常型別構成。這些指標的值是位元組碼索引(bytecode index, bci),用以定位位元組碼。

其中,from指標和to指標標示了該異常處理器所監控的範圍,例如try程式碼所覆蓋的範圍。target指標則指向異常處理器的起始位置。

public static void main(String[] args){
    try{
        mayThrowException();
    } catch(Exception e){
        e.printStackTrace();
    }
}

//對應的Java位元組碼
public static void main(java.lang.String[]);
    Code:
        0: invokestatic mayThrowException:()V
        3: goto 11
        6: astore_1
        7: aload_1
        8: invokevirtual java.lang.Exception.printStackTrace
       11: return
     Exception table:
        from    to    target    type
          0     3      Class    java/lang/Exception //異常表條目

編譯過後,該方法的異常表擁有一個條目。其from指標和to指標分別為0和3,代表它的監控範圍從索引為0的位元組碼開始,到索引為3的位元組碼結束(不包括3)。該條目的target指標是6,代表這個異常處理器從索引6的位元組碼開始。條目的最後一列,代表該異常處理器所捕獲的異常型別正是Exception。

當程式觸發異常是,Java虛擬機器會從上至下遍歷異常表中的所有條目。當觸發異常的位元組碼的索引值在某個異常表監控範圍內,Java虛擬機器會判斷所丟擲的異常和該條目想要捕獲的異常是否匹配。如果匹配,Java虛擬機器會將控制流轉移至該條目target指標指向的位元組碼。

如果遍歷完所有異常表條目,Java虛擬機器仍未匹配到異常處理器,那麼它會彈出當前方法對應的Java棧幀,並且在呼叫者(caller)中重複上述操作。在最壞情況下,Java虛擬機器需要遍歷當前執行緒Java棧上所有方法的異常表。

finally程式碼塊的編譯比較複雜。當前版本Java編譯器的做法,是復制finally程式碼塊的內容,分別放在try-catch程式碼塊所有正常執行路徑以及異常執行路徑的出口中。

針對異常執行路徑,Java編譯器會生成一個或多個異常表條目,監控整個try-catch程式碼塊,並且捕獲所有種類的異常(在javap中以any指代)。這些異常表條目的target指標將指向另一份複製的finally程式碼塊。並且,在這個finally程式碼塊的最後,Java編譯器會重新丟擲所捕獲的異常。

我們可以使用javap工具來檢視下面這段包含了try-catch-finally程式碼塊的編譯結果。為了更好地區分每個程式碼塊,我們定義了四個例項欄位:tryBlock、catchBlock、finallyBlock以及methodExit,並且僅在對應的程式碼塊中訪問這些欄位。

public class Foo{
	private int tryBlock;
	private int catchBlock;
	private int finallyBlock;
	private int methodExit;

	public void test(){
		try{
			tryBlock = 0;
		} catch(Exception e){
			catchBlock = 1;
		} finally{
			finallyBlock = 2;
		}
		methodExit = 3;
	}
}

然後編譯並檢視其位元組碼

javac Foo.java
javap -c Foo
Compiled from "Foo.java"
public class Foo {
  public Foo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: aload_0
       1: iconst_0
       2: putfield      #2                  // Field tryBlock:I
       5: aload_0
       6: iconst_2
       7: putfield      #3                  // Field finallyBlock:I
      10: goto          35
      13: astore_1
      14: aload_0
      15: iconst_1
      16: putfield      #5                  // Field catchBlock:I
      19: aload_0
      20: iconst_2
      21: putfield      #3                  // Field finallyBlock:I
      24: goto          35
      27: astore_2
      28: aload_0
      29: iconst_2
      30: putfield      #3                  // Field finallyBlock:I
      33: aload_2
      34: athrow
      35: aload_0
      36: iconst_3
      37: putfield      #6                  // Field methodExit:I
      40: return
    Exception table:
       from    to  target type
           0     5    13   Class java/lang/Exception
           0     5    27   any
          13    19    27   any
}

可以看到,便以結果包含三份finally程式碼塊。其中,前兩份分別位於try程式碼塊和catch程式碼塊的正常執行路徑出口。最後一份則作為異常處理器,監控try程式碼塊以及catch程式碼塊。它將捕獲try程式碼塊觸發的、未被catch程式碼塊捕獲的異常,以及catch程式碼塊觸發的異常。

如果catch程式碼塊捕獲了異常,並且觸發了另一個異常,那麼finally捕獲並且重拋的異常是哪個呢?答案是後者。也就是說原本的異常便會被忽略掉,這對於程式碼除錯來說十分不利。

Java 7 的 Supressed 異常以及語法糖

Java 7 引入了Supressed異常來解決這個問題。這個新特性允許開發人員將一個異常附於另一個異常之上。因此,丟擲的異常可以附帶多個異常資訊。

然而,Java層面的finally程式碼塊缺少指向所捕獲異常的引用,所以這個新特性使用起來十分繁瑣。

為此,Java 7 專門構造了一個名為try-with-resources的語法糖,在位元組碼層面自動使用Supressed異常。當然,該語法糖的主要目的並不是使用Supressed異常,而是精簡資源開啟關閉的用法。

在Java 7 之前,對於開啟的資源,我們需要定義一個finally程式碼塊,來確保該資源在正常或者異常執行狀況下都能關閉。

資源的關閉操作本身容易觸發異常。因此,如果同時開啟多個資源,那麼每一個資源都要對應一個獨立的try-finally程式碼塊,以保證每個資源都能夠關閉。這樣一來,程式碼將會變得十分繁瑣。

FileInputStream in0 = null;
FileInputStream in1 = null;
FileInputStream in2 = null;
...
try{
    in0 = new FileInputStream(new File("in0.txt"));
    ...
    try{
        in1 = new FileInputStream(new File("in1.txt"));
        ...
        try{
            in2 = new FileInputStream(new File("in2.txt"));
            ...
        } finally{
            if(in2 != null)
                in2.close();
        }
    } 
    finally{
        if(in1 != null)
            in1.close();
    }
} finally{
    if(in0 != null)
        in0.close();
}

Java 7 的try-with-resources語法糖極大地簡化了上述程式碼。程式可以在try關鍵字後宣告並例項化實現了AutoCloseable介面的類,編譯器將自動新增對應的close()操作。在宣告多個AutoCloseable 例項的情況下,編譯生成的位元組碼類似於上面手工編寫程式碼的編譯結果。與手工程式碼相比,try-with-resources還會使用Supressed異常的功能,來避免原異常“被消失”。

public class Foo implements AutoCloseable {
	private final String name;
	public Foo(String name) {
		this.name = name;
	}

	@Override
	public void close(){
		throw new RuntimeException(name);
	}

	public static void main(String[] args) {
		try (Foo foo0 = new Foo("Foo0"); //try-with-resources
			 Foo foo1 = new Foo("Foo1");
			 Foo foo2 = new Foo("Foo2")) {
			throw new RuntimeException("Initial");
		}
	}
}

執行結果如下:

javac Foo.java 
java Foo
Exception in thread "main" java.lang.RuntimeException: Initial
	at Foo.main(Foo.java:16)
	Suppressed: java.lang.RuntimeException: Foo2
		at Foo.close(Foo.java:9)
		at Foo.main(Foo.java:17)
	Suppressed: java.lang.RuntimeException: Foo1
		at Foo.close(Foo.java:9)
		at Foo.main(Foo.java:17)
	Suppressed: java.lang.RuntimeException: Foo0
		at Foo.close(Foo.java:9)
		at Foo.main(Foo.java:17)

除了 try-with-resources語法糖之外,Java 7 還支援在同一catch程式碼塊中捕獲多種異常。實際實現非常簡單,生成多個異常表條目即可。

//在同一 catch 程式碼塊中捕獲多種異常
try {
    ...
} catch (SomeException | OtherException e) {
    ...
}

編寫Java原始碼

public class Foo{
	private int tryBlock;
	private int catchBlock;
	private int finallyBlock;
	private int methodExit;

	public void test(){
		for (int i = 0; i < 100; i++){
			try{
				tryBlock = 0;
				if(i < 50){
					continue;
				}else if(i < 80){
					break;
				}else {
					return;
				}
			} catch(Exception e){
				catchBlock = 1;
			} finally{
				finallyBlock = 2;
			}
		}
		methodExit = 3;
	}
}

編譯並檢視其位元組碼

javac Foo.java 
javap -c Foo
Compiled from "Foo.java"
public class Foo {
  public Foo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: bipush        100
       5: if_icmpge     75
       8: aload_0
       9: iconst_0
      10: putfield      #2                  // Field tryBlock:I
      13: iload_1
      14: bipush        50
      16: if_icmpge     27
      19: aload_0
      20: iconst_2
      21: putfield      #3                  // Field finallyBlock:I
      24: goto          69
      27: iload_1
      28: bipush        80
      30: if_icmpge     41
      33: aload_0
      34: iconst_2
      35: putfield      #3                  // Field finallyBlock:I
      38: goto          75
      41: aload_0
      42: iconst_2
      43: putfield      #3                  // Field finallyBlock:I
      46: return
      47: astore_2
      48: aload_0
      49: iconst_1
      50: putfield      #5                  // Field catchBlock:I
      53: aload_0
      54: iconst_2
      55: putfield      #3                  // Field finallyBlock:I
      58: goto          69
      61: astore_3
      62: aload_0
      63: iconst_2
      64: putfield      #3                  // Field finallyBlock:I
      67: aload_3
      68: athrow
      69: iinc          1, 1
      72: goto          2
      75: aload_0
      76: iconst_3
      77: putfield      #6                  // Field methodExit:I
      80: return
    Exception table:
       from    to  target type
           8    19    47   Class java/lang/Exception
          27    33    47   Class java/lang/Exception
           8    19    61   any
          27    33    61   any
          47    53    61   any
}

由此可見,finally程式碼塊被拷貝到了 if語句的每個分支之後(如果分支中有return語句,則在該語句之前)

此文從極客時間專欄《深入理解Java虛擬機器》搬運而來,撰寫此文的目的:

  1. 對自己的學習總結歸納

  2. 此篇文章對想深入理解Java虛擬機器的人來說是非常不錯的文章,希望大家支援一下鄭老師。