1. 程式人生 > 其它 >Java基礎 - 異常詳解

Java基礎 - 異常詳解

異常的層次結構

Throwable

Throwable 是 Java 語言中所有錯誤與異常的超類。

Throwable 包含兩個子類:Error(錯誤)和 Exception(異常),它們通常用於指示發生了異常情況。

Throwable 包含了其執行緒建立時執行緒執行堆疊的快照,它提供了 printStackTrace() 等介面用於獲取堆疊跟蹤資料等資訊。

Error(錯誤)

Error 類及其子類:程式中無法處理的錯誤,表示執行應用程式中出現了嚴重的錯誤。

此類錯誤一般表示程式碼執行時 JVM 出現問題。通常有 Virtual MachineError(虛擬機器執行錯誤)、NoClassDefFoundError(類定義錯誤)等。比如 OutOfMemoryError:記憶體不足錯誤;StackOverflowError:棧溢位錯誤。此類錯誤發生時,JVM 將終止執行緒。

這些錯誤是不受檢異常,非程式碼性錯誤。因此,當此類錯誤發生時,應用程式不應該去處理此類錯誤。按照Java慣例,我們是不應該實現任何新的Error子類的!

Exception(異常)

程式本身可以捕獲並且可以處理的異常。Exception 這種異常又分為兩類:執行時異常和編譯時異常。

  • 執行時異常

都是RuntimeException類及其子類異常,如NullPointerException(空指標異常)、IndexOutOfBoundsException(下標越界異常)等,這些異常是不檢查異常,程式中可以選擇捕獲處理,也可以不處理。這些異常一般是由程式邏輯錯誤引起的,程式應該從邏輯角度儘可能避免這類異常的發生。

執行時異常的特點是Java編譯器不會檢查它,也就是說,當程式中可能出現這類異常,即使沒有用try-catch語句捕獲它,也沒有用throws子句宣告丟擲它,也會編譯通過。

  • 非執行時異常 (編譯異常)

是RuntimeException以外的異常,型別上都屬於Exception類及其子類。從程式語法角度講是必須進行處理的異常,如果不處理,程式就不能編譯通過。如IOException、SQLException等以及使用者自定義的Exception異常,一般情況下不自定義檢查異常。

可查的異常(checked exceptions)和不可查的異常(unchecked exceptions)

  • 可查異常
    (編譯器要求必須處置的異常):

正確的程式在執行中,很容易出現的、情理可容的異常狀況。可查異常雖然是異常狀況,但在一定程度上它的發生是可以預計的,而且一旦發生這種異常狀況,就必須採取某種方式進行處理。

除了RuntimeException及其子類以外,其他的Exception類及其子類都屬於可查異常。這種異常的特點是Java編譯器會檢查它,也就是說,當程式中可能出現這類異常,要麼用try-catch語句捕獲它,要麼用throws子句宣告丟擲它,否則編譯不會通過。

  • 不可查異常(編譯器不要求強制處置的異常)

包括執行時異常(RuntimeException與其子類)和錯誤(Error)。

異常基礎

異常關鍵字

  • try – 用於監聽。將要被監聽的程式碼(可能丟擲異常的程式碼)放在try語句塊之內,當try語句塊內發生異常時,異常就被丟擲。
  • catch – 用於捕獲異常。catch用來捕獲try語句塊中發生的異常。
  • finally – finally語句塊總是會被執行。它主要用於回收在try塊裡開啟的物力資源(如資料庫連線、網路連線和磁碟檔案)。只有finally塊,執行完成之後,才會回來執行try或者catch塊中的return或者throw語句,如果finally中使用了return或者throw等終止方法的語句,則就不會跳回執行,直接停止。
  • throw – 用於丟擲異常。
  • throws – 用在方法簽名中,用於宣告該方法可能丟擲的異常。

異常的申明(throws)

在Java中,當前執行的語句必屬於某個方法,Java直譯器呼叫main方法執行開始執行程式。若方法中存在檢查異常,如果不對其捕獲,那必須在方法頭中顯式宣告該異常,以便於告知方法呼叫者此方法有異常,需要進行處理。 在方法中宣告一個異常,方法頭中使用關鍵字throws,後面接上要宣告的異常。若宣告多個異常,則使用逗號分割。如下所示:

public static void method() throws IOException, FileNotFoundException{
    //something statements
}

注意:若是父類的方法沒有宣告異常,則子類繼承方法後,也不能宣告異常。

通常,應該捕獲那些知道如何處理的異常,將不知道如何處理的異常繼續傳遞下去。傳遞異常可以在方法簽名處使用 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子句宣告將它丟擲,否則會導致編譯錯誤
  • 僅當丟擲了異常,該方法的呼叫者才必須處理或者重新丟擲該異常。當方法的呼叫者無力處理該異常的時候,應該繼續丟擲,而不是囫圇吞棗。
  • 呼叫方法必須遵循任何可查異常的處理和宣告規則。若覆蓋一個方法,則不能宣告與覆蓋方法不同的異常。宣告的任何異常必須是被覆蓋方法所宣告異常的同類或子類。

異常的丟擲(throw)

如果程式碼可能會引發某種錯誤,可以建立一個合適的異常類例項並丟擲它,這就是丟擲異常。如下所示:

public static double method(int value) {
    if(value == 0) {
        throw new ArithmeticException("引數不能為0"); //丟擲一個執行時異常
    }
    return 5.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;
    }
}

異常的自定義

習慣上,定義一個異常類應包含兩個建構函式,一個無參建構函式和一個帶有詳細描述資訊的建構函式(Throwable 的 toString 方法會列印這些詳細資訊,除錯時很有用), 比如上面用到的自定義MyException:

public class MyException extends Exception {
    public MyException(){ }
    public MyException(String msg){
        super(msg);
    }
    // ...
}

異常的捕獲

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

  • try-catch
  • try-catch-finally
  • try-finally
  • try-with-resource

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
    }
}

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語句塊後的語句;
  • 一個完整的例子
檢視程式碼
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();
            }
        }
    }
}

try-finally

可以直接用try-finally嗎? 可以。

try塊中引起異常,異常程式碼之後的語句不再執行,直接執行finally語句。 try塊沒有引發異常,則執行完try塊就執行finally語句。

try-finally可用在不需要捕獲異常的程式碼,可以保證資源在使用後被關閉。例如IO流中執行完相應操作後,關閉相應資源;使用Lock物件保證執行緒同步,通過finally可以保證鎖會被釋放;資料庫連線程式碼時,關閉連線操作等等。

//以Lock加鎖為例,演示try-finally
ReentrantLock lock = new ReentrantLock();
try {
    //需要加鎖的程式碼
} finally {
    lock.unlock(); //保證鎖一定被釋放
}

finally遇見如下情況不會執行

  • 在前面的程式碼中用了System.exit()退出程式。
  • finally語句塊中發生了異常。
  • 程式所在的執行緒死亡。
  • 關閉CPU。

try-with-resource

try-with-resource是Java 7中引入的,很容易被忽略。

上面例子中,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 方法來獲取。

總結

  • try、catch和finally都不能單獨使用,只能是try-catch、try-finally或者try-catch-finally。
  • try語句塊監控程式碼,出現異常就停止執行下面的程式碼,然後將異常移交給catch語句塊來處理。
  • finally語句塊中的程式碼一定會被執行,常用於回收資源 。
  • throws:宣告一個異常,告知方法呼叫者。
  • throw :丟擲一個異常,至於該異常被捕獲還是繼續丟擲都與它無關。

Java程式設計思想一書中,對異常的總結。

  • 在恰當的級別處理問題。(在知道該如何處理的情況下了捕獲異常。)
  • 解決問題並且重新呼叫產生異常的方法。
  • 進行少許修補,然後繞過異常發生的地方繼續執行。
  • 用別的資料進行計算,以代替方法預計會返回的值。
  • 把當前執行環境下能做的事儘量做完,然後把相同的異常重拋到更高層。
  • 把當前執行環境下能做的事儘量做完,然後把不同的異常拋到更高層。
  • 終止程式。
  • 進行簡化(如果你的異常模式使問題變得太複雜,那麼用起來會非常痛苦)。
  • 讓類庫和程式更安全。

異常實踐

在 Java 中處理異常並不是一個簡單的事情。不僅僅初學者很難理解,即使一些有經驗的開發者也需要花費很多時間來思考如何處理異常,包括需要處理哪些異常,怎樣處理等等。這也是絕大多數開發團隊都會制定一些規則來規範進行異常處理的原因。

當你丟擲或捕獲異常的時候,有很多不同的情況需要考慮,而且大部分事情都是為了改善程式碼的可讀性或者 API 的可用性。

異常不僅僅是一個錯誤控制機制,也是一個通訊媒介。因此,為了和同事更好的合作,一個團隊必須要制定出一個最佳實踐和規則,只有這樣,團隊成員才能理解這些通用概念,同時在工作中使用它。

這裡給出幾個被很多團隊使用的異常處理最佳實踐。

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

主要原因有三點:

  • 異常機制的設計初衷是用於不正常的情況,所以很少會會JVM實現試圖對它們的效能進行優化。所以,建立、丟擲和捕獲異常的開銷是很昂貴的。
  • 把程式碼放在try-catch中返回阻止了JVM實現本來可能要執行的某些特定的優化。
  • 對陣列進行遍歷的標準模式並不會導致冗餘的檢查,有些現代的JVM實現會將它們優化掉。

在 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);
    }
}

儘量使用標準的異常

程式碼重用是值得提倡的,這是一條通用規則,異常也不例外。

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

  • 它使得你的API更加易於學習和使用,因為它與程式設計師原來已經熟悉的習慣用法是一致的。
  • 對於用到這些API的程式而言,它們的可讀性更好,因為它們不會充斥著程式設計師不熟悉的異常。
  • 異常類越少,意味著記憶體佔用越小,並且轉載這些類的時間開銷也越小。

Java標準異常中有幾個是經常被使用的異常。如下表格:

異常 使用場合
IllegalArgumentException 引數的值不合適
IllegalStateException 引數的狀態不合適
NullPointerException 在null被禁止的情況下引數值為null
IndexOutOfBoundsException 下標越界
ConcurrentModificationException 在禁止併發修改的情況下,物件檢測到併發修改
UnsupportedOperationException 物件不支援客戶請求的方法

雖然它們是Java平臺庫迄今為止最常被重用的異常,但是,在許可的條件下,其它的異常也可以被重用。例如,如果你要實現諸如複數或者矩陣之類的算術物件,那麼重用ArithmeticException和NumberFormatException將是非常合適的。如果一個異常滿足你的需要,則不要猶豫,使用就可以,不過你一定要確保丟擲異常的條件與該異常的文件中描述的條件一致。這種重用必須建立在語義的基礎上,而不是名字的基礎上。

最後,一定要清楚,選擇重用哪一種異常並沒有必須遵循的規則。例如,考慮紙牌物件的情形,假設有一個用於發牌操作的方法,它的引數(handSize)是發一手牌的紙牌張數。假設呼叫者在這個引數中傳遞的值大於整副牌的剩餘張數。那麼這種情形既可以被解釋為IllegalArgumentException(handSize的值太大),也可以被解釋為IllegalStateException(相對客戶的請求而言,紙牌物件的紙牌太少)。

對異常進行文件說明

當在方法上宣告丟擲異常時,也需要進行文件說明。目的是為了給呼叫者提供儘可能多的資訊,從而可以更好地避免或處理異常。

在 Javadoc 新增 @throws 宣告,並且描述丟擲異常的場景。

/**
* Method description
* 
* @throws MyBusinessException - businuess exception description
*/
public void doSomething(String input) throws MyBusinessException {
   // ...
}

同時,在丟擲MyBusinessException 異常時,需要儘可能精確地描述問題和相關資訊,這樣無論是列印到日誌中還是在監控工具中,都能夠更容易被人閱讀,從而可以更好地定位具體錯誤資訊、錯誤的嚴重程度等。

優先捕獲最具體的異常

大多數 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)
    }
}

不要捕獲 Throwable 類

Throwable 是所有異常和錯誤的超類。你可以在 catch 子句中使用它,但是你永遠不應該這樣做!

如果在 catch 子句中使用 Throwable ,它不僅會捕獲所有異常,也將捕獲所有的錯誤。JVM 丟擲錯誤,指出不應該由應用程式處理的嚴重問題。 典型的例子是 OutOfMemoryError 或者 StackOverflowError 。兩者都是由應用程式控制之外的情況引起的,無法處理。

所以,最好不要捕獲 Throwable ,除非你確定自己處於一種特殊的情況下能夠處理錯誤。

public void doNotCatchThrowable() {
    try {
        // do something
    } catch (Throwable t) {
        // don't do this!
    }
}

不要忽略異常

很多時候,開發者很有自信不會丟擲異常,因此寫了一個catch塊,但是沒有做任何處理或者記錄日誌。

public void doNotIgnoreExceptions() {
    try {
        // do something
    } catch (NumberFormatException e) {
        // this will never happen
    }
}

但現實是經常會出現無法預料的異常,或者無法確定這裡的程式碼未來是不是會改動(刪除了阻止異常丟擲的程式碼),而此時由於異常被捕獲,使得無法拿到足夠的錯誤資訊來定位問題。

合理的做法是至少要記錄異常的資訊。

public void logAnException() {
    try {
        // do something
    } catch (NumberFormatException e) {
        log.error("This should never happen: " + e); // see this line
    }
}

不要記錄並丟擲異常

這可能是本文中最常被忽略的最佳實踐。

可以發現很多程式碼甚至類庫中都會有捕獲異常、記錄日誌並再次丟擲的邏輯。如下:

try {
    new Long("xyz");
} catch (NumberFormatException e) {
    log.error(e);
    throw e;
}

這個處理邏輯看著是合理的。但這經常會給同一個異常輸出多條日誌。如下:

17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)

如上所示,後面的日誌也沒有附加更有用的資訊。如果想要提供更加有用的資訊,那麼可以將異常包裝為自定義異常。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

因此,僅僅當想要處理異常時才去捕獲,否則只需要在方法簽名中宣告讓呼叫者去處理。

包裝異常時不要拋棄原始的異常

捕獲標準異常幷包裝為自定義異常是一個很常見的做法。這樣可以新增更為具體的異常資訊並能夠做針對的異常處理。 在你這樣做時,請確保將原始異常設定為原因(注:參考下方程式碼 NumberFormatException e 中的原始異常 e )。Exception 類提供了特殊的建構函式方法,它接受一個 Throwable 作為引數。否則,你將會丟失堆疊跟蹤和原始異常的訊息,這將會使分析導致異常的異常事件變得困難。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

不要使用異常控制程式的流程

不應該使用異常控制應用的執行流程,例如,本應該使用if語句進行條件判斷的情況下,你卻使用異常處理,這是非常不好的習慣,會嚴重影響應用的效能。

不要在finally塊中使用return。

try塊中的return語句執行成功後,並不馬上返回,而是繼續執行finally塊中的語句,如果此處存在return語句,則在此直接返回,無情丟棄掉try塊中的返回點。

如下是一個反例:

private int x = 0;
public int checkReturn() {
    try {
        // x等於1,此處不返回
        return ++x;
    } finally {
        // 返回的結果是2
        return ++x;
    }
}

深入理解異常

JVM處理異常的機制?

提到JVM處理異常的機制,就需要提及Exception Table,以下稱為異常表。我們暫且不急於介紹異常表,先看一個簡單的 Java 處理異常的小例子。

public static void simpleTryCatch() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   }
}

上面的程式碼是一個很簡單的例子,用來捕獲處理一個潛在的空指標異常。

當然如果只是看簡簡單單的程式碼,我們很難看出什麼高深之處,更沒有了今天文章要談論的內容。

所以這裡我們需要藉助一把神兵利器,它就是javap,一個用來拆解class檔案的工具,和javac一樣由JDK提供。

然後我們使用javap來分析這段程式碼(需要先使用javac編譯)

//javap -c Main
 public static void simpleTryCatch();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: goto          11
       6: astore_0
       7: aload_0
       8: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      11: return
    Exception table:
       from    to  target type
           0     3     6   Class java/lang/Exception

看到上面的程式碼,應該會有會心一笑,因為終於看到了Exception table,也就是我們要研究的異常表。

異常表中包含了一個或多個異常處理者(Exception Handler)的資訊,這些資訊包含如下

  • from 可能發生異常的起始點
  • to 可能發生異常的結束點
  • target 上述from和to之前發生異常後的異常處理者的位置
  • type 異常處理者處理的異常的類資訊

那麼異常表用在什麼時候呢

答案是異常發生的時候,當一個異常發生時

  • 1.JVM會在當前出現異常的方法中,查詢異常表,是否有合適的處理者來處理
  • 2.如果當前方法異常表不為空,並且異常符合處理者的from和to節點,並且type也匹配,則JVM呼叫位於target的呼叫者來處理。
  • 3.如果上一條未找到合理的處理者,則繼續查詢異常表中的剩餘條目
  • 4.如果當前方法的異常表無法處理,則向上查詢(彈棧處理)剛剛呼叫該方法的呼叫處,並重覆上面的操作。
  • 5.如果所有的棧幀被彈出,仍然沒有處理,則拋給當前的Thread,Thread則會終止。
  • 6.如果當前Thread為最後一個非守護執行緒,且未處理異常,則會導致JVM終止執行。

以上就是JVM處理異常的一些機制。

try catch -finally

除了簡單的try-catch外,我們還常常和finally做結合使用。比如這樣的程式碼

public static void simpleTryCatchFinally() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   } finally {
       System.out.println("Finally");
   }
}

同樣我們使用javap分析一下程式碼

檢視程式碼
public static void simpleTryCatchFinally();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
       6: ldc           #7                  // String Finally
       8: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      11: goto          41
      14: astore_0
      15: aload_0
      16: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      19: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #7                  // String Finally
      24: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          41
      30: astore_1
      31: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #7                  // String Finally
      36: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_1
      40: athrow
      41: return
    Exception table:
       from    to  target type
           0     3    14   Class java/lang/Exception
           0     3    30   any
          14    19    30   any

和之前有所不同,這次異常表中,有三條資料,而我們僅僅捕獲了一個Exception, 異常表的後兩個item的type為any; 上面的三條異常表item的意思為:

  • 如果0到3之間,發生了Exception型別的異常,呼叫14位置的異常處理者。
  • 如果0到3之間,無論發生什麼異常,都呼叫30位置的處理者
  • 如果14到19之間(即catch部分),不論發生什麼異常,都呼叫30位置的處理者。

再次分析上面的Java程式碼,finally裡面的部分已經被提取到了try部分和catch部分。我們再次調一下程式碼來看一下

檢視程式碼
public static void simpleTryCatchFinally();
    Code:
      //try 部分提取finally程式碼,如果沒有異常發生,則執行輸出finally操作,直至goto到41位置,執行返回操作。  

       0: invokestatic  #3                  // Method testNPE:()V
       3: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
       6: ldc           #7                  // String Finally
       8: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      11: goto          41

      //catch部分提取finally程式碼,如果沒有異常發生,則執行輸出finally操作,直至執行got到41位置,執行返回操作。
      14: astore_0
      15: aload_0
      16: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      19: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #7                  // String Finally
      24: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          41
      //finally部分的程式碼如果被呼叫,有可能是try部分,也有可能是catch部分發生異常。
      30: astore_1
      31: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #7                  // String Finally
      36: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_1
      40: athrow     //如果異常沒有被catch捕獲,而是到了這裡,執行完finally的語句後,仍然要把這個異常丟擲去,傳遞給呼叫處。
      41: return

Catch先後順序的問題

我們在程式碼中的catch的順序決定了異常處理者在異常表的位置,所以,越是具體的異常要先處理,否則就會出現下面的問題

private static void misuseCatchException() {
   try {
       testNPE();
   } catch (Throwable t) {
       t.printStackTrace();
   } catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
       e.printStackTrace();
   }
}

這段程式碼會導致編譯失敗,因為先捕獲Throwable後捕獲Exception,會導致後面的catch永遠無法被執行。

Return 和finally的問題

這算是我們擴充套件的一個相對比較極端的問題,就是類似這樣的程式碼,既有return,又有finally,那麼finally導致會不會執行

public static String tryCatchReturn() {
   try {
       testNPE();
       return  "OK";
   } catch (Exception e) {
       return "ERROR";
   } finally {
       System.out.println("tryCatchReturn");
   }
}

答案是finally會執行,那麼還是使用上面的方法,我們來看一下為什麼finally會執行。

反編譯
public static java.lang.String tryCatchReturn();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: ldc           #6                  // String OK
       5: astore_0
       6: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #8                  // String tryCatchReturn
      11: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: aload_0
      15: areturn       返回OK字串,areturn意思為return a reference from a method
      16: astore_0
      17: ldc           #10                 // String ERROR
      19: astore_1
      20: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: ldc           #8                  // String tryCatchReturn
      25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      28: aload_1
      29: areturn  //返回ERROR字串
      30: astore_2
      31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #8                  // String tryCatchReturn
      36: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_2
      40: athrow  如果catch有未處理的異常,丟擲去。

異常是否耗時?為什麼會耗時?

說用異常慢,首先來看看異常慢在哪裡?有多慢?下面的測試用例簡單的測試了建立物件、建立異常物件、丟擲並接住異常物件三者的耗時對比:

測試對比
public class ExceptionTest {  
  
    private int testTimes;  
  
    public ExceptionTest(int testTimes) {  
        this.testTimes = testTimes;  
    }  
  
    public void newObject() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            new Object();  
        }  
        System.out.println("建立物件:" + (System.nanoTime() - l));  
    }  
  
    public void newException() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            new Exception();  
        }  
        System.out.println("建立異常物件:" + (System.nanoTime() - l));  
    }  
  
    public void catchException() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            try {  
                throw new Exception();  
            } catch (Exception e) {  
            }  
        }  
        System.out.println("建立、丟擲並接住異常物件:" + (System.nanoTime() - l));  
    }  
  
    public static void main(String[] args) {  
        ExceptionTest test = new ExceptionTest(10000);  
        test.newObject();  
        test.newException();  
        test.catchException();  
    }  
}

執行結果:

建立物件:575817  
建立異常物件:9589080  
建立、丟擲並接住異常物件:47394475  

建立一個異常物件,是建立一個普通Object耗時的約20倍(實際上差距會比這個數字更大一些,因為迴圈也佔用了時間,追求精確的讀者可以再測一下空迴圈的耗時然後在對比前減掉這部分),而丟擲、接住一個異常物件,所花費時間大約是建立異常物件的4倍。

那佔用時間的“大頭”:丟擲、接住異常,系統到底做了什麼事情?請參考這篇文章:

面試題

1.什麼是Java異常

答:異常是發生在程式執行過程中阻礙程式正常執行的錯誤事件。比如:使用者輸入錯誤資料、硬體故障、網路阻塞等都會導致出現異常。 只要在Java語句執行中產生了異常,一個異常物件就會被建立,JRE就會試圖尋找異常處理程式來處理異常。如果有合適的異常處理程式,異常物件就會被異常處理程式接管,否則,將引發執行環境異常,JRE終止程式執行。 Java異常處理框架只能處理執行時錯誤,編譯錯誤不在其考慮範圍之內。

2.Java異常處理中有哪些關鍵字?

答:

  • throw:有時我們需要顯式地建立並丟擲異常物件來終止程式的正常執行。throw關鍵字用來丟擲並處理執行時異常。
  • throws:當我們丟擲任何“被檢查的異常(checked exception)”並不處理時,需要在方法簽名中使用關鍵字throws來告知呼叫程式此方法可能會丟擲的異常。呼叫方法可能會處理這些異常,或者同樣用throws來將異常傳給上一級呼叫方法。throws關鍵字後可接多個潛在異常,甚至是在*main()*中也可以使用throws。
  • try-catch:我們在程式碼中用try-catch塊處理異常。當然,一個try塊之後可以有多個catch子句,try-catch塊也能巢狀。每個catch塊必須接受一個(且僅有一個)代表異常型別的引數。
  • finally:finally塊是可選的,並且只能配合try-catch一起使用。雖然異常終止了程式的執行,但是還有一些開啟的資源沒有被關閉,因此,我們能使用finally進行關閉。不管異常有沒有出現,finally塊總會被執行。

