Java異常處理機制與最佳實踐

  這周小組內的學習是探討Java異常處理的最佳實踐,今天週末,外面太悶,宅在家裏對Java的異常處理作一個總結,若有不對的地方歡迎指正~java

一. 談談我的對Java異常處理的見解

  維基百科對於異常處理的定義是:web

異常處理,是編程語言或計算機硬件裏的一種機制,用於處理軟件或信息系統中出現的異常情況(即超出程序正常執行流程的某些特殊條件)。

  Java語言從設計之初就提供了對異常處理的支持,而且不一樣於其它語言,Java對於異常採起了強校驗機制,即對於編譯期異常須要API調用方顯式地對異常進行處理,這種強校驗機制被一部分人所鍾愛,也有一部分人狂吐槽它。持支持觀點的人認爲這種機制能夠極大的提高系統的穩定性,當存在潛在異常的時候強制開發人員去處理異常,而反對的人則認爲強制異常處理下降了代碼的可讀性,一串串的try-catch讓代碼看起來不夠簡潔,而且不正確的使用不只達不到提高系統穩定性的目的,反而成爲了bug的良好棲息之地。算法

  Java的異常處理是一把雙刃劍,這是我一貫持有的觀點。我的認爲咱們不能對Java的異常處理片面的下一個好或者很差的定義,黑格爾說「存在即合理」,既然Java的設計者強制要求咱們去處理Java的異常,那麼與其在那裏吐槽,還不如去學習如何用好Java的異常處理,讓其爲提高程序的穩定性所服務。不過從筆者的親身感覺來看,用好Java的異常處理門檻並不低!編程

二. Java的異常繼承體系

輸入圖片說明
  上圖展現了Java的異常繼承體系,Throwable是整個Java異常處理的最高抽象(但它不是抽象類哦),它實現了Serializable接口,而後Java將異常分爲了Error和Exception兩大類。Error定義了資源不足、約束失敗、其它使程序沒法繼續執行的條件,通常咱們在程序中不須要本身去定義額外的Error,Java設計者提供的Error場景幾乎覆蓋了全部可預見的狀況。當程序中存在Error時,咱們也無須去處理它,由於這種錯誤通常都是致命的,讓程序掛掉是最好的處理方法。數組

  咱們通常常常接觸到的是Exception,Error被翻譯爲錯誤,而Exception則被翻譯成異常,異常能夠理解爲是輕微的錯誤,不少時候是不致命的,咱們能夠catch以後,通過處理讓程序繼續執行。但有時候一些錯誤雖然是輕微的,但依靠程序自己也無力挽救,即錯誤和異常之間沒有明顯的邊界,爲了解決這個問題,Exception被設計成了CheckedException和UnCheckedException兩大類,咱們能夠把UnCheckedException看作是介於Exception和Error的中間產物,有時候它能夠被catch,有時候咱們也但願它可讓程序當即掛掉。安全

  • CheckedException:也稱做編譯期異常,這類異常強制軟件研發人員進行catch處理,若是不處理則沒法編譯經過,這類異常不少時候都是能夠恢復的
  • UnCheckedException:也稱做運行時異常,這類異常不強制要求軟件研發人員進行catch處理,若是不處理則出現該異常的時候程序會掛掉,這個時候有點接近於Error,雖然不強制,咱們也能夠主動去catch這些異常,處理以後讓程序繼續執行,這個時候有點接近於通常的Exception

三. 最佳實踐

  • 異常應該使用在程序會發生異常的地方

  Java異常處理體系的缺點不光在於下降了程序的可讀性,JVM在對待有try-catch代碼塊的時候的時候每每不能更好的優化,甚至不優化,而且對於一個異常的處理在時間開銷上相對是比較昂貴的,因此對於正常的狀況,咱們不該該使用異常機制去達到本身所揣測的JVM對於代碼的優化,由於JVM在不斷的發展和進步中,任何一種優化的教條都不能保證在將來的某個時刻不會被JVM用更好的方式替換掉,因此咱們在編碼的時候更多的應該是去專一代碼的邏輯正確和簡潔,而不是去琢磨如何編碼才能讓JVM更好地優化,此外咱們也不該該用異常去作流程控制,由於前面說過,異常的處理過程開銷是昂貴的。總的說來就是咱們應該針對潛在異常的程序才使用Java的異常處理機制,而對於正常的程序流程則不該該試圖利用異常去達到某種目的,這樣每每會弄巧成拙。架構

@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void badTry() {
    int result = 0;
    int i = 0;
    try {
        while (true) {
            result += this.a[i++];
        }
    } catch (ArrayIndexOutOfBoundsException e) {
        // do nothing
    }
}
@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void goodTry() {
    int result = 0;
    for (int i = 0; i < ARRAY_SIZE; i++) {
        result += this.a[i];
    }
}

  上面兩個函數都實現了對數組的遍歷求和過程,可是兩種方法使用不一樣的方式來結束這個過程,badTry利用異常機制來終止遍歷的過程,而goodTry則是咱們經常使用的foreach迭代。咋看一眼,你可能會對badTry的用法感到不屑,誰特麼會去這樣寫程序,可是若是對JVM的內部優化過程有必定了解的話,那麼badTry的寫法也彷佛有那麼點意思,首先咱們來看一下利用JMH對這兩個方法進行測試的時間開銷:併發

# Run complete. Total time: 00:07:41
Benchmark                     Mode      Cnt    Score    Error  Units
EffectiveException.badTry   sample     1819  117.413 ±  3.423  ms/op
EffectiveException.goodTry  sample  5290340   ≈ 10⁻⁴           ms/op

偏題一下:我通常推薦你們在統計程序運行時間的時候用JMH工具,而不是利用System.currentTimeMillis()這種方式去作時間打點。JMH是Java的微基準測試框架,相對於時間打點的方式來講,JMH統計的是程序預熱以後CPU真正的執行時間,剔除了解釋、等待CPU分配時間片等時間,因此統計結果更加具有真實性框架

  回到正題,從上面JMH通過200次迭代的統計結果來看,goodTry執行的時間約爲10⁻⁴ms,而badTry則耗費了117.413ms(正負誤差3.423ms),因此能夠看異常處理開銷仍是比較昂貴的,對於這種正常的流程,咱們利用異常的機制去控制程序的執行每每會拔苗助長。
  對於數組的遍歷,Java在每次訪問元素的時候都會去檢查一下數組下標是否越界,若是越界則會拋出IndexOutOfBoundsException,這樣給Java程序猿在編碼上帶來了便利,可是若是對於一個數組的頻繁訪問,那麼這種邊界檢查將會是一筆不小的開銷,但倒是不能省去的步驟,爲了提高執行效率,JVM通常會採起以下優化措施:編程語言

1. 若是下標是常量的狀況,那麼能夠在編譯器經過數據流分析就能夠斷定運行的時候是否須要檢查
2. 若是是在循環中利用變量去訪問一個數組,那麼JVM也能夠在編譯器分析循環的範圍是否在數組邊界以內,從而能夠消除邊界檢查

  上面例子中的badTry寫法,編碼者應該是但願利用Java的 隱式異常處理機制 來提高程序的運行效率。

/*
 * 用戶調用
 */
obj.toString();

  上面的代碼爲用戶的一次普通調用obj.toString(),對於該調用,Java會去判斷是否存在空指針異常,因此JVM會將這部分代碼編譯成下面這個樣子:

/*
 * JVM編譯
 */
if(null != obj) {
    obj.toString();
} else {
    throw new NullPointerException();
}

  若是對於obj.toString()進行頻繁的調用,那麼這樣的優化勢必會形成每一次調用都要去判空,這但是一筆不小的開銷,因此JVM會利用隱式異常處理機制對上面這部分代碼進行再次優化:

/*
 * JVM隱式異常優化
 */
try {
    obj.toString();
} catch (segment_fault) {  // Segment Fault信號異常處理器
    /**
     * 傳入異常處理器中進行恢復並拋出NullPointerException
     * 用戶態->內核態->用戶態
     */
    exception_handler();
}

  隱式異常處理經過異常機制來減免對於空指針的斷定,也就是先執行代碼主體,只要不拋異常就繼續正常執行,一旦跑了異常說明obj爲空指針,就轉去處理異常,而後拋出NullPointerException來告知用戶,這樣就不用每次調用以前都去判空了。可是隱式異常也存在一個缺陷,若是上面的代碼頻繁的拋異常,那麼每次JVM都要轉去處理異常,而後再返回,這個過程是須要由用戶態轉到內核態處理,處理完成以後再返回用戶態,最後向用戶拋出NullPointerException的過程,這可比一次判空的代價要昂貴多了,因此對於頻繁異常的狀況,咱們試圖利用異常去控制流程是不合適的,就像咱們在最開始的例子給出,當利用ArrayIndexOutOfBoundsException來結束數組遍歷過程的開銷是很大的,因此不要用異常去處理正常的執行流程,或者不要用異常去作流程控制。

  • 若是API的調用方可以很好的處理異常,就拋checked exception,不然unchecked exception更加合適

  對於Java的CheckedExceptionUnCheckedException,以及Error,咱們在使用的時候可能常常會去疑惑到底使用哪種,我始終以爲這沒有具體的教條可尋,好比哪一種狀況必定用哪種異常,可是咱們仍是能夠總結出一些基本的使用原則。

1.何時使用Error?

  Error通常被用於定義資源不足、約束失敗、其它使程序沒法繼續執行的條件的場景,咱們常常會在JVM中看到Error的狀況,好比OOM,因此對於Error而言,通常不推薦在程序中去主動使用,也不推薦去實現本身的Error,對於有這種需求的狀況咱們徹底能夠利用UncheckedException代替。

2.何時使用CheckedException?

  對於CheckedException的使用,若是同時知足以下兩條原則,則推薦使用,若是不能知足則使用UnCheckedException讓程序早點掛掉應該是一種更好的選擇:

1. API的調用方正確的使用API不能阻止異常的發生
2. 一旦異常發生,調用方能夠採起有效的應對措施

  在設計API的時候,拋出CheckedException可以暗示調用者對異常手動處理,提高系統穩定性,可是若是調用方也不知道若是更好的處理,那麼把異常拋給調用方也沒有任何意義,並且API每拋出一個CheckedException,也就意味着API調用方須要多一個catch,則將讓程序變的不夠簡潔。
  有些狀況下,咱們在設計API時,能夠經過一些技巧來避免拋出CheckException。好比下面這段代碼中,代碼的意圖在於設計了一個File類,而整個File類須要對外提供提供一個執行的方法exec,可是在執行的時候須要驗證該文件的MD5值是否正確,execShell裏面給出了兩種方案,第一種是隻對外提供一個方法,在該方法中先驗證MD5值,而後執行文件,在驗證MD5值的時候,會拋出CheckedException,因而exec函數向外拋出了一個CodeException,調用該函數的程序不得不去處理該異常,而方案二則是對外提供了兩個函數:isCheckSumRightexec2,前者執行MD5值驗證邏輯,當驗證經過則返回true,不然返回false,exec2則是函數的執行主體。這樣設計API,調用時先調用isCheckSumRight,而後在掉用exec2,這樣能夠免去CheckedException,讓程序更加美觀,同時API也能夠更加靈活。可是這樣去重構有兩個不太適用的場景,一個是當併發調用時,若是沒有作線程安全控制,那麼會存在線程安全問題,由於在isCheckSumRightexec2之間的瞬間可能會發生狀態的改變,另一個就是若是拆分紅兩個函數以後,這兩個函數之間有重複的邏輯,那麼爲了性能考慮,這樣的拆分也不值得。好比狀態檢查函數裏面是檢查一個文件是否能夠被打開,若是能夠被打開就在主體函數裏面去執行讀取文件操做,可是在主體文件中讀取文件時咱們仍然須要將文件打開一次,因而這個時候就存在了重複,下降了性能,爲了代碼的美觀,去作這樣的設計不是好的設計。

public void execShell(File file) {
    /*
     * 方案一
     */
    try {
        file.exec();
    } catch (CodecException e) {
        // TODO: handle this exception
    }
    /*
     * 方案二
     * 不適用的情景:
     * 1.沒有同步措施的併發訪問
     * 2.狀態檢查的執行邏輯與正文函數重複
     */
    if (file.isCheckSumRight()) {
        file.exec2();
    } else {
        // TODO: do something
    }
}
/**
 * File的定義 
 * @author zhenchao.wang 2016-08-09 16:29
 * @version 1.0.0
 */
public class File<T> {
    private T content;
    private String md5Checksum;
    public File(T content, String md5Checksum, boolean isCheckSum) {
        this.content = content;
        this.md5Checksum = md5Checksum;
    }
    /**
     * 執行文件
     * v1.0
     *
     * @throws CodecException
     */
    public void exec() throws CodecException {
        String md5Value;
        try {
            md5Value = CodecUtil.MD5(String.valueOf(this.content));
        } catch (NoSuchAlgorithmException e) {
            throw new CodecException("no such no such algorithm", e);
        } catch (UnsupportedEncodingException e) {
            throw new CodecException("unsupported encoding", e);
        }
        if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
            // TODO: do something
        } else {
            // TODO: do something
        }
    }
    /**
     * 文件內容校驗
     *
     * @return
     */
    public boolean isCheckSumRight() {
        boolean checkResult = false;
        String md5Value;
        try {
            md5Value = CodecUtil.MD5(String.valueOf(this.content));
        } catch (NoSuchAlgorithmException e) {
            return checkResult;
        } catch (UnsupportedEncodingException e) {
            return checkResult;
        }
        if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
            checkResult = true;
        }
        return checkResult;
    }
    /**
     * 執行文件
     * v2.0
     *
     */
    public void exec2() {
        // TODO: do something
    }
}

3.何時使用UnCheckedException?

  Error和UnCheckedException的共同點都是UnChecked,可是以前有說過通常咱們不該該主動使用Error,因此當須要拋出Unchecked異常的時候,UnCheckedException是咱們最好的選擇,咱們也能夠本身去繼承RuntimeException類來定義本身的UncheckedException。簡單的說,當咱們發現CheckedException不適用的時候,咱們應該去使用UncheckedException,而不是Error。

  • 儘可能使用jdk提供的異常類型

  「不要重複發明車輪」是軟件開發中的一句至理名言,在異常的選擇上也一樣適用,JDK內置了許多Exception,當咱們須要使用的時候咱們應該先去檢查JDK是否有提供,而不是去實現一個自定義的異常。這樣作主要有以下兩點好處:
1.這樣的API設計更加容易讓調用方理解,減小了調用方的學習成本
2.減小了異常類的數目,能夠下降程序編譯、加載的時間
  在使用JDK提供的Exception類的時候,必定要去閱讀一下docs對於該Exception類的說明,不能只是簡單的依據類名去揣測類的用途,一旦揣測錯誤,將會給API的調用方形成困惑。

  • 使用「異常轉譯」讓語義更清晰

  在軟件設計的時候,咱們一般會進行分層處理,典型的就是「三層軟件設計架構」,即web層、業務邏輯層(service),以及持久化層(orm),三層之間相互隔離,不能跨層調用。雖然不少開發者在開發的時候會去注重分層,可是在異常處理方面仍是會出現「跨層傳播」的現象,好比咱們在orm層拋出的dao異常,由於在service裏面沒有通過處理就直接拋給了web層,這樣就出現了「跨層傳播」,這樣沒有任何好處,web開發人員在調用服務的時候,須要去捕獲一個SQLException,除了不知道如何去處理,也會讓開發人員對於底層的設計疑惑,因此在這種時候,咱們能夠經過「異常轉譯」,在service對orm層拋出的異常進行處理以後,封裝成service層的異常再拋給web層,以下面的代碼:

public User getUserInfo(long userId) throws ServiceException {
    User user = new User();
    try {
        user = userDao.findUserById(userId);
    } catch (SQLException e) {
        log.error("DB operate exception!", e);
        // 異常轉譯
        throw new ServiceException("DB operate exception!", e);
    }
    return user;
}

  異常轉譯值得提倡,可是不能濫用,咱們仍是要堅持對CheckedException處理的原則,不能僅僅捕獲後就轉譯拋出,這樣只是讓異常在語義上更加易於理解,可是對API的調用並無起到實質性的幫助。

  • 推薦爲方法的checken exception和unchecken exception編寫文檔

  在方法的聲明中,咱們應該爲每一個方法編寫可能會拋出的全部異常(CheckedException和UnCheckedException)利用@throws關鍵字編寫文檔,包括異常的名稱,是CheckedException仍是UnCheckedException,異常發生的條件等,從而讓API的調用方可以正確的使用。
  這裏不得不吐槽一下,到目前爲止我所接觸的歷史項目就沒有一個是註釋及格的(個人要求並不高~),一行註釋都沒有的也是大有所在,因此每次去看別人的代碼都很痛苦。這裏還得感謝一下飛哥(@陳飛),在阿里的時候,飛哥做爲個人導師對個人編碼風格的糾正起到了很是重要的做用。

  • 留下罪症,不要偷吃

  當異常發生的時候,咱們一般會將其記錄到日誌文件,過後經過分析日誌來查明形成錯誤的緣由,異常在被拋出的時候,咱們也能夠在棧軌跡中添加一些描述信息,從而讓異常更加易於理解,對於描述信息的設置,咱們最主要的是要「保留做案現場」,即形成異常的實時條件,好比當形成ArrayIndexOutOfBoundsException的數組的上下界,以及當前訪問的下標等數組,這樣會爲咱們在後面排錯起到極大的幫助做用,由於有些bug是很難被重現的。   與「保留做案現場」這一良好習慣背道而馳的是「吃異常」,若是不是真的須要,千萬不要把異常給吃了,哪怕你不去處理,用日誌記錄一下都比吃掉它要強不少。說一個故事背景,有一次在重構一個「訂單審覈服務」項目的時候,將服務部署啓動以後,啓動日誌一切正常,一切都是那麼的美好,可是訂單審覈的結果始終不正確,可是日誌就是沒有錯誤,無奈只能去看源碼,而後在歷史代碼裏面發現了下面這樣一串:

try{
    // 具體算法模型文件加載邏輯
}catch (FileNotFoundException e) {
    try{
        throw new IOException(e);
    } catch (IOException ee) {
        // 他把異常給吃了!!!
    }
}

  先不去討論這段代碼寫的是有多奇葩,形成上面現象的主要緣由是當算法模型文件找不到,發生異常的時候,這個異常吃掉了,catch以後不作任何處理,連用日誌記錄一下都沒有,它就這樣無聲無息地從這個宇宙中消失了,給我形成了10000點傷害。因此若是不是必須,咱們在代碼裏面千萬不要去把異常吃掉,這樣會讓本來提高系統穩定性的java異常處理機制成爲bug的良好棲息之地!

最後,若是你們對於java異常處理體系有更深的理解或者更好的實踐,也歡迎在評論中提出,你們一塊兒探討,共同進步~


參考
  1. Effective Java 2nd Edition
  2. 透過JVM看Exception的本質
相關文章
相關標籤/搜索