Java 異常處理中的種種細節!

承香墨影發表於2019-01-09

前言

今天我們來討論一下,程式中的錯誤處理。

在任何一個穩定的程式中,都會有大量的程式碼在處理錯誤,有一些業務錯誤,我們可以通過主動檢查判斷來規避,可對於一些不能主動判斷的錯誤,例如 RuntimeException,我們就需要使用 try-catch-finally 語句了。

有人說,錯誤處理並不難啊,try-catch-finally 一把梭,try 放功能程式碼,在 catch 中捕獲異常、處理異常,finally 中寫那些無論是否發生異常,都要執行的程式碼,這很簡單啊。

處理錯誤的程式碼,確實並不難寫,可是想把錯誤處理寫好,也並不是一件容易的事情。

接下來我們就從實現到 JVM 原理,講清楚 Java 的異常處理。

學東西,我還是推薦要帶著問題去探索,提前思考幾個問題吧:

  1. 一個方法,異常捕獲塊中,不同的地方的 return 語句,誰會生效?
  2. catch 和 finally 中出現異常,會如何處理?
  3. try-catch 是否影響效率?
  4. Java 異常捕獲的原理?

二、Java 異常處理

2.1 概述

既然是異常處理,肯定是區分異常發生捕獲、處理異常,這也正是組成異常處理的兩大要素。

在 Java 中,丟擲的異常可以分為顯示異常隱式異常,這種區分主要來自丟擲異常的主體是什麼,顯示和隱式也是站在應用程式的視角來區分的。

顯示異常的主體是當前我們的應用程式,它指的是在應用程式中使用 “throw” 關鍵字,主動將異常例項丟擲。而隱式異常就不受我們控制, 它觸發的主體是 Java 虛擬機器,指的是 Java 虛擬機器在執行過程中,遇到了無法繼續執行的異常狀態,續而將異常丟擲。

對於隱式異常,在觸發時,需要顯示捕獲(try-catch),或者在方法頭上,用 "throw" 關鍵字宣告,交由呼叫者捕獲處理。

2.2 使用異常捕獲

在我們編寫異常處理程式碼的時候,主要就是使用前面介紹到的 try-catch-finally 這三種程式碼塊。

  • try 程式碼塊:包含待監控異常的程式碼。
  • catch 程式碼塊:緊跟 try 塊之後,可以指定異常型別。允許指定捕獲多種不同的異常,catch 塊用來捕獲在 try 塊中出發的某個指定型別的異常。
  • finally 程式碼塊:緊跟 try 塊或 catch 塊之後,用來宣告一段必定會執行的程式碼。例如用來清理一些資源。

catch 允許存在多個,用於針對不同的異常做不同的處理。如果使用 catch 捕獲多種異常,各個 catch 塊是互斥的,和 switch 語句類似,優先順序是從上到下,只能選擇其一去處理異常。

既然 try-catch-finally 存在多種情況,並且在發生異常和不發生異常時,表現是不一致的,我們就分清楚來單獨分析。

1. try塊中,未發生異常

不觸發異常,當然是我們樂於看見的。在這種情況下,如果有 finally 塊,它會在 try 塊之後執行,catch 塊永遠也不會被執行。

2. try塊中,發生異常

在發生異常時,會首先檢查異常型別,是否存在於我們的 catch 塊中指定的待捕獲異常。如果存在,則這個異常被捕獲,對應的 catch 塊程式碼則開始執行,finally 塊程式碼緊隨其後。

例如:我們只監聽了空指標(NullPointerException),此時如果發生了除數為 0 的崩潰(ArithmeticException),則是不會被處理的。

當觸發了我們未捕獲的異常時,finally 程式碼依然會被執行,在執行完畢後,繼續將異常“丟擲去”。

3. catch 或者 finally 發生異常

catch 程式碼塊和 finally 程式碼塊,也是我們編寫的,理論上也是有出錯的可能。

那麼這兩段程式碼發生異常,會出現什麼情況呢?

當在 catch 程式碼塊中發生異常時,此時的表現取決於 finally 程式碼塊中是否存在 return 語句。如果存在,則 finally 程式碼塊的程式碼執行完畢直接返回,否則會在 finally 程式碼塊執行完畢後,將 catch 程式碼中新產生的異常,向外丟擲去。

而在極端情況下,finally 程式碼塊發生了異常,則此時會中斷 finally 程式碼塊的執行,直接將異常向外丟擲。

2.3 異常捕獲的返回值

再回頭看看第一個問題,假如我們寫了一個方法,其中的程式碼被 try-catch-finally 包裹住進行異常處理,此時如果我們在多個地方都有 return 語句,最終誰的會被執行?

如上圖所示,在完整的 try-catch-finally 語句中,finally 都是最後執行的,假設 finally 程式碼塊中存在 return 語句,則直接返回,它是優先順序最高的。

一般我們不建議在 finally 程式碼塊中新增 return 語句,因為這會破壞並阻止異常的丟擲,導致不宜排查的崩潰。

2.4 異常的型別

在 Java 中,所有的異常,其實都是一個個異常類,它們都是 Throwable 類或其子類的例項。

Throwable 有兩大子類,ExceptionError

  • Exception:表示程式可能需要捕獲並且處理的異常。
  • Error:表示當觸發 Error 時,它的執行狀態已經無法恢復了,需要中止執行緒甚至是中止虛擬機器。這是不應該被我們應用程式所捕獲的異常。

通常,我們只需要捕獲 Exception 就可以了。但 Exception 中,有一個特殊的子類 RuntimeException,即執行時錯誤,它是在程式執行時,動態出現的一些異常。比較常見的就是 NullPointerException、ArrayIndexOutOfBoundsException 等。

Error 和 RuntimeException 都屬於非檢查異常(Unchecked Exception),與之相對的就是普通 Exception 這種屬於檢查異常(Checked Exception)。

所有檢查異常都需要在程式中,用程式碼顯式捕獲,或者在方法中用 throw 關鍵字顯式標註。其實意思很明顯,要不你自己處理了,要不你丟擲去讓別人處理。

這種檢查異常的機制,是在編譯期間進行檢查的,所以如果不按此規範處理,在編譯器編譯程式碼時,就會丟擲異常。

2.5 異常處理的效能問題

對於異常處理的效能問題,其實是一個很有爭議的問題,有人覺得異常處理是多做了一些工作,肯定對效能是有影響的。但是也有人覺得異常處理的影響,和增加一個 if-else 屬於同種量級,對效能的影響其實微乎其微,是在可以接受的範圍內的。

既然有爭議,最簡單的辦法是寫個 Demo 驗證一下。當然,我們這裡是需要區分不同的情況,然後根據解決對比的。

一個最簡單的 for 迴圈 100w 次,在其中做一個 a++ 的自增操作。

  • A:無任何 try-catch 語句。
  • B:將 a++ 包在 try 程式碼塊中。
  • C:在 try 程式碼塊中,觸發一個異常。

就是一個簡單的 for 迴圈,就不貼程式碼了,異常通過 5/0 這樣的運算,觸發除數為 0 的 ArithmeticException 異常,並在 JDK 1.8 的環境下執行。

為了避免影響取樣結果,每個例子都單獨執行 10 遍之後,取平均值(單位納秒)。

到這裡基本上就可以得出結論了,在沒有發生異常的情況下,try-catch 對效能的影響微乎其微。但是一旦發生異常,效能上則是災難性的。

因此,我們應該儘可能的避免通過異常來處理正常的邏輯檢查,這樣可以確保不會因為發生異常而導致效能問題。

至於為什麼發生異常時,效能差別會有如此之大,就需要從 Java 虛擬機器 JVM 的角度來分析了,後面會詳細分析。

2.6 異常處理無法覆蓋非同步回撥

try-catch-finally 確實很好用,但是它並不能捕獲,非同步回撥中的異常。try 語句裡的方法,如果允許在另外一個執行緒中,其中丟擲的異常,是無法在呼叫者這個執行緒中捕獲的。

這一點在使用的過程中,需要特別注意。

三、JVM 如何處理異常

3.1 JVM 異常處理概述

接下來我們從 JVM 的角度,分析 JVM 如何處理異常。

當異常發生時,異常例項的構建,是非常消耗效能的。這是由於在構造異常例項時,Java 虛擬機器需要生成該異常的異常棧(stack trace)。

異常棧會逐一訪問當前執行緒的 Java 棧幀,以及各種除錯資訊。包括棧幀所指向的方法名,方法所在的類名、檔名以及在程式碼中是第幾行觸發的異常。

這些異常輸出到 Log 中,就是我們熟悉的崩潰日誌(崩潰棧)。

3.2 崩潰例項分析異常處理

當把 Java 程式碼編譯成位元組碼後,每個方法都會附帶一個異常表,其中記錄了當前方法的異常處理。

下面直接舉個例子,寫一個最簡單的 try-catch 類。

使用 javap -c 進行反編譯成位元組碼。

可以看到,末尾的 Exceptions Table 就是異常表。異常表中的每一條記錄,都代表了一個異常處理器。

異常處理器中,標記了當前異常監控的起始、結束程式碼索引,和異常處理器的索引。其中 from 指標和 to 指標標識了該異常處理器所監控的程式碼範圍,target 指標則指向異常處理器的起始位置,type 則為最後監聽的異常。

例如上面的例子中,main 函式中存在異常表,Exception 的異常監聽程式碼範圍分別是 [0,8)(不包括 8),異常處理器的索引為 11。

繼續分析異常處理流程,還需要區分是否命中異常。

1. 命中異常

當程式發生異常時,Java 虛擬機器會從上到下遍歷異常表中所有的記錄。當發現觸發異常的位元組碼的索引值,在某個異常表中某個異常監控的範圍內。Java 虛擬機器會判斷所丟擲的異常和該條異常監聽的異常型別,是否匹配。如果能匹配上,Java 虛擬機器會將控制流轉向至該此異常處理器的 target 索引指向的位元組碼,這是命中異常的情況。

2. 未命中異常

而如果遍歷完異常表中所有的異常處理器之後,仍未匹配到異常處理器,那麼它會彈出當前方法對應的 Java 棧幀。回到它的呼叫者,在其中重複此過程。

最壞的情況下,Java 虛擬機器需要遍歷當前執行緒 Java 棧上所有方法的異常表。

3.3 編譯後的 finally 程式碼塊

我們寫的程式碼,其實終歸是給人讀的,但是編譯器乾的事兒,都不是人事兒。它會把程式碼做一些特殊的處理,只是為了讓自己更好解析和執行。

編譯器對 finally 程式碼塊,就是這樣處理的。在當前版本的 Java 編譯器中,會將 finally 程式碼塊的內容,複製幾份,分別放在所有可能執行的程式碼路徑的出口中。

寫個 Demo 驗證一下,程式碼如下。

繼續 javap -c 反編譯成位元組碼。

這個例子中,為了更清晰的看到 finally 程式碼塊,我在其中輸出的一段 Log “run finally”。可以看到,編譯結果中,包含了三份 finally 程式碼塊。

其中,前兩份分別位於 try 程式碼塊和 catch 程式碼塊的正常執行路徑出口。最後一份則作為全域性的異常處理器,監控 try 程式碼塊以及 catch 程式碼塊。它將捕獲 try 程式碼塊觸發並且未命中 catch 程式碼塊捕獲的異常,以及在 catch 程式碼塊觸發的異常。

而 finally 的程式碼,如果出現異常,就不是當前方法所能處理的了,會直接向外丟擲。

3.4 異常表中的 any 是什麼?

從上圖中可以看到,在異常表中,還存在兩個 any 的資訊。

第一個資訊的 from 和 to 的範圍就是 try 程式碼塊,等於是對 catch 遺漏異常的一種補充,表示會處理所有種類的異常。

第二個資訊的 from 和 to 的範圍,仔細看能看到它其實是 catch 程式碼塊,這也正好印證了我們上面的結論,catch 程式碼塊其實也被異常處理器監控著。

只是如果命中了 any 之後,因為沒有對應的異常處理器,會繼續向上丟擲去,交由該方法的呼叫方法處理。

四、總結

到這裡我們就基本上講清楚了 Java 異常處理的所有內容。

在日常開發當中,應該儘量避免使用異常處理的機制來處理業務邏輯,例如很多程式碼中,型別轉換就使用 try-catch 來處理,其實是很不可取的。

異常捕獲對應用程式的效能確實有影響,但也是分情況的。

一旦異常被丟擲來,方法也就跟著 return 了,捕獲異常棧時會導致效能變得很慢,尤其是呼叫棧比較深的時候。

但是從另一個角度來說,異常丟擲時,基本上表明程式的錯誤。應用程式在大多數情況下,應該是在沒有異常情況的環境下執行的。所以,異常情況應該是少數情況,只要我們不濫用異常處理,基本上不會影響正常處理的效能問題。

本文對你有幫助嗎?留言、點贊、轉發是最大的支援,謝謝!


聯機圓桌」?推薦我的知識星球,一年 50 個優質問題,上桌聯機學習。

公眾號後臺回覆成長『成長』,將會得到我準備的學習資料,也能回覆『加群』,一起學習進步;你還能回覆『提問』,向我發起提問。

推薦閱讀:

“寒冬”正是學習時|關於字元編碼,你需要知道的都在這裡 | 分詞,科普及解決方案| 圖解:HTTP 範圍請求 | 小程式學習資料 |HTTP 內容編碼 | 輔助模式實戰 | 輔助模式玩出花樣 | 小程式 Flex 佈局

相關文章