
Exception 和 Error 都是繼承了 Throwable 類,在 Java 中只有 Throwable 類型的實(shí)例才可以被拋出(throw)或者捕獲(catch),它是異常處理機(jī)制的基本組成類型。
Exception 和 Error 體現(xiàn)了 Java 平臺(tái)設(shè)計(jì)者對不同異常情況的分類。Exception 是程序正常運(yùn)行中,可以預(yù)料的意外情況,可能并且應(yīng)該被捕獲,進(jìn)行相應(yīng)處理。
Error是指在正常情況下,不大可能出現(xiàn)的情況,絕大部分的 Error都會(huì)導(dǎo)致程序(比如 JVM 自身)處于非正常的、不可恢復(fù)狀態(tài)。既然是非正常情況,所以不便于也不需要捕獲,常見的比如 OutOfMemoryError 之類,都是 Error 的子類。
Exception 又分為可檢查(checked)異常和不檢查(unchecked)異常,可檢查異常在源代碼里必須顯式地進(jìn)行捕獲處理,這是編譯期檢查的一部分。前面我介紹的不可查的 Error,是 Throwable 不是 Exception。
不檢查異常就是所謂的運(yùn)行時(shí)異常,類似 NullPointerException、ArrayIndexOutOfBoundsException 之類,通常是可以編碼避免的邏輯錯(cuò)誤,具體根據(jù)需要來判斷是否需要捕獲,并不會(huì)在編譯期強(qiáng)制要求。
考點(diǎn)分析:
第一,理解 Throwable、Exception、Error的設(shè)計(jì)和分類。比如,掌握那些應(yīng)用最為廣泛的子類,以及如何自定義異常等。

其中有些子類型,最好重點(diǎn)理解一下,比如 NoClassDefFoundError 和 ClassNotFoundException 有什么區(qū)別,這也是個(gè)經(jīng)典的入門題目。
第二,理解 Java 語言中操作 Throwable 的元素和實(shí)踐。掌握最基本的語法是必須的,如 try-catch-finally 塊,throw、throws 關(guān)鍵字等。與此同時(shí),也要懂得如何處理典型場景。
異常處理代碼比較繁瑣,比如我們需要寫很多千篇一律的捕獲代碼,或者在 finally 里面做一些資源回收工作。隨著 Java 語言的發(fā)展,引入了一些更加便利的特性,比如 try-with-resources 和 multiple catch,具體可以參考下面的代碼段。在編譯時(shí)期,會(huì)自動(dòng)生成相應(yīng)的處理邏輯,比如,自動(dòng)按照約定俗成 close 那些擴(kuò)展了 AutoCloseable 或者 Closeable 的對象。
try (BufferedReader br = new BufferedReader(…);
BufferedWriter writer = new BufferedWriter(…)) {// Try-with-resources
// do something
catch ( IOException | XEception e) {// Multiple catch
// Handle it
}
知識(shí)擴(kuò)張:
前面談的大多是概念性的東西,下面我們來看一些實(shí)戰(zhàn)中的例子。
先來看第一個(gè)吧,下面的代碼反映了異常處理中哪些不當(dāng)之處?
try {
// 業(yè)務(wù)代碼
// …
Thread.sleep(1000L);
} catch (Exception e) {
// Ignore it
}
這段代碼雖然很短,但是已經(jīng)違反了異常處理的兩個(gè)基本原則。
第一,盡量不要捕獲類似 Exception 這樣的通用異常,而是應(yīng)該捕獲特定異常,在這里是 Thread.sleep() 拋出的 InterruptedException。
這是因?yàn)樵谌粘5拈_發(fā)和合作中,我們讀代碼的機(jī)會(huì)往往超過寫代碼,軟件工程是門協(xié)作的藝術(shù),所以我們有義務(wù)讓自己的代碼能夠直觀地體現(xiàn)出盡量多的信息,而泛泛的 Exception 之類,恰恰隱藏了我們的目的。另外,我們也要保證程序不會(huì)捕獲到我們不希望捕獲的異常。比如,你可能更希望 RuntimeException 被擴(kuò)散出來,而不是被捕獲。
進(jìn)一步講,除非深思熟慮了,否則不要捕獲 Throwable 或者 Error,這樣很難保證我們能夠正確程序處理OutOfMemoryError。
第二,不要生吞(swallow)異常。這是異常處理中要特別注意的事情,因?yàn)楹芸赡軙?huì)導(dǎo)致非常難以診斷的詭異情況。
生吞異常,往往是基于假設(shè)這段代碼可能不會(huì)發(fā)生,或者感覺忽略異常是無所謂的,但是千萬不要在產(chǎn)品代碼做這種假設(shè)!
如果我們不把異常拋出來,或者也沒有輸出到日志(Logger)之類,程序可能在后續(xù)代碼以不可控的方式結(jié)束。沒人能夠輕易判斷究竟是哪里拋出了異常,以及是什么原因產(chǎn)生了異常。
再來看看第二段代碼
try {
// 業(yè)務(wù)代碼
// …
} catch (IOException e) {
e.printStackTrace();
}
這段代碼作為一段實(shí)驗(yàn)代碼,它是沒有任何問題的,但是在產(chǎn)品代碼中,通常都不允許這樣處理。你先思考一下這是為什么呢?
我們先來看看printStackTrace()的文檔,開頭就是“Prints this throwable and its backtrace to the standard error stream”。問題就在這里,在稍微復(fù)雜一點(diǎn)的生產(chǎn)系統(tǒng)中,標(biāo)準(zhǔn)出錯(cuò)(STERR)不是個(gè)合適的輸出選項(xiàng),因?yàn)槟愫茈y判斷出到底輸出到哪里去了。
尤其是對于分布式系統(tǒng),如果發(fā)生異常,但是無法找到堆棧軌跡(stacktrace),這純屬是為診斷設(shè)置障礙。所以,最好使用產(chǎn)品日志,詳細(xì)地輸出到日志系統(tǒng)里。
我們接下來看下面的代碼段,體會(huì)一下Throw early, catch late 原則。
public void readPreferences(String fileName){
InputStream in = new FileInputStream(fileName);
}
如果 fileName 是 null,那么程序就會(huì)拋出NullPointerException,但是由于沒有第一時(shí)間暴露出問題,堆棧信息可能非常令人費(fèi)解,往往需要相對復(fù)雜的定位。這個(gè) NPE 只是作為例子,實(shí)際產(chǎn)品代碼中,可能是各種情況,比如獲取配置失敗之類的。在發(fā)現(xiàn)問題的時(shí)候,第一時(shí)間拋出,能夠更加清晰地反映問題。
我們可以修改一下,讓問題“throw early”,對應(yīng)的異常信息就非常直觀了。
public void readPreferences(String filename) {
Objects. requireNonNull(filename);
InputStream in = new FileInputStream(filename);
}
至于“catch late”,其實(shí)是我們經(jīng)??鄲赖膯栴},捕獲異常后,需要怎么處理呢?最差的處理方式,就是我前面提到的“生吞異?!?,本質(zhì)上其實(shí)是掩蓋問題。如果實(shí)在不知道如何處理,可以選擇保留原有異常的 cause 信息,直接再拋出或者構(gòu)建新的異常拋出去。在更高層面,因?yàn)橛辛饲逦模I(yè)務(wù))邏輯,往往會(huì)更清楚合適的處理方式是什么。
有的時(shí)候,我們會(huì)根據(jù)需要自定義異常,這個(gè)時(shí)候除了保證提供足夠的信息,還有兩點(diǎn)需要考慮:
- 是否需要定義成 Checked Exception,因?yàn)檫@種類型設(shè)計(jì)的初衷更是為了從異常情況恢復(fù),作為異常設(shè)計(jì)者,我們往往有充足信息進(jìn)行分類。
-
在保證診斷信息足夠的同時(shí),也要考慮避免包含敏感信息,因?yàn)槟菢涌赡軐?dǎo)致潛在的安全問題。如果我們看 Java 的標(biāo)準(zhǔn)類庫,你可能注意到類似
java.net.ConnectException,出錯(cuò)信息是類似Connection refused (Connection refused),而不包含具體的機(jī)器名、IP、端口等,一個(gè)重要考量就是信息安全。類似的情況在日志中也有,比如,用戶數(shù)據(jù)一般是不可以輸出到日志里面的。
業(yè)界有一種爭論(甚至可以算是某種程度的共識(shí)),Java 語言的 Checked Exception 也許是個(gè)設(shè)計(jì)錯(cuò)誤,反對者列舉了幾點(diǎn):
Checked Exception 的假設(shè)是我們捕獲了異常,然后恢復(fù)程序。但是,其實(shí)我們大多數(shù)情況下,根本就不可能恢復(fù)。Checked Exception 的使用,已經(jīng)大大偏離了最初的設(shè)計(jì)目的。
Checked Exception 不兼容 functional 編程,如果你寫過 Lambda/Stream 代碼,相信深有體會(huì)。
很多開源項(xiàng)目,已經(jīng)采納了這種實(shí)踐,比如 Spring、Hibernate 等,甚至反映在新的編程語言設(shè)計(jì)中,比如 Scala 等。如果有興趣,你可以參考:
http://literatejava.com/exceptions/checked-exceptions-javas-biggest-mistake/。
當(dāng)然,很多人也覺得沒有必要矯枉過正,因?yàn)榇_實(shí)有一些異常,比如和環(huán)境相關(guān)的 IO、網(wǎng)絡(luò)等,其實(shí)是存在可恢復(fù)性的,而且 Java 已經(jīng)通過業(yè)界的海量實(shí)踐,證明了其構(gòu)建高質(zhì)量軟件的能力。
我們從性能角度來審視一下 Java 的異常處理機(jī)制,這里有兩個(gè)可能會(huì)相對昂貴的地方:
try-catch 代碼段會(huì)產(chǎn)生額外的性能開銷,或者換個(gè)角度說,它往往會(huì)影響 JVM 對代碼進(jìn)行優(yōu)化,所以建議僅捕獲有必要的代碼段,盡量不要一個(gè)大的 try 包住整段的代碼;與此同時(shí),利用異常控制代碼流程,也不是一個(gè)好主意,遠(yuǎn)比我們通常意義上的條件語句(if/else、switch)要低效。
Java 每實(shí)例化一個(gè) Exception,都會(huì)對當(dāng)時(shí)的棧進(jìn)行快照,這是一個(gè)相對比較重的操作。如果發(fā)生的非常頻繁,這個(gè)開銷可就不能被忽略了。
所以,對于部分追求極致性能的底層類庫,有種方式是嘗試創(chuàng)建不進(jìn)行??煺盏?Exception。這本身也存在爭議,因?yàn)檫@樣做的假設(shè)在于,我創(chuàng)建異常時(shí)知道未來是否需要堆棧。問題是,實(shí)際上可能嗎?小范圍或許可能,但是在大規(guī)模項(xiàng)目中,這么做可能不是個(gè)理智的選擇。如果需要堆棧,但又沒有收集這些信息,在復(fù)雜情況下,尤其是類似微服務(wù)這種分布式系統(tǒng),這會(huì)大大增加診斷的難度。
當(dāng)我們的服務(wù)出現(xiàn)反應(yīng)變慢、吞吐量下降的時(shí)候,檢查發(fā)生最頻繁的 Exception 也是一種思路。
文章的最后,知識(shí)的挖掘還沒有到最后,我們可以思考一個(gè)問題:
對于異常處理編程,不同的編程范式也會(huì)影響到異常處理策略,比如,現(xiàn)在非?;馃岬姆磻?yīng)式編程(Reactive Stream),因?yàn)槠浔旧硎钱惒?、基于事件機(jī)制的,所以出現(xiàn)異常情況,決不能簡單拋出去;另外,由于代碼堆棧不再是同步調(diào)用那種垂直的結(jié)構(gòu),這里的異常處理和日志需要更加小心,我們看到的往往是特定 executor 的堆棧,而不是業(yè)務(wù)方法調(diào)用關(guān)系。對于這種情況,你有什么好的辦法嗎?
可在留言區(qū)一起探討哦,還有很多相關(guān)知識(shí)沒有羅列到,也可以在留言區(qū)評論。