06 JVM 是如何處理異常的
在 JAVA 中,異常處理的方式主要是丟擲異常和捕獲異常。這兩大要素共同實現程式控制流的非正常轉移。
丟擲異常可以分為顯示和隱式兩種。顯示丟擲異常的主體是應用程式,它指的是在程式中使用 throw 關鍵字,手動將異常例項丟擲。隱式丟擲異常的主題是 Java 虛擬機器,它指的是 Java 虛擬機器在執行過程中,碰到無法繼續執行的異常狀態,自動丟擲異常。例如陣列越界異常。
捕獲異常主要設計一下三種程式碼塊:
1:try 程式碼塊,用來標記需要進行異常監控的程式碼。
2:catch 程式碼塊,跟在 try 程式碼塊之後,用來捕獲在 try 程式碼塊中觸發的某種指定型別的異常。除了宣告所捕獲異常的型別之外,catch 程式碼塊還定義了針對該異常型別的異常處理。在 Java 中,try 程式碼塊後面可以跟著多個 catch 程式碼塊,來捕獲不同型別的異常。Java 虛擬機器會自上而下匹配異常處理器,所以前面的 catch 程式碼塊不能覆蓋後面的,否則編譯器報錯。
3:finally 程式碼塊,跟在 catch 程式碼塊之後,用來宣告一段必定執行的程式碼。它的設計是為了避免跳過某些關鍵的清理程式碼。比如關閉已經開啟的系統資源。
在程式正常執行情況下, finally 程式碼塊會在 try 程式碼塊之後執行。
如果 try 程式碼塊觸發異常,異常沒有被捕獲的情況下,finally 程式碼塊會直接執行,並在執行結束後重新丟擲異常。如果該異常被 catch 程式碼塊捕獲,finally 程式碼塊則會在 catch 程式碼塊之後執行。在某些情況下,catch 程式碼塊也觸發了異常,那麼 finally 程式碼同樣會執行,並丟擲 catch 程式碼塊觸發的異常。如果 finally 程式碼塊也觸發了異常,那就中斷 finally 程式碼塊,向上丟擲異常。
異常的基本概念
在 Java 語言規範中,所有異常都是 Throwable 類或者其子類的實現。
Throwable 類有兩大直接子類。一個是 Error,涵蓋程式不應捕獲的異常。當程式觸發 Error 的時候,它的執行狀態已經無法會發,需要終止執行緒甚至是終止虛擬機器。一個是 Exception ,涵蓋程式可能需要捕獲並且處理的異常。
RuntimeException 是 Exception 的一個特殊子類,用來表示程式無法繼續執行,但是還能搶救一下的情況,陣列越界便是其中一種。
RuntimeException 和 Error 屬於 Java 裡的非檢查異常。其他異常則屬於檢查異常。在Java 語法中,所有的檢查異常都需要程式顯示地捕獲,或者在方法宣告中用 throws 關鍵字標註。通常情況下,程式自定義的異常應為檢查異常,以便最大化利用 Java 編譯器的編譯時檢查。
異常例項的構造十分昂貴。在構造異常例項時,Java 虛擬機器需要生成該異常的棧軌跡。該曹組會逐一訪問當前執行緒的 Java 棧幀,並且記錄下各種除錯資訊,包括棧幀所指向的方法的名字,方法所在的類名,檔名,以及在程式碼中的第幾行觸發該異常。在生成棧軌跡時,Java 虛擬機器會忽略掉異常構造器以及填充棧幀的 Java 方法,直接從新建異常位置開始算起。此外,Java 虛擬機器還會忽略標記為不可見的 Java 方法棧幀。
異常例項的構造昂貴,但是卻沒有做快取優化。如果做了快取優化,那麼丟擲的異常例項對應的棧軌跡並非 throw 語句的位置了,而是第一次新建異常的位置。所以,為了準確的定位到錯誤的位置,我們往往選擇丟擲新建異常例項。
Java 虛擬機器是如何捕獲異常的
在編譯生成的位元組碼中,每個方法都附帶一個異常表,異常表中的每一個條目代表一個異常處理器,並且由 form 指標,to 指標,target 指標以及所捕獲的異常型別構成。這些指標的值是位元組碼索引,用來定位位元組碼。
from 指標和 to 指標標示了該異常所監控的範圍:try 程式碼塊所覆蓋的範圍。
target 指標標示了異常處理器的起始位置:catch 程式碼塊的起始位置。
當程式處罰異常時,Java 虛擬機器會從上至下遍歷異常表中的所有條目。當觸發異常的位元組碼索引值在某個異常表條目的監控範圍內,Java 虛擬機器再判斷所丟擲的異常和該條目想要捕獲的異常是否匹配。如果匹配,Java 虛擬機器會將控制流轉移至該條目 target 指標指向的位元組碼。
如果遍歷完異常表的條目未曾匹配到異常處理器,那麼它會彈出當前方法對應的 Java 棧幀,並且在呼叫者中重複上述操作。
finally 程式碼塊的編譯比較複雜。當前版本 Java 編譯器的做法:複製 finally 程式碼塊內容,分別放在 try-catch 程式碼塊所有正常執行路徑以及異常執行路徑的出口中。
針對異常執行路徑,Java 編譯器會生成一個(上圖變種2)或者多個(上圖變種1)異常表條目,監控整個 try-catch 程式碼塊,並且捕獲所有種類的異常。這些異常表條目的 target 指標將指向另一份複製的 finally 程式碼塊(上圖變種1,變種2 中紅色 finally block),並且重新丟擲捕獲的異常。
問題:如果 catch 程式碼塊捕獲了異常,並且觸發了另一個異常,那麼 finally 捕獲並且重拋的異常是 catch 程式碼塊觸發的新的異常,原本的異常就被忽略了。這對程式碼除錯來說,就不友好了。
Java 7 中引出了 Supressed 異常來解決上面的問題。這個新特性允許開發人員將一個異常附在另一個異常上,這樣丟擲的異常就可以附帶多個異常的資訊。
問答
Q:為什麼使用異常捕獲的程式碼比較耗費效能
單從 Java 語法上看不出來,但是從 JVM 實現的細節上來看就明白了。構造異常例項,需要生成該異常的棧軌跡。該操作會逐一訪問當前執行緒的棧幀,記錄各種除錯資訊,包括類名,方法名,觸發異常的程式碼行數等等。
Q:finally 是怎麼實現無論異常與否都能執行
編譯器在編譯程式碼時會複製 finally 程式碼塊放在 try-catch 程式碼塊所有正常執行路徑以及異常執行路徑的出口處。
Q:finally 中有 ruturn 語句,catch 中丟擲的異常會被忽略,為什麼
catch 丟擲的異常會被 finally 捕獲,執行完 finally 後會重新丟擲該異常。由於 finally 中有 return 語句,在重新丟擲異常之前,程式碼就已經返回了。
Q:方法的異常表都包含哪些異常
方法的異常表只宣告這段程式碼會被捕獲的異常,而且是非檢查異常。如果 catch 中有自定義異常,那麼異常表中也會包含自定義異常的條目。
Q:檢查異常和非檢查異常也就是其他書籍中說的編譯期異常和執行時異常?
檢查異常也會在執行過程中丟擲。但是它會要求編譯器檢查程式碼有沒有顯式地處理該異常。非檢查異常包括Error和RuntimeException,這兩個則不要求編譯器顯式處理。
總結
本文創作靈感來源於 極客時間 鄭雨迪老師的《深入拆解 Java 虛擬機器》課程,通過課後反思以及借鑑各位學友的發言總結,現整理出自己的知識架構,以便日後溫故知新,查漏補缺。
關注本人公眾號,第一時間獲取最新文章釋出,每日更新一篇技術文章。