Java異??刂茩C制又被稱為“違例控制機制”。
捕獲程序錯誤最理想的時機是在編譯階段,這樣可以徹底避免錯誤的代碼運行。但并非所有的錯誤都能在編譯期間偵測到,有些問題必須在運行期間解決。
錯誤在運行期間發生時,我們可能不知道具體應該怎樣解決,但我們清楚此時不能不管不顧地繼續執行下去。此時應該做的事情是:
- 暫停程序的運行
- 指出何時、何地發生了什么樣的錯誤
- 可能的話應處理此錯誤并恢復程序的執行
Java異常控制機制的作用流程:
異常產生
首先程序引擎需要能夠獲知異常的產生。Java中預置了一系列基本的異常條件,如數組下標越界、空指針、被零除等等,這些異常是由JVM自動產生的(也被稱為運行時異常,見后);另一部分異常則是由Java代碼(可能是JDK的代碼或開發人員自己編寫的代碼)產生的(也被稱為checked異常,見后)。
異常產生即是異常對象的實例化,該對象的類型通常就說明了異常條件的類型,實例化的異常對象中還會包含對異常條件的補充說明(message),以及異常發生時的線程調用棧信息(stacktrace)。
在這個環節中,JAVA完成了對錯誤的描述,包括錯誤發生的時間、錯誤的類型(即異常對象的Class)、對錯誤的描述(message)和錯誤發生的位置(stacktrace)。異常拋出
異常拋出是JAVA程序流中的一種特殊流程,當異常產生后,JVM會停止繼續執行后面的代碼,并將異常對象拋出。拋出的異常對象會進入調用棧的上一層,如果異常對象沒有被捕獲,它會沿著調用棧的順序逐層向上拋出,直至調用棧為空,此時該線程的運行也就徹底終止了。
異常的拋出解決了當前作用域可能不具備處理異常所需的信息的問題,將異常對象在調用棧中逐級向上傳遞,直至有能力處理異常的作用域將其捕獲。異常捕獲
在異常對象逐級向上拋出的過程中,如果調用棧中某一層有捕獲該類型異常的邏輯,該異常對象便會被捕捉,異常被捕獲后JVM會終止拋出異常對象的過程。異常處理
當異常對象被捕獲后,JVM會執行捕獲后的處理邏輯(處理邏輯是由程序員編寫的)。當處理邏輯執行完成后,JVM會繼續執行捕獲了異常的作用域中接下來的代碼(除非異常處理邏輯中將該異常繼續拋出,或異常處理邏輯中產生了新的異常)。
try-catch-finally
前文所述的異??刂屏鞒蹋贘AVA程序中以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異??刂茩C制實際上并不僅對“異常”起作用。除了我們所說的異常(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捕獲處理:
另一個理解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框架沒有“吃掉”異常)。被中間件或容器捕獲到的異常,一般情況下會被記錄在中間件或容器自己的日志中(也有可能不會記),但問題在于,這種情況下,用戶會看到中間件或容器提供的錯誤頁,這些錯誤頁基本沒有用戶友好型可言,而且有可能會把異常堆棧的信息直接顯示在頁面上,在開放性的系統中,暴露堆棧信息極有可能引發嚴重的安全問題。
而在后臺進程中,如果異常逃掉了,將會導致線程的退出。如果沒有守護線程及時補充異常退出的線程,那么將有可能發生整個進程因為異常而中止的災難性后果。
所以說,在編程時應絕對避免異?!疤右荨钡那闆r,對于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會全部被吃掉。
正確的寫法: