Java異常控制機制又被稱為“違例控制機制”。
捕獲程式錯誤最理想的時機是在編譯階段,這樣可以徹底避免錯誤的代碼運作。但并非所有的錯誤都能在編譯期間偵測到,有些問題必須在運作期間解決。
錯誤在運作期間發生時,我們可能不知道具體應該怎樣解決,但我們清楚此時不能不管不顧地繼續執行下去。此時應該做的事情是:
- 暫停程式的運作
- 指出何時、何地發生了什麼樣的錯誤
- 可能的話應處理此錯誤并恢複程式的執行
Java異常控制機制的作用流程:
- 異常産生
- 首先程式引擎需要能夠獲知異常的産生。Java中預置了一系列基本的異常條件,如數組下标越界、空指針、被零除等等,這些異常是由JVM自動産生的(也被稱為運作時異常,見後);另一部分異常則是由Java代碼(可能是JDK的代碼或開發人員自己編寫的代碼)産生的(也被稱為checked異常,見後)。
- 異常産生即是異常對象的執行個體化,該對象的類型通常就說明了異常條件的類型,執行個體化的異常對象中還會包含對異常條件的補充說明(message),以及異常發生時的線程調用棧資訊(stacktrace)。
- 在這個環節中,JAVA完成了對錯誤的描述,包括錯誤發生的時間、錯誤的類型(即異常對象的Class)、對錯誤的描述(message)和錯誤發生的位置(stacktrace)。
- 異常抛出
- 異常抛出是JAVA程式流中的一種特殊流程,當異常産生後,JVM會停止繼續執行後面的代碼,并将異常對象抛出。抛出的異常對象會進入調用棧的上一層,如果異常對象沒有被捕獲,它會沿着調用棧的順序逐層向上抛出,直至調用棧為空,此時該線程的運作也就徹底終止了。
- 異常的抛出解決了目前作用域可能不具備處理異常所需的資訊的問題,将異常對象在調用棧中逐級向上傳遞,直至有能力處理異常的作用域将其捕獲。
- 異常捕獲
- 在異常對象逐級向上抛出的過程中,如果調用棧中某一層有捕獲該類型異常的邏輯,該異常對象便會被捕捉,異常被捕獲後JVM會終止抛出異常對象的過程。
- 異常處理
- 當異常對象被捕獲後,JVM會執行捕獲後的處理邏輯(處理邏輯是由程式員編寫的)。當處理邏輯執行完成後,JVM會繼續執行捕獲了異常的作用域中接下來的代碼(除非異常處理邏輯中将該異常繼續抛出,或異常處理邏輯中産生了新的異常)。
try-catch-finally
前文所述的異常控制流程,在JAVA程式中以try-catch-finally結構實作:
- try塊也被稱為“警戒區”,try塊包裹的代碼在執行過程如果産生異常,或其調用棧的下層中産生了異常并被抛至本層,則會被與此try塊關聯的catch指令嘗試捕獲。若異常産生于警戒區之外,則會直接向上層抛出。
- catch指令後的括号内指定希望捕捉的異常對象類型(可以指定多個),如果産生或被抛至此層的異常對象是catch指定的異常類型(或其子類),則異常對象會被捕捉。上例中,所有Exception對象及其子類的對象在此處均會被捕獲。
- 被捕獲後,JVM會執行catch塊中的代碼,catch塊中的代碼能夠通路被捕捉到的異常對象(即上例中的Exception e)。
- catch塊中的代碼仍然有可能産生異常,是以也可以在catch塊中插入try-catch-finally。
- finally塊為可選塊,如果有,則無論是否有異常被抛出,JVM都會在try-catch塊執行完成後執行finally塊中的代碼。
Exception與Error
前文所述的Java異常控制機制實際上并不僅對“異常”起作用。除了我們所說的異常(Exception)能夠被産生、抛出和捕捉之外,還有另一種類型“錯誤(Error)”。
Java中,Throwable是所有可以被抛出并捕獲的類的父類。Throwable有兩大子類,分别是Exception和Error。
Java官方并沒有給出Error和Exception的嚴格定義,而是将Error描述為“應用程式不應嘗試捕捉處理的嚴重問題”,Exception則是“應用程式應該嘗試捕捉處理的問題”。
我們從幾個例子看一下:
- NoClassDefFoundError:JVM的ClassLoader在嘗試加載某個類,但該類在Classpath中并不存在時會産生的錯誤。例如a.jar依賴b.jar中的某個類,如果我們使用編譯完成的a.jar時并沒有引入b.jar,編譯器并不會發現問題(因為a.jar已經完成了編譯,需要編譯的代碼中隻使用了a.jar中的api,并沒有直接使用b.jar),但在運作時JVM找不到b.jar中被a所依賴的類,便會發生錯誤。
- UnsupportedClassVersionError:當JVM嘗試加載一個class但發現該class的版本并不被支援時産生的錯誤。例如我們使用JDK1.8開發并編譯一個類,但在JDK1.7的環境中運作時,便會發生此錯誤
- OutOfMemoryError:當JVM記憶體不足,無法為一個對象配置設定記憶體時發生的錯誤,例如堆區記憶體溢出、Perm區記憶體溢出等。
- StackOverFlowError:當程式的遞歸調用過深,導緻線程調用棧溢出時發生的錯誤。
- NoSuchFieldError/NoSuchMethodError:當JVM試圖通路某個成員屬性或某個方法時,發現目标不存在。一般都是由于class資訊在運作時被改變導緻的,多見于使用反射時。
通過上面的例子能夠看出,Error一般都與程式本身的直接關系不大,更多是由于環境導緻的問題。而且Error發生後通常程式都沒有再繼續執行下去的可能性,是以Java官方将其定義為“應用程式不應嘗試捕捉處理的嚴重問題”。
Exception的分類
Java将Exception分為兩類,checked異常和unchecked異常,也被稱為非運作時異常和運作時(runtime)異常。
RuntimeException是Exception的一個子類,RuntimeException的子類都屬于unchecked異常(也就是運作時異常),其他所有的Exception都是checked異常(也就是非運作時異常)。
這兩種異常的差別從字面上即可了解,checked代表“必須被check”,而unchecked代表“無須被check”:
Java要求checked異常必須被在代碼編寫階段就調用者了解,unchecked異常則不用。如果一個方法中有可能産生checked異常,則Java編譯器會要求該方法定義中必須加入throws定義,明确說明該方法可能會抛出某類checked異常。如下圖:
foo方法可能産生IOException(這是一種checked異常),是以bar方法在調用foo時,編譯器會提示錯誤。此時可以在bar方法的定義行中加入throws:
public void bar() throws IOException
也可以在bar方法内将IOException捕獲處理:
圖檔.png
另一個了解checked異常與unchecked異常差別的角度是:所有由JVM自動生成的異常都是unchecked異常,反之,由java程式主動生成的異常是checked異常。
例如:
上圖中f.createNewFile()方法可能會産生checked異常IOException,我們看看File類的源碼:
這裡寫圖檔描述
可以看到紅框處,IOException異常是在代碼中被主動抛出的,凡是這樣在代碼中主動抛出的異常,都是checked異常。
相應地,unchecked異常是JVM在運作時自動産生的,例如下圖的方法,隻要傳入的參數b等于0,就會在運作時自動産生ArithmeticException:
代碼中永遠不需要這樣寫:
異常處理的原則
異常處理的原則主要有三個:
- 具體明确
- 提早抛出
- 延遲捕獲
具體明确:
指抛出的異常應能通過異常類名和message準确說明異常的類型和産生異常的原因。
我們通過例子來看:
代碼1:
代碼2:
這兩段代碼的處理邏輯是類似的,均是在入參input1或input2為null或空串時抛出異常,但隻有第二段符合“具體明确”的标準:
首先,第二段代碼通過異常類型【IllegalArgumentException】明确了異常是由于傳入了不合法的參數導緻的;其次,在message中說明了具體是哪個參數不合法,為什麼不合法。這樣不僅能夠在查閱日志時快速知曉異常産生的原因,也讓上層的程式能夠針對IllegalArgumentException這一特定類型的異常進行有針對性的捕捉和處理。
相比之下,第一段代碼中抛出的異常就不夠具體明确,異常類型Exception不具有說明性質,異常message也不夠明确,上層程式難以處理,閱讀日志時也難以快速定位。
提早抛出:
指應盡可能早的發現并抛出異常,便于精确定位問題。
同樣通過例子來看:
代碼1:
代碼2:
在傳入的filename為null時,這兩段代碼都會抛出異常,第一段代碼抛出的異常是:
第二段代碼抛出的異常是:
第一段代碼抛出的異常是在标準Java類庫【InputFileStream】中抛出的,這首先就提升了問題定位的難度,不過幸好stacktrace中也列印出了前面的調用鍊,我們可以在标準類庫的調用者身上查找問題(可以定位到Test.java的第38行)。
同時NullPointerException是Java中資訊量最少的(卻也是最常遭遇且讓人崩潰的)異常。它壓根不提我們最關心的事情:到底哪裡是null。在稍微複雜一些的場景中(如一行代碼中有多處都可能導緻NullPointerException)會讓人更加崩潰。
而相比之下第二段代碼對filename提前進行了校驗,并以IllegalArgumentException的形式抛出,這樣在第一段代碼中遇到的兩個問題都可以得到解決,這便是提早抛出的好處。
延遲捕獲:
指異常的捕獲和處理應盡可能延遲,讓掌握更多資訊的作用域來處理異常。
代碼1:
上面的代碼中,readSomeFile方法将new FileInputStream處有可能産生的FileNotFoundException捕獲,并将異常資訊記錄到了日志中。
這麼做看起來似乎沒什麼問題,但readSomeFile這個方法有可能是一個通用的底層方法,會在各種業務場景下被調用,不同的業務場景下,發生FileNotFoundException時的處理政策可能不一樣(例如某些場景要求記錄異常并告警,某些場景會使用其他檔案名重試),但readSomeFile方法并不知道自己所處的業務場景是什麼樣的,這一資訊隻有更上層的作用域才了解,是以在方法内部直接捕獲并處理異常的做法就顯得有問題了,程式将無法通過甄别業務場景來執行不同的異常處理邏輯。
代碼2:
第二段代碼看起來反而更加簡單了,沒有對FileNotFoundException加以處理,而是直接在方法定義中将其抛出。然而在上面所述的場景下,這種處理方式反而是正确的。将異常抛出交由掌握了足夠多資訊的上層調用者捕獲,這樣就可以根據異常産生所處的具體業務流程來進行不同的處理。
例如我們可以在一個業務邏輯中這樣處理:
同時在另一個業務邏輯中這樣處理:
其他重要原則
- 不要讓異常逃掉
- 當一個異常在整個調用棧中的任意一層都沒有被捕獲,這個異常就“逃掉”了。這對于任何程式來說都是一個災難性的事件。
- 對于B/S系統,從請求處理線程中逃掉的異常很可能會被B/S架構(如Struts/SpringMVC等)捕捉到。如果沒有正确配置,這些逃掉的異常很可能就被架構“吃掉”了,即架構捕獲了從業務代碼層抛出的異常,且沒有記錄或沒有完整記錄異常資訊。這樣的異常來無影去無蹤,完全無迹可尋,堪稱程式員的大敵。
- 某些情況下,異常會被抛到中間件或容器(Tomcat/Jboss/Weblogic/Websphere等)層(可能是沒有使用B/S架構或B/S架構沒有“吃掉”異常)。被中間件或容器捕獲到的異常,一般情況下會被記錄在中間件或容器自己的日志中(也有可能不會記),但問題在于,這種情況下,使用者會看到中間件或容器提供的錯誤頁,這些錯誤頁基本沒有使用者友好型可言,而且有可能會把異常堆棧的資訊直接顯示在頁面上,在開放性的系統中,暴露堆棧資訊極有可能引發嚴重的安全問題。
- 而在背景程序中,如果異常逃掉了,将會導緻線程的退出。如果沒有守護線程及時補充異常退出的線程,那麼将有可能發生整個程序因為異常而中止的災難性後果。
- 是以說,在程式設計時應絕對避免異常“逃逸”的情況,對于B/S系統來說,我們可以在每個Action中都加入try-catch塊,捕獲所有Exception,也可以利用B/S架構的特性來實作從Action層抛出的異常的統一處理(如Struts2和SpringMVC都有的攔截器機制)。對于背景程序來說,可以利用try-catch塊避免異常導緻線程中止,也可以通過添加守護線程來及時補充因異常而退出的線程,同時還應使用Thread.setDefaultUncaughtExceptionHandler來確定未捕獲異常的正确記錄。
- 正确記錄異常資訊
- 即在異常的stacktrace資訊完整、未缺失的基礎上,確定異常的stacktrace被正确記錄到日志中
錯誤的做法:
上面的5種處理全都是錯誤的,前兩種将異常資訊輸出到了控制台而不是日志檔案中。後三種錯誤的使用了log4j的error方法,均沒有正确記錄異常的stacktrace
正确的方法:
注意應使用正确的error方法,傳入兩個參數,參數1是對異常的附加描述,參數2是未被篡改過的異常對象
在某些情況下,可能需要在處理異常後繼續抛出,讓上層捕獲後繼續處理,在這種情況下,需要注意抛出的異常對象未被篡改。
錯誤的:
如果像上圖這樣寫的話,下層的異常stacktrace會全部被吃掉。
正确的寫法: