1. 程式人生 > >【系列】重新認識Java語言——異常(Exception)

【系列】重新認識Java語言——異常(Exception)

異常,是Java中非常常用的功能,它可以簡化程式碼,並且增強程式碼的安全性。本文將介紹一些異常高階知識,也是學習Java一來的一次總結。包括以下內內容:

  1. 異常的基礎知識
  2. 異常特點
  3. 異常誤用
  4. 如何正確地使用異常
  5. 異常的實現原理

關於異常

異常機制,是指程式不正常時的處理方式。具體來說,異常機制提供了程式退出的安全通道。當出現錯誤後,程式執行的流程發生改變,程式的控制權轉移到異常處理器。

異常的一般性語法為:

    try {
        // 有可能丟擲異常的程式碼
    } catch (Exception e) {
        // 異常處理
    } finally
{ // 無論是否捕獲到異常都會執行的程式 }

Java異常體系

Java異常中的體系結構如下圖所示。

exception-structure

  • Throwable類是整個Java異常體系的超類,都有的異常類都是派生自這個類。包含Error和Exception兩個直接子類。
  • Error表示程式在執行期間出現了十分嚴重、不可恢復的錯誤,在這種情況下應用程式只能中止執行,例如JAVA虛擬機器出現錯誤。在程式中不用捕獲Error型別的異常。一般情況下,在程式中也不應該丟擲Error型別的異常。
  • Exception是應用層面上最頂層的異常類,包含RuntimeException(執行時異常)和 Checked Exception(受檢異常)。
    • RuntimeException
      是一種Unchecked Exception,即表示編譯器不會檢查程式是否對RuntimeException作了處理,在程式中不必捕獲RuntimException型別的異常,也不必在方法體宣告丟擲RuntimeException類。一般來說,RuntimeException發生的時候,表示程式中出現了程式設計錯誤,所以應該找出錯誤修改程式,而不是去捕獲RuntimeException。常見的RuntimeException有NullPointException、ClassCastException、IllegalArgumentException、IndexOutOfBoundException等。
    • Checked Exception是相對於Unchecked Exception而言的,Java中並沒有一個名為Checked Exception的類。它是在程式設計中使用最多的Exception,所有繼承自Exception並且不是RuntimeException的異常都是Checked Exception。JAVA 語言規定必須對checked Exception作處理,編譯器會對此作檢查,要麼在方法體中宣告丟擲checked Exception,要麼使用catch語句捕獲checked Exception進行處理,不然不能通過編譯。常用的Checked Exception有IOException、ClassNotFoundException等。

異常的特點

通用特點

JVM捕獲並處理未被應用程式捕獲的異常

無論是受檢異常(Checked Exception)還是執行時異常(Runtime Exception),如果異常沒有被應用程式捕獲,那麼最終這個異常會交由JVM來進行處理,會明顯出現下面兩個結果:
1. 當前執行緒會停止執行,異常觸發點後面的程式碼將得不到執行。
2. 異常棧資訊會通過標準錯誤流輸出。

/**
 * 應用程式沒有處理丟擲的異常時,會交由JVM來處理這個異常。結果是:
 * 1. 當前執行緒會停止執行,異常觸發點後面的程式碼將得不到執行。
 * 2. 異常棧資訊會通過標準錯誤流輸出。
 * 
 * @author xialei
 * @version 1.0 2016年5月18日下午9:53:54
 */
public class UncatchedException {

    public static void main(String[] args) throws Exception {
        throwException();
        System.out.println("這一行不會被打印出來");
    }

    public static void throwException() throws Exception {
        int i = 0;
        if (i == 0) {
            throw new Exception();
        }
    }
}

異常catch有順序性

在catch異常時,如果有多個異常,那麼是會有順序要求的。子型別必須要在父型別之前進行catch,catch與分支邏輯是一致,如果父型別先被catch,那麼後被catch的分支根本得不到執行機會。

/*
 * 個人主頁:http://hinylover.space
 *
 * Creation Date: 2016年4月7日 下午2:29:42
 */
package demo.blog.java.exception;

/**
 * 在catch異常時,如果有多個異常,那麼是會有順序要求的。子型別必須要在父型別之前進行catch,
 * catch與分支邏輯是一致,如果父型別先被catch,那麼後被catch的分支根本得不到執行機會。
 * 
 * @author xialei
 * @version 1.0 2016年5月18日下午10:00:40
 */
public class ExceptionCatchOrder {

    public void wrongCatchOrder() {
        try {
            Integer i = null;
            int j = i;
        } catch (Exception e) {
        } catch (NullPointerException e) { // 編譯不通過,eclipse提示“Unreachable catch block for NullPointerException. It is already handled by the catch block for Exception”
        }
    }
}

異常被吃掉

如果在finally中返回值,那麼在程式中丟擲的異常資訊將會被吞噬掉。這是一個非常值得注意的問題,因為異常資訊是非常重要的,在出現問題時,我們通常憑它來查詢問題。如果編碼不小心而導致異常被吞噬,排查起來是相當困難的,這將是一個大隱患。

/*
 * 個人主頁:http://hinylover.space
 *
 * Creation Date: 2016年4月7日 下午2:29:42
 */
package demo.blog.java.exception;

/**
 * 如果在finally中返回值,那麼在程式中丟擲的異常資訊將會被吞噬掉。
 * @author xialei
 * @version 1.0 2016年5月18日下午10:08:43
 */
public class FinallySwallowException {

    public static void main(String[] args) throws Exception {
        System.out.println(swallowException()); // 打印出2,而不是打印出異常棧
    }

    public static int swallowException() throws Exception {
        try {
            throw new Exception();
        } finally {
            return 2;
        }
    }
}

重寫Exception的fillInStackTrace()方法

使用自定義異常時,可以重寫fillInStackTrace()方法來控制Exception的異常棧資訊。預設情況下,在程式丟擲異常時,最終會通過呼叫private native Throwable fillInStackTrace(int dummy)這個本地方法來獲取當前執行緒的堆疊資訊,這是一個非常耗時的操作。如果我們僅僅需要用到異常的傳播性質,而不關係異常的堆疊資訊,那麼完全可以通過重寫fillInStackTrace()方法來實現。

/*
 * 個人主頁:http://hinylover.space
 *
 * Creation Date: 2016年4月7日 下午2:29:42
 */
package demo.blog.java.exception;

/**
 * 重寫Exception的fillInStackTrace()方法
 * 
 * @author xialei
 * @version 1.0 2016年5月18日下午10:18:57
 */
public class MyException extends Exception {

    public MyException(String message) {
        super(message);
    }

    /*
     * 重寫fillInStackTrace方法會使得這個自定義的異常不會收集執行緒的整個異常棧資訊,會大大
     * 提高減少異常開銷。
     */
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }

    public static void main(String[] args) {
        try {
            throw new MyException("由於MyException重寫了fillInStackTrace方法,那麼它不會收集執行緒執行棧資訊。");
        } catch (MyException e) {
            e.printStackTrace(); // 在控制檯的列印結果為:demo.blog.java.exception.MyException: 由於MyException重寫了fillInStackTrace方法,那麼它不會收集執行緒執行棧資訊。
        }
    }
}

受檢異常(checked exception)

必須處理或者向上丟擲

我們必須要對底層丟擲來的受檢異常進行處理,處理方式有try...catch...或者向上丟擲(throws),否則程式無法通過編譯。

package demo.blog.java.exception;

/**
 * 必須對底層丟擲的異常進行處理
 * @author xialei
 * @version 1.0 2016年5月18日下午10:42:53
 */
public class CheckedException {

    public static void main(String[] args) {
        throwException(); // 編譯不通過,必須對底層丟擲的異常進行處理
    }

    public static void throwException() throws Exception {
        throw new Exception();
    }
}

不能捕獲未被丟擲的受檢異常

如果我們試圖去捕獲一個未被丟擲的受檢異常,程式將無法通過編譯(Exception除外)。

/*
 * 個人主頁:http://hinylover.space
 *
 * Creation Date: 2016年5月18日 下午10:45:32
 */
package demo.blog.java.exception;

import java.io.IOException;

/**
 * 不能捕獲一個沒有被丟擲的受檢異常(Exception除外)
 * @author xialei
 * @version 1.0 2016年5月18日下午10:45:32
 */
public class CantCatchUnthrowedException {

    public void cantCatchUnthrowedException() {
        try {
            int i = 0;
        } catch (IOException e) { // 編譯不通過,eclipse提示:Unreachable catch block for IOException. This exception is never thrown from the try statement body
            e.printStackTrace();
        }
    }
}

執行時異常(runtime exception)

執行時異常(runtime exception)與受檢異常(checked exception)的最大區別是不強制對丟擲的異常進行處理。所有的執行時異常都繼承自RuntimeException這個類,別問為什麼,Java是這麼規定的。與受檢異常類似的例子,如果丟擲的是執行時異常,就算不捕獲這個異常,程式也可以編譯通過。

/*
 * 個人主頁:http://hinylover.space
 *
 * Creation Date: 2016年5月18日 下午11:02:57
 */
package demo.blog.java.exception;


/**
 * 編譯通過
 * @author xialei
 * @version 1.0 2016年5月18日下午11:02:57
 */
public class MyRuntimeException {

    public void myRuntimeException() {
        throw new RuntimeException(); // 可以正常編譯
    }
}

異常的使用

用受檢異常還是執行時異常?

在使用異常時,筆者經常為使用何種異常而犯難,在實際使用過程中總結了一些小經驗。

大概率發生時使用執行時異常

從概率上來說,如果這個異常發生的頻率非常高,那麼因為使用執行時異常,最典型的就是NullPointException。Java中呼叫每個物件的方法時,都有可能會發生NullPointException。如果這是一個受檢異常,那麼在每次呼叫物件方法要麼try {} catch {},那麼使用throws關鍵字向上丟擲。無論哪種方式,程式碼無疑都會是非常醜陋的,那畫面太“美”不敢看。如果程式碼裡充斥著各種異常處理塊,可讀性將會大打折扣。

異常無法恢復時使用執行時異常

當異常發生時,如果開發者無法從異常狀態恢復到正常狀態,那麼這種異常應該是執行時異常。如果使用受檢異常,這除了加重開發者的負擔之外,別無它用。當在呼叫其他方法時,如果方法丟擲受檢異常,那麼筆者就會比較緊張。因為這意味著需要停止業務邏輯開發,然後開始思考如何處理這該死的異常。執行時異常通常是由於開發者程式設計不當所引起的,譬如空指標異常、除零異常等。如果開發者在開發過程中小心謹慎,考慮周全,就可能避免這種異常的發生。

可恢復時優先使用受檢異常

如果我們能夠從異常中恢復到正常狀態,那麼應該優先使用受檢異常。為什麼是優先而不是一定呢?因為從原理上來說,使用執行時異常也可以恢復到正常狀態,而且使用執行時異常的程式碼無疑會比較乾淨整潔。而使用受檢異常,明確地說明了呼叫方式時可能發生異常情況,強制開發者去處理這些異常情況常常會增強程式碼的健壯性。受檢異常通常是由外部環境所引起的,譬如IOException等。

使用受檢異常做流程控制

從Java語義上來說,應該是當程式層面真正發生異常狀況時才應該使用異常(Exception),《Effect Java》一書中也建議只有真正的異常情況才使用異常。但我們有時也會利用異常來達到業務流程控制的目的。這樣做主要有下面的好處:

  1. 簡化程式碼邏輯。我們無需為多分枝業務流程編寫各種if...else...語句來處理不同的情況。相反地,我們只需處理正常的業務流程即可,異常流程只需要通過異常向上丟擲去即可,至於誰去處理這些異常,則不需要我們過多地關心。

  2. 可讀性增強。如果一段程式碼中充斥著分枝邏輯,那麼整個程式碼的可讀性會非常差。在閱讀程式碼時,很難理清楚程式碼的主幹。說到底,主幹程式碼才是我們重點關注的。如果使用異常進行流程控制,主幹程式碼就清晰地顯示在面前,兩個字:舒服!

儘量集中處理異常

在各種有關程式碼重構的書本中,都會提到一個核心原則:一個方法應該僅做一件事情。如果一個方法中,既包含業務邏輯,又包含異常處理程式,那麼實際上這個方法就做了兩件事情。如果異常上層可以處理,那麼就不應該在下層處理。在上層進行處理的好處是,可以對異常進行統一地處理。而至於將異常處理程式分散到程式碼的各個地方,導致維護起來十分困難。在進行異常處理時,應該優先考慮使用AOP(面向切面程式設計)技術,這樣降低了核心業務邏輯與異常處理的耦合性。

自定義異常體系

在應用系統中應該要建立自己的異常體系,這樣便於統一處理系統中出現的異常。筆者在開發過程中,通常會建立類似下圖所示的系統體系。越靠近底層,越使用更加底層的、具體的異常。如果是其他系統中的異常(譬如Java自身的異常),也應該將其轉化為自定義體系中的對應異常。

self-define-exception-system

異常誤用

e.printTrace()處理所有異常。

使用e.printTrace()來粗暴地處理所有異常是新手經常犯的毛病,筆者在初學時也是這麼幹的。為什麼會出現這種情況呢?因為如果未處理的受檢異常,程式碼將編譯不通過,IDE(如eclipse)中會無情地打上各種紅叉叉。這是IDE可以幫我們處理的情況,於是按照IDE的提示,try{}catch{}這段程式碼,其預設的異常處理就是呼叫e.printTrace()方法。初學者只顧著程式碼順利通過編譯,而完全沒有考慮這樣做存在的風險。這樣做的風險如下:

  1. 錯失正確的處理方法。相當一部分受檢異常通過正確地處理是可以恢復正常,簡單粗暴地使用e.printTrace()將錯失恢復機會。
  2. “丟失”異常資訊。printTrace()方法是Throwable類中的一個方法,它的作用是將異常棧資訊列印到標準錯誤流中。如果Web專案,會將異常資訊列印到容器的日誌檔案中;如果是普通專案,通常會將標準輸出和標準錯誤重定向到dev/null(空裝置)中。無論是何種情況,都有可能導致異常資訊“丟失”(web容器中的日誌其實不代表丟失了,但是我們通常不會利用容器級別的日誌排錯),給排錯帶來很大的麻煩。

全部使用執行時異常

為了“偷懶”,無論什麼情況都使用執行時異常,這樣就可以不用費勁處理異常了,輕輕鬆鬆。但是,現在沒事不代表以後不出事,這無疑為程式碼埋下了隱患。如果在呼叫一個方法時,該方法並沒有顯示地丟擲異常,也沒有在javadoc中強調,我們就不會知道呼叫這段程式碼可能發生的異常情況,那些可能出現的異常情況對我們來說是透明的。

總是catch Exception物件

在捕獲和處理異常時,不管3721,一股腦地catch Exception物件。沒錯,這樣就可以一次性處理所有異常情況。但是,所有地異常都使用相同的處理程式真的對嗎?在這樣做之前應該先要打個大大的問號。這樣做的後果是我們會無形中忽略那些重要的異常。

正確使用異常

  • 正確地處理異常。針對不用的異常採取合適的、正確的異常處理方式,不要遇到任何異常都printTrace()或者列印一個日誌。
  • catch時指定具體的異常。不要一股腦地catch Exception,具體的異常應該單獨catch住,越具體的異常越早catch。
  • 涉及到資源時,需要finally。如果涉及到資源的關閉時,應該將關閉資源的程式碼寫在finally程式碼塊內。
  • 最小化try{ } catch{ }範圍。try的範圍應該儘量小,最好就是try住丟擲異常的那個方法即可。

異常的實現原理(位元組碼級別)

從位元組碼層面上來分析一下Java異常的實現原理,編寫如下所示的原始碼,使用javac命令進行編譯,然後使用javap命令檢視編譯後的位元組碼細節內容。

public class ExceptionClassCode {

    public int demo() {
        int x;
        try {
            x = 1;
            return x;
        } catch (Exception e) {
            x = 2;
            return x;
        } finally {
            x = 3
        }
    }
}
 public int demo();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=5, args_size=1
         0: iconst_1 // 生成整數1
         1: istore_1 // 將生成的整數1賦予第1號區域性變數(x=1)
         2: iload_1 // 將x(=1)的值入棧
         3: istore_2 // 將棧頂的值(=1)賦予第2號變數(returnValue)
         4: iconst_3 // 生成整數3
         5: istore_1 // x=3
         6: iload_2 // returnValue=當前棧頂值(=1)
         7: ireturn // 返回returnValue(=1)
         8: astore_2 // 將Exception物件引用值賦予第2號區域性變數
         9: iconst_2 // 生成整數2
        10: istore_1 // x=2
        11: iload_1 // x(=2)壓入棧頂
        12: istore_3 // 將棧頂的值(=2)賦予第3號變數(returnValue)
        13: iconst_3 // 生成整數3
        14: istore_1 // x=3
        15: iload_3  // returnValue(=2)壓入棧頂
        16: ireturn  // 返回returnValue(=2)
        17: astore        4 // 將異常資訊儲存到第4號區域性變數
        19: iconst_3 // 生成整數3
        20: istore_1 // x=3
        21: aload         4 // 將異常引用值壓入棧
        23: athrow // 丟擲棧頂所引用的異常
      Exception table:
         from    to  target type
             0     4     8   Class java/lang/Exception # 如果0~4行位元組碼(try程式碼塊)中出現Exception及其子類異常,則執行第8行(catch程式碼行)
             0     4    17   any # 無論0~4行位元組碼(try程式碼塊)是否丟擲異常,都執行第17行(finally程式碼行)
             8    13    17   any # 無論8~13行位元組碼(catch程式碼塊)是否丟擲異常,都執行第17行(finally程式碼行)
            17    19    17   any 

看到位元組碼中有一個Exception table(異常表)區域,這個就是與異常相關的位元組碼內容。它表示在fromto所指示的位元組碼行中,如果丟擲type所對應的異常(及其子類),那麼就跳到target指定的位元組碼行開始執行。