Java 核心知識體系:深入分析異常機制
1 什麼是異常
異常是指程序在運行過程中發生的,由於外部問題導致的運行異常事件,如:網絡連接失敗、文件找不到、非法參數、未將對象引用設置到對象的實例(空指針)等。異常是一個事件行爲,在程序運行期間觸發,並打斷程序的運行。
Java 作爲面向對象的編程語言,它的異常是 Throwable 子類的對象的實例,當程序存在不健全的條件,且條件都滿足的時候,就會觸發錯誤並出現異常。
2 異常的分類
從 Java 異常類的整體層次結構,可以看出異常的具體分類:
2.1 異常拋出類型(Throwable)
在 Java 語言中,Throwable 類 是所有的錯誤與異常的最頂層超類,其他的異常類都繼承於該父類。它包含兩個子類:Error(錯誤)和 Exception(異常),用於表達發生異常情況的類型。Throwable 包含了其線程創建時線程執行堆棧的快照,並提供了 printStackTrace() 等接口用於獲取堆棧跟蹤數據等信息。
2.2 錯誤類型(Error)
Error 類及其子類:程序中無法處理的錯誤,表示運行應用程序中出現了嚴重的錯誤。通常情況爲應用程序 ,"不應該被捕獲或者處理的驗證異常"。
此類錯誤一般表示代碼運行時 JVM 出現問題。通常有 Virtual MachineError(虛擬機運行錯誤)、NoClassDefFoundError(類定義錯誤)等。比如 OutOfMemoryError:內存不足錯誤;StackOverflowError:棧溢出錯誤。這些錯誤是不可查的,因爲它們在應用程序的控制和處理能力之 外,而且絕大多數是程序運行時不允許出現的狀況。在 Java 中,錯誤通過 Error 的子類描述。
2.3 異常類型(Exception)
Exception 以及它的子類,代表程序運行時發送的各種不期望發生的事件。可以被 Java 異常處理機制使用,是異常處理的核心。Exception 這種異常又分爲兩類:運行時異常和編譯時異常。
2.3.1 運行時異常
都是 RuntimeException 類及其子類異常,如 NullPointerException(空指針異常)、IndexOutOfBoundsException(下標越界異常) 等,這些異常是不檢查異常,程序中可以選擇捕獲處理,也可以不處理。這些異常一般是由程序邏輯錯誤引起的,程序應該從邏輯角度儘可能避免這類異常的發生。運行時異常的特點是 Java 編譯器不會檢查它,也就是說,當程序中可能出現這類異常,即使沒有用 try-catch 語句捕獲它,也沒有用 throws 子句聲明拋出它,也會編譯通過。
2.3.2 非運行時異常 (編譯異常)
是 RuntimeException 以外的異常,類型上都屬於 Exception 類及其子類。從程序語法角度講是必須進行處理的異常,如果不處理,程序就不能編譯通過。如 IOException、SQLException 等以及用戶自定義的 Exception 異常,一般情況下不自定義檢查異常。
2.3.3 檢查性異常(checked exception)
正確的程序在運行中,很容易出現的、情理可容的異常狀況。可查異常雖然是異常狀況,但在一定程度上它的發生是可以預計的,而且一旦發生這種異常狀況,就必須採取某種方式進行處理。
除了 Error 和 RuntimeException 的其它異常。Java 語言強制要求程序員爲這樣的異常做預備處理工作(使用 try…catch…finally 或者 throws)。在方法中要麼用 try-catch 語句捕獲它並處理,要麼用 throws 子句聲明拋出它,否則編譯不會通過。類似如 SQLException,IOException,ClassNotFoundException 等。
常見的檢查性異常如下:
2.3.4 非檢查性異常 (checked exception)
包括運行時異常(RuntimeException 與其子類)和錯誤(Error)及其子類。
Java 語言在編譯時,不會提示和發現這樣的異常,不要求在程序中處理這些異常。所以我們可以在程序中編寫代碼來處理(使用 try…catch…finally)這樣的異常,也可以不做任何處理。
但是這種錯誤或異常,一般來說是程序邏輯錯誤導致的異常,所以我們應該修正代碼,而不是通過異常處理器處理。
常見的非檢查性異常如下:
3 異常基礎詳解
3.1 異常關鍵字
-
try – 用於監聽。
將要被監聽的代碼 (可能拋出異常的代碼) 放在 try 語句塊之內,當 try 語句塊內發生異常時,異常就被拋出。 -
catch – 用於捕獲異常。
catch 用來捕獲 try 語句塊中發生的異常。 -
finally – finally 語句塊總是會被執行。
它主要用於回收在 try 塊裏打開的物力資源 (如數據庫連接、網絡連接和磁盤文件)。只有 finally 塊,執行完成之後,纔會回來執行 try 或者 catch 塊中的 return 或者 throw 語句,如果 finally 中使用了 return 或者 throw 等終止方法的語句,則就不會跳回執行,直接停止。 -
throw – 用於拋出異常。
-
throws – 用在方法簽名中,用於聲明該方法可能拋出的異常。
3.2 throws - 異常的顯示聲明
在 Java 中,當前執行的語句必屬於某個方法,Java 解釋器調用 main 方法執行開始執行程序。若方法中存在檢查異常,如果不對其捕獲,那必須在方法頭中顯式聲明該異常,以便於告知方法調用者此方法有異常,需要進行處理。在方法中聲明一個異常,方法頭中使用關鍵字 throws,後面接上要聲明的異常。若聲明多個異常,則使用逗號分割。如下所示:
public static void yourMethod() throws Exception{ //todo 業務邏輯}
注意:若是父類的方法沒有聲明異常,則子類繼承方法後,也不能聲明異常。
通常,應該捕獲那些知道如何處理的異常,將不知道如何處理的異常繼續傳遞下去。傳遞異常可以在方法簽名處使用 throws 關鍵字聲明可能會拋出的異常。
private static void readFile(String filePath) throws IOException { File file = new File(filePath);
String result; BufferedReader reader = new BufferedReader(new FileReader(file)); while((result = reader.readLine())!=null) {
System.out.println(result);
}
reader.close();
}
Throws 拋出異常的規則:
-
如果是不可查異常(unchecked exception),即 Error、RuntimeException 或它們的子類,那麼可以不使用 throws 關鍵字來聲明要拋出的異常,編譯仍能順利通過,但在運行時會被系統拋出。
-
必須聲明方法可拋出的任何可查異常(checked exception)。即如果一個方法可能出現受可查異常,要麼用 try-catch 語句捕獲,要麼用 throws 子句聲明將它拋出,否則會導致編譯錯誤
-
僅當拋出了異常,該方法的調用者才必須處理或者重新拋出該異常。當方法的調用者無力處理該異常的時候,應該繼續拋出,而不是囫圇吞棗。
-
調用方法必須遵循任何可查異常的處理和聲明規則。若覆蓋一個方法,則不能聲明與覆蓋方法不同的異常。聲明的任何異常必須是被覆蓋方法所聲明異常的同類或子類。
3.3 throw - 拋出異常
如果代碼可能會引發某種錯誤,可以創建一個合適的異常類實例並拋出它,這就是拋出異常。如下所示:
public static double yourMethod(int value) { if(value < 0) { throw new ArithmeticException("參數不能爲0"); //拋出一個運行時異常
} return 6.0 / value;
}
大部分情況下都不需要手動拋出異常,因爲 Java 的大部分方法要麼已經處理異常,要麼已聲明異常。所以一般都是捕獲異常或者再往上拋。
有時我們會從 catch 中拋出一個異常,目的是爲了改變異常的類型。多用於在多系統集成時,當某個子系統故障,異常類型可能有多種,可以用統一的異常類型向外暴露,不需暴露太多內部異常細節。
private static void readFile(String filePath) throws MyException {
try { // code
} catch (IOException e) { MyException ex = new MyException("read file failed.");
ex.initCause(e); throw ex;
}
}
3.4 異常的自定義
習慣上,定義一個異常類應包含兩個構造函數,一個無參構造函數和一個帶有詳細描述信息的構造函數(Throwable 的 toString 方法會打印這些詳細信息,調試時很有用), 比如上面用到的自定義
MyException:
public class MyException extends Exception {
public MyException(){ }
public MyException(String msg){ super(msg);
} // ...}
3.5 異常的捕獲
異常捕獲處理的方法通常有:
-
try-catch
-
try-catch-finally
-
try-finally
-
try-with-resource
3.5.1 try-catch
在一個 try-catch 語句塊中可以捕獲多個異常類型,並對不同類型的異常做出不同的處理
private static void readFile(String filePath) { try { // code
} catch (FileNotFoundException e) { // handle FileNotFoundException
} catch (IOException e){ // handle IOException
}
}
同一個 catch 也可以捕獲多種類型異常,用 | 隔開
private static void readFile(String filePath) { try { // code
} catch (FileNotFoundException | UnknownHostException e) { // handle FileNotFoundException or UnknownHostException
} catch (IOException e){ // handle IOException
}
}
3.5.2 try-catch-finally
- 常規語法
try {
//執行程序代碼,可能會出現異常 } catch(Exception e) {
//捕獲異常並處理 } finally { //必執行的代碼}
-
執行的順序
-
當 try 沒有捕獲到異常時:try 語句塊中的語句逐一被執行,程序將跳過 catch 語句塊,執行 finally 語句塊和其後的語句;
-
當 try 捕獲到異常,catch 語句塊裏沒有處理此異常的情況:當 try 語句塊裏的某條語句出現異常時,而沒有處理此異常的 catch 語句塊時,此異常將會拋給 JVM 處理,finally 語句塊裏的語句還是會被執行,但 finally 語句塊後的語句不會被執行;
-
當 try 捕獲到異常,catch 語句塊裏有處理此異常的情況:在 try 語句塊中是按照順序來執行的,當執行到某一條語句出現異常時,程序將跳到 catch 語句塊,並與 catch 語句塊逐一匹配,找到與之對應的處理程序,其他的 catch 語句塊將不會被執行,而 try 語句塊中,出現異常之後的語句也不會被執行,catch 語句塊執行完後,執行 finally 語句塊裏的語句,最後執行 finally 語句塊後的語句;
-
無異常情況 ,catch 模塊被忽略,先執行業務邏輯,再執行 finally。
-
異常情況,假設執行到業務邏輯 2 的時候,出現故障異常,則業務邏輯 3 沒有執行,直接執行 catch,最後再執行 finally。
-
一個完整的例子
private static void readFile(String filePath) throws MyException { File file = new File(filePath);
String result; BufferedReader reader = null; try {
reader = new BufferedReader(new FileReader(file)); while((result = reader.readLine())!=null) {
System.out.println(result);
}
} catch (IOException e) {
System.out.println("readFile method catch block."); MyException ex = new MyException("read file failed.");
ex.initCause(e); throw ex;
} finally {
System.out.println("readFile method finally block."); if (null != reader) { try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
3.5.3 try-finally
也可以直接用 try-finally。try 塊中引起異常,異常代碼之後的語句不再執行,直接執行 finally 語句。
try 塊沒有引發異常,則執行完 try 塊就執行 finally 語句。try-finally 可用在不需要捕獲異常的代碼,可以保證資源在使用後被關閉。例如 IO 流中執行完相應操作後,關閉相應資源;使用 Lock 對象保證線程同步,通過 finally 可以保證鎖會被釋放;數據庫連接代碼時,關閉連接操作等等。
//以Lock加鎖爲例,演示try-finallyReentrantLock lock = new ReentrantLock();try { //需要加鎖的代碼} finally { lock.unlock(); //保證鎖一定被釋放}
finally 遇見如下情況不會執行
-
在前面的代碼中用了 System.exit() 退出程序。
-
finally 語句塊中發生了異常。
-
程序所在的線程死亡。
-
關閉 CPU。
3.5.4 try-with-resource
上面例子中,finally 中的 close 方法也可能拋出 IOException, 從而覆蓋了原始異常。JAVA 7 提供了更優雅的方式來實現資源的自動釋放,自動釋放的資源需要是實現了 AutoCloseable 接口的類。
- 代碼實現
private static void tryWithResourceTest(){ try (Scanner scanner = new Scanner(new FileInputStream("c:/abc"),"UTF-8")){ // code
} catch (IOException e){ // handle exception
}
}
- 看下 Scanner
public final class Scanner implements Iterator<String>, Closeable { // ...}public interface Closeable extends AutoCloseable { public void close() throws IOException;
}
try 代碼塊退出時,會自動調用 scanner.close 方法,和把 scanner.close 方法放在 finally 代碼塊中不同的是,若 scanner.close 拋出異常,則會被抑制,拋出的仍然爲原始異常。被抑制的異常會由 addSusppressed 方法添加到原來的異常,如果想要獲取被抑制的異常列表,可以調用 getSuppressed 方法來獲取。
3.6 異常總結
-
try、catch 和 finally 都不能單獨使用,只能是 try-catch、try-finally 或者 try-catch-finally。
-
try 語句塊監控代碼,出現異常就停止執行下面的代碼,然後將異常移交給 catch 語句塊來處理。
-
finally 語句塊中的代碼一定會被執行,常用於回收資源 。
-
throws:聲明一個異常,告知方法調用者。
-
throw :拋出一個異常,至於該異常被捕獲還是繼續拋出都與它無關。
Java 編程思想一書中,對異常的總結。 -
在恰當的級別處理問題。(在知道該如何處理的情況下捕獲異常。)
-
解決問題並且重新調用產生異常的方法。
-
進行少許修補,然後繞過異常發生的地方繼續執行。
-
用別的數據進行計算,以代替方法預計會返回的值。
-
把當前運行環境下能做的事儘量做完,然後把相同的異常重拋到更高層。
-
把當前運行環境下能做的事儘量做完,然後把不同的異常拋到更高層。
-
終止程序。
-
進行簡化(如果你的異常模式使問題變得太複雜,那麼用起來會非常痛苦)。
-
讓類庫和程序更安全。
3.7 常用的異常
在 Java 中提供了一些異常用來描述經常發生的錯誤,對於這些異常,有的需要程序員進行捕獲處理或聲明拋出,有的是由 Java 虛擬機自動進行捕獲處理。Java 中常見的異常類:
-
RuntimeException
-
java.lang.ArrayIndexOutOfBoundsException 數組索引越界異常。當對數組的索引值爲負數或大於等於數組大小時拋出。
-
java.lang.ArithmeticException 算術條件異常。譬如:整數除零等。
-
java.lang.NullPointerException 空指針異常。當應用試圖在要求使用對象的地方使用了 null 時,拋出該異常。譬如:調用 null 對象的實例方法、訪問 null 對象的屬性、計算 null 對象的長度、使用 throw 語句拋出 null 等等
-
java.lang.ClassNotFoundException 找不到類異常。當應用試圖根據字符串形式的類名構造類,而在遍歷 CLASSPAH 之後找不到對應名稱的 class 文件時,拋出該異常。
-
java.lang.NegativeArraySizeException 數組長度爲負異常
-
java.lang.ArrayStoreException 數組中包含不兼容的值拋出的異常
-
java.lang.SecurityException 安全性異常
-
java.lang.IllegalArgumentException 非法參數異常
-
IOException
-
IOException:操作輸入流和輸出流時可能出現的異常。
-
EOFException 文件已結束異常
-
FileNotFoundException 文件未找到異常
-
其他
-
ClassCastException 類型轉換異常類
-
ArrayStoreException 數組中包含不兼容的值拋出的異常
-
SQLException 操作數據庫異常類
-
NoSuchFieldException 字段未找到異常
-
NoSuchMethodException 方法未找到拋出的異常
-
NumberFormatException 字符串轉換爲數字拋出的異常
-
StringIndexOutOfBoundsException 字符串索引超出範圍拋出的異常
-
IllegalAccessException 不允許訪問某類異常
-
InstantiationException 當應用程序試圖使用 Class 類中的 newInstance() 方法創建一個類的實例,而指定的類對象無法被實例化時,拋出該異常
4 異常實踐
當你拋出或捕獲異常的時候,有很多不同的情況需要考慮,而且大部分事情都是爲了改善代碼的可讀性或者 API 的可用性。
異常不僅僅是一個錯誤控制機制,也是一個通信媒介。因此,爲了和同事更好的合作,一個團隊必須要制定出一個最佳實踐和規則,只有這樣,團隊成員才能理解這些通用概念,同時在工作中使用它。
這裏給出幾個被很多團隊使用的異常處理最佳實踐。
4.1 只針對不正常的情況才使用異常
異常只應該被用於不正常的條件,它們永遠不應該被用於正常的控制流。《阿里手冊》中:【強制】Java 類庫中定義的可以通過預檢查方式規避的 RuntimeException 異常不應該通過 catch 的方式來處理,比如:NullPointerException,IndexOutOfBoundsException 等等。
比如,在解析字符串形式的數字時,可能存在數字格式錯誤,不得通過 catch Exception 來實現
- 代碼 1
if (obj != null) { //...}
- 代碼 2
try {
obj.method();
} catch (NullPointerException e) { //...}
主要原因有三點:
-
異常機制的設計初衷是用於不正常的情況,所以很少會會 JVM 實現試圖對它們的性能進行優化。所以,創建、拋出和捕獲異常的開銷是很昂貴的。
-
把代碼放在 try-catch 中返回阻止了 JVM 實現本來可能要執行的某些特定的優化。
-
對數組進行遍歷的標準模式並不會導致冗餘的檢查,有些現代的 JVM 實現會將它們優化掉。
4.2 在 finally 塊中清理資源或者使用 try-with-resource 語句
當使用類似 InputStream 這種需要使用後關閉的資源時,一個常見的錯誤就是在 try 塊的最後關閉資源。
- 錯誤示例
public void doNotCloseResourceInTry() { FileInputStream inputStream = null; try { File file = new File("./tmp.txt");
inputStream = new FileInputStream(file); // use the inputStream to read a file
// do NOT do this
inputStream.close();
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
}
問題就是,只有沒有異常拋出的時候,這段代碼纔可以正常工作。try 代碼塊內代碼會正常執行,並且資源可以正常關閉。但是,使用 try 代碼塊是有原因的,一般調用一個或多個可能拋出異常的方法,而且,你自己也可能會拋出一個異常,這意味着代碼可能不會執行到 try 代碼塊的最後部分。結果就是,你並沒有關閉資源。
所以,你應該把清理工作的代碼放到 finally 裏去,或者使用 try-with-resource 特性。
- 方法一:使用 finally 代碼塊
與前面幾行 try 代碼塊不同,finally 代碼塊總是會被執行。不管 try 代碼塊成功執行之後還是你在 catch 代碼塊中處理完異常後都會執行。因此,你可以確保你清理了所有打開的資源。
public void closeResourceInFinally() { FileInputStream inputStream = null; try { File file = new File("./tmp.txt");
inputStream = new FileInputStream(file); // use the inputStream to read a file
} catch (FileNotFoundException e) {
log.error(e);
} finally { if (inputStream != null) { try {
inputStream.close();
} catch (IOException e) {
log.error(e);
}
}
}
}
- 方法二:Java 7 的 try-with-resource 語法
如果你的資源實現了 AutoCloseable 接口,你可以使用這個語法。大多數的 Java 標準資源都繼承了這個接口。當你在 try 子句中打開資源,資源會在 try 代碼塊執行後或異常處理後自動關閉。
public void automaticallyCloseResource() { File file = new File("./tmp.txt"); try (FileInputStream inputStream = new FileInputStream(file);) { // use the inputStream to read a file
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
}
4.3 儘量使用標準的異常
重用現有的異常有幾個好處:
-
它使得你的 API 更加易於學習和使用,因爲它與程序員原來已經熟悉的習慣用法是一致的。
-
對於用到這些 API 的程序而言,它們的可讀性更好,因爲它們不會充斥着程序員不熟悉的異常。
-
異常類越少,意味着內存佔用越小,並且轉載這些類的時間開銷也越小。
Java 標準異常中有幾個是經常被使用的異常。如下表格:
雖然它們是 Java 平臺庫迄今爲止最常被重用的異常,但是,在許可的條件下,其它的異常也可以被重用。例如,如果你要實現諸如複數或者矩陣之類的算術對象,那麼重用 ArithmeticException 和 NumberFormatException 將是非常合適的。如果一個異常滿足你的需要,則不要猶豫,使用就可以,不過你一定要確保拋出異常的條件與該異常的文檔中描述的條件一致。這種重用必須建立在語義的基礎上,而不是名字的基礎上。
最後,一定要清楚,選擇重用哪一種異常並沒有必須遵循的規則。例如,考慮紙牌對象的情形,假設有一個用於發牌操作的方法,它的參數 (handSize) 是發一手牌的紙牌張數。假設調用者在這個參數中傳遞的值大於整副牌的剩餘張數。那麼這種情形既可以被解釋爲 IllegalArgumentException(handSize 的值太大),也可以被解釋爲 IllegalStateException(相對客戶的請求而言,紙牌對象的紙牌太少)。
4.4 對異常進行文檔說明
當在方法上聲明拋出異常時,也需要進行文檔說明。目的是爲了給調用者提供儘可能多的信息,從而可以更好地避免或處理異常。
在 Javadoc 添加 @throws 聲明,並且描述拋出異常的場景。
/**
* Method description
*
* @throws MyBusinessException - businuess exception description
*/public void doSomething(String input) throws MyBusinessException { // ...}
同時,在拋出 MyBusinessException 異常時,需要儘可能精確地描述問題和相關信息,這樣無論是打印到日誌中還是在監控工具中,都能夠更容易被人閱讀,從而可以更好地定位具體錯誤信息、錯誤的嚴重程度等。
4.5 優先捕獲最具體的異常
大多數 IDE 都可以幫助你實現這個最佳實踐。當你嘗試首先捕獲較不具體的異常時,它們會報告無法訪問的代碼塊。
但問題在於,只有匹配異常的第一個 catch 塊會被執行。因此,如果首先捕獲 IllegalArgumentException ,則永遠不會到達應該處理更具體的 NumberFormatException 的 catch 塊,因爲它是 IllegalArgumentException 的子類。
總是優先捕獲最具體的異常類,並將不太具體的 catch 塊添加到列表的末尾。
你可以在下面的代碼片斷中看到這樣一個 try-catch 語句的例子。第一個 catch 塊處理所有 NumberFormatException 異常,第二個處理所有非 NumberFormatException 異常的 IllegalArgumentException 異常。
public void catchMostSpecificExceptionFirst() { try {
doSomething("A message");
} catch (NumberFormatException e) {
log.error(e);
} catch (IllegalArgumentException e) {
log.error(e)
}
}
4.6 不讓異常影響程序的流程
不使用異常控制程序原本的執行流程,更多的試試用程序本身的判斷,比如 while、if,避免因爲異常的執行影響程序原本的性能。
4.7 不要在 finally 塊中使用 return。
try 塊中的 return 語句執行成功後,並不馬上返回,而是繼續執行 finally 塊中的語句,如果此處存在 return 語句,則在此直接返回,無情丟棄掉 try 塊中的返回點。
如下是一個反例:
private int x = 0;public int checkReturn() { try { // x等於1,此處不返回
return ++x;
} finally { // 返回的結果是2
return ++x;
}
}
5 總結
這邊詳細介紹了異常的概念、原理,以及在應用中的一些小結。異常的能力是我們快速定位程序錯誤的重要手段之一,也是我們不斷優化程序,提高程序健壯性的依據,所以熟練掌握異常的使用是非常有必要的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/JEjycV8kTu2A2W2A_q_WhA