3.描述一下異常的層級。

答:Java異常是層級的,並通過繼承來區分不同種類的異常。

  • Throwable是所有異常的父類,它有兩個直接子物件Error,Exception,其中Exception又被繼續劃分為“被檢查的異常(checked exception)”和”執行時的異常(runtime exception,即不受檢查的異常)”。 Error表示編譯時和系統錯誤,通常不能預期和恢復,比如硬體故障、JVM崩潰、記憶體不足等。
  • 被檢查的異常(Checked exceptions)在程式中能預期,並要嘗試修復,如FileNotFoundException。我們必須捕獲此類異常,併為使用者提供有用資訊和合適日誌來進行除錯。Exception是所有被檢查的異常的父類。
  • 執行時異常(Runtime Exceptions)又稱為不受檢查異常,源於糟糕的程式設計。比如我們檢索陣列元素之前必須確認陣列的長度,否則就可能會丟擲ArrayIndexOutOfBoundException執行時異常。RuntimeException是所有執行時異常的父類。

4.Java異常類有哪些的重要方法?

答:Exception和它的所有子類沒有提供任何特殊方法供使用,它們的所有方法都是來自其基類Throwable。

  • String getMessage():方法返回Throwable的String型資訊,當異常通過構造器建立後可用。
  • String getLocalizedMessage():此方法通過被重寫來得到用本地語言表示的異常資訊返回給呼叫程式。Throwable類通常只是用getMessage()方法來實現返回異常資訊。
  • synchronized Throwable getCause():此方法返回異常產生的原因,如果不知道原因的話返回null。(原文有拼寫錯誤 應該是if 不是id)
  • String toString():方法返回String格式的Throwable資訊,此資訊包括Throwable的名字和本地化資訊。
  • void printStackTrace():該方法列印棧軌跡資訊到標準錯誤流。該方法能接受PrintStream 和PrintWriter作為引數實現過載,這樣就能實現列印棧軌跡到檔案或流中。

5.描述Java 7 ARM(Automatic Resource Management,自動資源管理)特徵和多個catch塊的使用

答:如果一個try塊中有多個異常要被捕獲,catch塊中的程式碼會變醜陋的同時還要用多餘的程式碼來記錄異常。有鑑於此,Java 7的一個新特徵是:一個catch子句中可以捕獲多個異常。示例程式碼如下:

catch(IOException | SQLException | Exception ex){
     logger.error(ex);
     throw new MyException(ex.getMessage());
}

大多數情況下,當忘記關閉資源或因資源耗盡出現執行時異常時,我們只是用finally子句來關閉資源。這些異常很難除錯,我們需要深入到資源使用的每一步來確定是否已關閉。因此,Java 7用try-with-resources進行了改進:在try子句中能建立一個資源物件,當程式的執行完try-catch之後,執行環境自動關閉資源。下面是這方面改進的示例程式碼:

try (MyResource mr = new MyResource()) {
            System.out.println("MyResource created in try-with-resources");
        } catch (Exception e) {
            e.printStackTrace();
        }

6. 有沒有遇到NPE?怎麼處理?

流程:確定異常來源(傳入引數、Session獲取的資料、資料庫查詢的結果、級聯呼叫等)

預防可能出現異常的地方(自動拆裝箱)

1)返回型別為基本資料型別,return 包裝資料型別的物件時,自動拆箱有可能產生 NPE。
 反例:public int f() { return Integer 物件}, 如果為 null,自動解箱拋 NPE。
2) 資料庫的查詢結果可能為 null。
3) 集合裡的元素即使 isNotEmpty,取出的資料元素也可能為 null。
4) 遠端呼叫返回物件時,一律要求進行空指標判斷,防止 NPE。
5) 對於 Session 中獲取的資料,建議 NPE 檢查,避免空指標。
6) 級聯呼叫 obj.getA().getB().getC();一連串呼叫,易產生 NPE。
正例:使用 JDK8 的 Optional 類來防止 NPE 問題。

參考文章