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 等。
常見的檢查性異常如下:

77afiI

2.3.4 非檢查性異常 (checked exception)

包括運行時異常(RuntimeException 與其子類)和錯誤(Error)及其子類。
Java 語言在編譯時,不會提示和發現這樣的異常,不要求在程序中處理這些異常。所以我們可以在程序中編寫代碼來處理(使用 try…catch…finally)這樣的異常,也可以不做任何處理。
但是這種錯誤或異常,一般來說是程序邏輯錯誤導致的異常,所以我們應該修正代碼,而不是通過異常處理器處理。
常見的非檢查性異常如下:

x7GnGE

3 異常基礎詳解

3.1 異常關鍵字

  1. try – 用於監聽。
    將要被監聽的代碼 (可能拋出異常的代碼) 放在 try 語句塊之內,當 try 語句塊內發生異常時,異常就被拋出。

  2. catch – 用於捕獲異常。
    catch 用來捕獲 try 語句塊中發生的異常。

  3. finally – finally 語句塊總是會被執行。
    它主要用於回收在 try 塊裏打開的物力資源 (如數據庫連接、網絡連接和磁盤文件)。只有 finally 塊,執行完成之後,纔會回來執行 try 或者 catch 塊中的 return 或者 throw 語句,如果 finally 中使用了 return 或者 throw 等終止方法的語句,則就不會跳回執行,直接停止。

  4. throw – 用於拋出異常。

  5. 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 拋出異常的規則:

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 異常的捕獲

異常捕獲處理的方法通常有:

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 {    //必執行的代碼}
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 遇見如下情況不會執行

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
    }
}
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 異常總結

3.7 常用的異常

在 Java 中提供了一些異常用來描述經常發生的錯誤,對於這些異常,有的需要程序員進行捕獲處理或聲明拋出,有的是由 Java 虛擬機自動進行捕獲處理。Java 中常見的異常類:

4 異常實踐

當你拋出或捕獲異常的時候,有很多不同的情況需要考慮,而且大部分事情都是爲了改善代碼的可讀性或者 API 的可用性。
異常不僅僅是一個錯誤控制機制,也是一個通信媒介。因此,爲了和同事更好的合作,一個團隊必須要制定出一個最佳實踐和規則,只有這樣,團隊成員才能理解這些通用概念,同時在工作中使用它。
這裏給出幾個被很多團隊使用的異常處理最佳實踐。

4.1 只針對不正常的情況才使用異常

異常只應該被用於不正常的條件,它們永遠不應該被用於正常的控制流。《阿里手冊》中:【強制】Java 類庫中定義的可以通過預檢查方式規避的 RuntimeException 異常不應該通過 catch 的方式來處理,比如:NullPointerException,IndexOutOfBoundsException 等等。

比如,在解析字符串形式的數字時,可能存在數字格式錯誤,不得通過 catch Exception 來實現

if (obj != null) {  //...}
try { 
  obj.method(); 
} catch (NullPointerException e) {  //...}

主要原因有三點:

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 特性。

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);
            }
        }
    }
}
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 儘量使用標準的異常

重用現有的異常有幾個好處:

FbeWWU

雖然它們是 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