Java 進階之異常處理

閱讀更多關於 Angular、TypeScript、Node.js/Java 、Spring 等技術文章,歡迎訪問個人我的博客 —— 全棧修仙之路

本文的主要內容分爲 Java 異常的定義、Java 異常的處理、JVM 基礎知識(異常表、JVM 指令分類和操做數棧)及深刻剖析 try-catch-finally 四部分(圖解形式)。在深刻剖析 try-catch-finally 部分會以字節碼的角度分析爲何 finally 語句必定會執行。第三和第四部分理解起來可能會有些難度,不感興趣的小夥伴可直接跳過。html

1、異常定義

異常是指在程序執行期間發生的事件,這些事件中斷了正常的指令流(例如,除零,數組越界訪問等)。在 Java 中,異常是一個對象,該對象包裝了方法內發生的錯誤事件,幷包含如下信息:java

  • 與異常有關的信息,如類型
  • 發生異常時程序的狀態
  • 其它自定義消息(可選)

此外,異常對象也能夠被拋出或捕獲。Java 程序在執行過程當中發生的異常可分爲兩大類:Error 和 Exception,它們都繼承於 Throwable 類。git

throwable-system.jpg

1.1 Error

An Error is a subclass of Throwable that indicates serious problems that a reasonable application should not try to catch. Most such errors are abnormal conditions.

Error 是 Throwable 類的子類,它表示合理的應用程序不該該嘗試捕獲的嚴重問題。大多數這樣的錯誤都是異常狀況。讓咱們來看一下 Error 類的一些子類,並閱讀 JavaDoc 上與它們有關的註釋:github

  • AnnotationFormatError:當註解解析器嘗試從類文件讀取註解並確認註解格式不正確時拋出。
  • AssertionError:拋出該異常以代表斷言失敗。
  • LinkageError:連接錯誤的子類表示一個類對另外一個類有必定的依賴性;然而,後一個類在前一個類編譯後發生了不兼容的變化。
  • VirtualMachineError:拋出表示 Java 虛擬機已損壞或已耗盡繼續運行所需的資源。

這些錯誤是不可查的,由於它們在應用程序的控制和處理能力以外,並且絕大多數是程序運行時不容許出現的情況。面試

1.2 Exception

The class Exception and its subclasses are a form of Throwable that indicates conditions that a reasonable application might want to catch.

Exception 和它的子類是可拋出異常的一種形式,表示合理的應用程序可能想要捕獲的異常。在 Exception 分支中有一個重要的子類 RuntimeException(運行時異常),該類型的異常會自動爲你所編寫的程序建立ArrayIndexOutOfBoundsException(數組下標越界異常)、NullPointerException(空指針異常)、ArithmeticException(算術異常)、IllegalArgumentException(非法參數異常)等異常,這些異常是不檢查異常,程序中能夠選擇捕獲處理,也能夠不處理。這些異常通常是由程序邏輯錯誤引發的,程序應該從邏輯角度儘量避免這類異常的發生。編程

1.3 Error vs Exception

Error 一般是災難性的致命的錯誤,是程序沒法控制和處理的,當出現這些異常時,Java 虛擬機(JVM)通常會選擇終止線程;Exception 一般狀況下是能夠被程序處理的,而且在程序中應該儘量的去處理這些異常。數組

1.4 Unchecked Exception vs Checked Exception

Unchecked Exception(不受檢查的異常):多是常常出現的編程錯誤,好比 NullPointerException(空指針異常)或 IllegalArgumentException(非法參數異常)。應用程序有時能夠處理它或今後 Throwable 類型的異常中恢復。或者至少在 Thread 的 run 方法中捕獲它,記錄日誌並繼續運行。oracle

Checked Exception(檢查異常):在正確的程序運行過程當中,很容易出現的、情理可容的異常情況,在必定程度上這種異常的發生是能夠預測的,而且一旦發生該種異常,就必須採起某種方式進行處理。app

除了 RuntimeException 及其子類之外,其餘的 Exception 類及其子類都屬於檢查異常,當程序中可能出現這類異常,要麼使用 try-catch 語句進行捕獲,要麼用 throws 子句拋出,不然編譯沒法經過。jvm

不受檢查異常和檢查異常的區別是:不受檢查異常爲編譯器不要求強制處理的異常,檢查異常則是編譯器要求必須處置的異常。

2、異常處理

在 Java 中有 5 個關鍵字用於異常處理:try,catch,finally,throws 和 throw(注意 throw 和 throws 之間存在一些區別)。

Java 的異常處理包含三部分:聲明異常、拋出異常和捕獲異常。

2.1 聲明異常

一個 Java 方法必須在其簽名中聲明可能經過 throws 關鍵字在其方法體中 「拋出」 的已檢查異常的類型。

舉個例子,假設 methodD() 的定義以下:

public void methodD() throws XxxException, YyyException {
  // 方法體拋出XxxException和YyyException異常
}

methodD 的方法簽名表示運行 methodD 方法時,可能遇到兩種 checked exceptions:XxxException 和 YyyException。換句話說,在 methodD 方法中若出現某些不正常的狀況可能會觸發 XxxException 或 YyyException 異常。

請注意,咱們不須要聲明屬於 Error,RuntimeException 及其子類的異常。這些異常稱爲不受檢查的異常,由於編譯器未檢查它們。

2.2 拋出一個異常

當 Java 操做遇到異常狀況時,包含錯誤語句的方法應建立一個適當的 Exception 對象,並經過 throw XxxException 語句將其拋到 Java 運行時。例如:

public void methodD() throws XxxException, YyyException {   // 方法簽名
   // 方法體
   ...
   ...
   // 出現XxxException異常
   if ( ... )
      throw new XxxException(...);   // 構造一個XxxException對象並拋給JVM
   ...
   // 出現YyyException異常
   if ( ... )
      throw new YyyException(...);   // 構造一個YyyException對象並拋給JVM
   ...
}
請注意,在方法簽名中聲明異常的關鍵字爲 throws,在方法體內拋出異常對象的關鍵字爲 throw

2.3 捕獲異常

當方法拋出異常時,JVM 在調用堆棧中向後搜索匹配的異常處理程序。每一個異常處理程序均可以處理一類特殊的異常。異常處理程序能夠處理特定的類,也能夠處理其子類。若是在調用堆棧中未找到異常處理程序,則程序終止。

好比,假設 methodD 方法在方法簽名上聲明瞭可能拋出的 XxxException 和 YyyException 異常,具體以下:

public void methodD() throws XxxException, YyyException { ...... }

要在程序中使用 methodD 方法,好比在 methodC 方法中,你能夠這樣作:

  1. 將 methodD 方法的調用包裝在 try-catchtry-catch-finally 中,以下所示。每一個 catch 塊能夠包含一種類型的異常對應的異常處理程序。
public void methodC() {  // 未聲明異常
   ......
   try {
      ......
      // 調用聲明XxxException和YyyException異常的methodD方法
      methodD();
      ......
   } catch (XxxException ex) {
      // 處理XxxException異常
      ......
   } catch (YyyException ex} {
      // 處理YyyException異常
      ......
   } finally {   // 可選
      // 這些代碼總會執行,用於執行清理操做
      ......
   }
   ......
}
  1. 假設調用 methodD 方法的 methodC 不但願處理異常(經過 try-catch),它能夠在方法簽名中聲明這些異常,以下所示:
public void methodC() throws XxxException, YyyException { // 讓更高層級的方法來處理
   ...
   // 調用聲明XxxException和YyyException異常的methodD方法
   methodD();   // 無需使用try-catch
   ...
}

在這種狀況下,若是 methodD 方法拋出 XxxException 或 YyyException,則 JVM 將終止 methodD 方法和methodC 方法並將異常對象沿調用堆棧傳遞給 methodC 方法的調用者。

2.4 try-catch-finally

try-catch-finally 的語法以下:

try {
   // 主要邏輯,使用了可能拋出異常的方法
   ......
} catch (Exception1 ex) {
   // 處理Exception1異常
   ......
} catch (Exception2 ex) {
   // 處理Exception2異常
   ......
} finally {   // finally是可選的
   // 這些代碼總會執行,用於執行清理操做
   ......
}

若是在 try 塊運行期間未發生異常,則將跳過全部 catch 塊,並在 try 塊以後執行 finally 塊。若是 try 塊中的一條語句引起異常,則 Java 運行時將忽略 try 塊中的其他語句,並開始搜索匹配的異常處理程序。它將異常類型與每一個 catch 塊順序匹配。

若是 catch 塊捕獲了該異常類或該異常的超類,則將執行該 catch 塊中的語句。而後,在該catch 塊以後執行 finally 塊中的語句。該程序將在 try-catch-finally 以後繼續進入下一個語句,除非它被過早終止。

若是沒有任何 catch 塊匹配,則異常將沿調用堆棧傳遞。當前方法執行 finally 子句並從調用堆棧中彈出。調用者遵循相同的過程來處理異常。

3、JVM 基礎知識

3.1 異常表

前面咱們已經介紹了經過使用 try{}catch(){}finally{} 來對異常進行捕獲或者處理。可是對於 JVM 來講,在它內部是如何進行異常處理呢?實際上 Java 編譯後,會在代碼後附加異常表的形式來實現 Java 的異常處理及 finally 機制(JDK 1.4.2 以前,Java 編譯器是使用 jsr 和 ret 指令來實現 finally 語句,JDK1.7 及以後版本,則徹底禁止在 Class 文件中使用 jsr 和 ret 指令)。

屬性表(attribute_info)能夠存在於 Class 文件、字段表、方法表中,用於描述某些場景的專有信息。屬性表中有個 Code 屬性,該屬性在方法表中使用,Java 程序方法體中的代碼被編譯成的字節碼指令存儲在 Code 屬性中。而異常表(exception_table)則是存儲在 Code 屬性表中的一個結構,這個結構是可選的。

異常表結構以下表所示。它包含 4 個字段:若是當字節碼在第 start_pc 行到 end_pc 行之間(包括 start_pc 行而不包括 end_pc 行)出現了類型爲 catch_type 或者其子類的異常(catch_type 爲指向一個 CONSTANT_Class_info 型常量的索引),則跳轉到第 handler_pc 行執行。若是 catch_type 爲 0,表示任意異常狀況都須要轉到 handler_pc 處進行處理。

異常結構表:

類型 名稱 數量
u2 start_pc 1
u2 end_pc 1
u2 handler_pc 1
u2 catch_type 1

下面咱們開始來分析一下 一個 catch 語句多個 catch 語句try-catch-finally 語句 這三種情形所生成的字節碼。從而加深對 JVM 內部 try-catch-finally 機制的理解。

爲了節省篇幅示例代碼就不貼出來了,本人已上傳上傳至 Gist,須要完整代碼的小夥伴請自行獲取。

注意:經過 javap -v -p ClassName(編譯後所生成 class 文件的名稱) 能夠查看生成的 class 文件的信息。

3.2 JVM 指令分類

由於使用一字節表示操做碼,因此 Java 虛擬機最多隻能支持 256(2^8 )條指令。

Java 虛擬機規範已經定義了 205 條指令,操做碼分別是 0(0x00)到 202(0xCA)、254(0xFE)和 255(0xFF)。這 205 條指令構成了 Java 虛擬機的指令集(instruction set)。

Java 虛擬機規範把已經定義的 205 條指令按用途分紅了 11 類:

  1. 常量(constants)指令
  2. 加載(loads)指令
  3. 存儲(stores)指令
  4. 操做數棧(stack)指令
  5. 數學(math)指令
  6. 轉換(conversions)指令
  7. 比較(comparisons)指令
  8. 控制(control)指令
  9. 引用(references)指令
  10. 擴展(extended)指令
  11. 保留(reserved)指令
保留指令共有 3 條。其中一條是留給調試器的,用於實現斷點,操做碼是 202(0xCA),助記符是 breakpoint。另外兩條留給 Java 虛擬機實現內使用,操做碼分別是 254(0xFE)266(0xFF),助記符是 impdep1impdep2。這三條指令不容許出如今 class 文件中。

若想了解完整的 Java 字節碼指令列表,能夠訪問 Wiki - Java_bytecode_instruction_listings 這個頁面。

3.3 操做數棧

操做數棧也常稱爲操做棧。它是各類各樣的字節碼操做如何得到他們的輸入,以及他們如何提供他們的輸出。

例如,考慮 iadd 操做,它將兩個 int 添加在一塊兒。要使用它,你在堆棧上推兩個值,而後使用它:

iload_0     # Push the value from local variable 0 onto the stack
iload_1     # Push the value from local variable 1 onto the stack
iadd        # Pops those off the stack, adds them, and pushes the result

如今棧上的頂值是這兩個局部變量的總和。下一個操做可能須要頂層棧值,並將其存儲在某個地方,或者咱們可能在堆棧中推送另外一個值來執行其餘操做。

假設要將三個值添加在一塊兒,堆棧使這很容易:

iload_0     # Push the value from local variable 0 onto the stack
iload_1     # Push the value from local variable 1 onto the stack
iadd        # Pops those off the stack, adds them, and pushes the result
iload_2     # Push the value from local variable 2 onto the stack
iadd        # Pops those off the stack, adds them, and pushes the result

如今棧上的頂值是將這三個局部變量相加在一塊兒的結果。

讓咱們更詳細地看看第二個例子:

咱們假設:

> 堆棧是空的開始
> 局部變量 0 包含 27
> 局部變量 1 包含 10
> 局部變量 2 包含 5

因此最初 stack 的狀態:

+-------+
| stack |
+-------+
+-------+

而後咱們執行:

iload_0      # Push the value from local variable 0 onto the stack

當前操做數棧的狀態:

+-------+
| stack |
+-------+
|   27  |
+-------+

接着繼續執行:

iload_1     # Push the value from local variable 1 onto the stack

當前操做數棧的狀態:

+-------+
| stack |
+-------+
|   10  |
|   27  |
+-------+

如今咱們執行 iadd 指令:

iadd        # Pops those off the stack, adds them, and pushes the result

該指令會將 10 和 27 出棧並對它們執行加法運算,完成計算後會把結果繼續入棧。此時操做數棧的狀態爲:

+-------+
| stack |
+-------+
|   37  |
+-------+

繼續執行如下指令:

iload_2     # Push the value from local variable 2 onto the stack

該指令執行以後,操做數棧的狀態:

+-------+
| stack |
+-------+
|    5  |
|   37  |
+-------+

最後咱們執行 iadd 指令:

iadd        # Pops those off the stack, adds them, and pushes the result

該指令執行以後,操做數棧的最終狀態:

+-------+
| stack |
+-------+
|   42  |
+-------+

4、深刻剖析 try-catch-finally

前面咱們已經介紹了 Java 中異常和 JVM 虛擬機相關知識,以前恰好看過 字節碼角度看面試題 —— try catch finally 爲啥 finally 語句必定會執行 這篇文章,下面咱們來換個角度,即以 字節碼 的角度來分析一下 try-catch-finally 的底層原理。

注意:如下內容須要對 Java 字節碼有必定的瞭解,請小夥伴們選擇性閱讀。

4.1 一個 catch 語句

one-catch-statement.jpg

紅色虛線關聯塊(1)

tryItOut 方法編譯後生成如下代碼:

0: aload_0
1: invokespecial #2

上述代碼的做用是從局部變量表中加載 this,並調用 tryItOut 方法。

藍色虛線關聯塊(2)

catch 語句編譯後生成如下代碼:

7: astore_1
8: aload_0
9: aload_1
10: invokespecial #4

上述代碼的做用是加載 MyException 實例,並調用 handleException 方法。

細心的小夥伴可能會發現生成的 Code 的索引是:0 - 1 - 4 -7 - 8 - 9 - 10 -13,沒有看到 二、3 和 十一、12。我的猜想是由於 JVM 字節碼指令 invokespecial 操做數佔用了 2 個索引字節(歡迎知道真相的大佬,慷慨解答)。這裏 invokespecial 字節碼指令的格式定義以下:

invokespecial
indexbyte1
indexbyte2

Exception table

當字節碼在第 0 行到 4 行之間(包括 0 行而不包括 4 行)出現了類型爲 MyException 類型或者其子類的異常,則跳轉到第 7 行。若 type 的值爲 0 時,表示任意異常狀況都須要轉向到 target 處進行處理。

4.2 多個 catch 語句

multi-catch-statement.jpg

從上圖可知,若存在多個 catch 語句,則異常表中會生成多條記錄。astore_1 字節碼指令的做用是把引用(異常對象 e)存入局部變量表。

4.3 try-catch-finally 語句

try-catch-finally-statement.jpg

基於上圖咱們來詳細分析一下生成的字節碼:

  • 第 0 - 5 行對應的功能邏輯是調用 tryItOut 方法並最終執行 finally 語句中的 handleFinally 方法;
  • 第 8 行是使用 goto 指令跳轉到 31 行即執行 return 指令;
  • 第 11 - 18 行對應的功能邏輯是捕獲 MyException 異常進而調用 handleException 方法並最終執行 finally 語句中的 handleFinally 方法;
  • 第 21 行使用 goto 指令跳轉到 31 行即執行 return 指令;
  • 24 - 30 行對應的功能邏輯是若出現其餘異常時,先保存當時的異常對象而後繼續調用 handleFinally 方法,最後再拋出已保存的異常對象。
  • 第 31 行使用 goto 指令跳轉到 31 行即執行 return 指令。

根據上述的分析和圖中三個虛線框標出的字節碼,相信你們已經知道在 Java 的 try-catch-finally 語句中 finally 語句必定會執行的最終緣由了。

5、參考資源

相關文章
相關標籤/搜索