相信大家每天都在使用Java異常機制,也相信大家對try-catch-finally執行流程爛熟於胸。本文將介紹Java異常機制的一些細節問題,這些問題雖然很小,但對程式碼效能、可讀性有著較為重要的作用。
1. Java異常體系介紹
在學習一項技術前,一定要先站在制高點俯瞰技術全域性,從巨集觀上把控某項技術的整個脈絡結構。這樣你就可以有針對性地學習該體系結構中最重要的知識點,並且在學習細節的時候不至於鑽入牛角尖。所以,在介紹Java異常你所不知道的一些祕密之前,先讓大家複習一下Java異常體系。
Throwable是整個Java異常體系的頂層父類,它有兩個子類,分別是:Error和Exception。
Error表示系統致命錯誤,程式無法處理的錯誤,比如OutOfMemoryError、ThreadDeath等。這些錯誤發生時,Java虛擬機器只能終止執行緒。
Exception是程式本身可以處理的異常,這種異常分兩大類執行時異常和非執行時異常。
執行時異常都是RuntimeException類及其子類異常,如NullPointerException、IndexOutOfBoundsException等,這些異常屬於unchecked異常,開發人員可以選擇捕獲處理,也可以不處理。這些異常一般是由程式邏輯錯誤引起的,程式應該從邏輯角度儘可能避免這類異常的發生。
在Exception異常體系中,除了RuntimeException類及其子類的異常,均屬於checked異常。當你呼叫了丟擲這些異常的方法後,必須要處理這些異常。如果不處理,程式就不能編譯通過。如:IOException、SQLException、使用者自定義的Exception異常等。
2. try-with-resources
在JDK 1.7之前,處理IO操作非常麻煩。由於IOException屬於checked異常,呼叫者必須通過try-catch處理他們;又因為IO操作完成後需要關閉資源,然而關閉資源的close()方法也會丟擲checked異常,因此也需要使用try-catch處理該異常。因此,原本小小的一段IO操作程式碼會被複雜的try-catch巢狀包裹,從而極大地降低了程式的可讀性。
一個標準的IO操作程式碼如下:
public class Demo {
public static void main(String[] args) {
BufferedInputStream bin = null;
BufferedOutputStream bout = null;
try {
bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")));
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bin != null) {
try {
bin.close();
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bout != null) {
try {
bout.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
}
複製程式碼
上述程式碼使用一個輸出流bin和一個輸入六bout,將一個檔案中的資料寫入另一個檔案。由於IO資源非常寶貴,因此在完成操作後,必須在finally中分別釋放這兩個資源。並且為了能夠正確釋放這兩個IO資源,需要用兩個finally程式碼塊巢狀的方式完成資源的釋放。
在上述40行程式碼中,真正處理IO操作的程式碼不到10行,而其餘30行程式碼都是用於保證資源合理釋放的。這顯然導致程式碼可讀性較差。不過好在JDK 1.7提供了try-with-resources解決這一問題。修改後的程式碼如下:
public class TryWithResource {
public static void main(String[] args) {
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
我們需要將資源宣告程式碼放入try後的括號中,然後將資源處理程式碼放入try後的{}中,catch程式碼塊中仍然進行異常處理,並且無需寫finally程式碼塊。
那麼,try-with-resources為什麼能夠避免大量資源釋放程式碼呢?答案是,由Java編譯器來幫我們新增finally程式碼塊。注意,編譯器只會新增finally程式碼塊,而資源釋放的過程需要資源提供者提供。
在JDK 1.7中,所有的IO類都實現了AutoCloseable
介面,並且需要實現其中的close()
函式,資源釋放過程需要在該函式中完成。
那麼,編譯器在編譯時,會自動新增finally程式碼塊,並將close()
函式中的資源釋放程式碼加入finally程式碼塊中。從而提高程式碼可讀性。
異常遮蔽問題
在try-catch-finally程式碼塊中,如果try塊、catch塊和finally塊均有異常丟擲,那麼最終只能丟擲finally塊中的異常,而try塊和catch塊中的異常將會被遮蔽。這就是異常遮蔽問題。如下面程式碼所示:
public class Connection implements AutoCloseable {
public void sendData() throws Exception {
throw new Exception("send data");
}
@Override
public void close() throws Exception {
throw new MyException("close");
}
}
複製程式碼
首先定義一個Connection
類,該類提供了sendData()
和close()
方法,為了實驗需要,這兩個方法沒有任何業務邏輯,都直接丟擲一個異常。下面我們使用這個類。
public class TryWithResource {
public static void main(String[] args) {
try {
test();
}
catch (Exception e) {
e.printStackTrace();
}
}
private static void test() throws Exception {
Connection conn = null;
try {
conn = new Connection();
conn.sendData();
}
finally {
if (conn != null) {
conn.close();
}
}
}
}
複製程式碼
當執行conn.sendData()
時,該方法會將異常拋給呼叫者main()
,但在拋之前會先執行finally塊。當執行finally塊中的conn.close()
方法時,也會向呼叫者拋一個異常。此時,由try塊丟擲的異常將會被覆蓋,main方法中僅列印finally塊中的異常。其結果如下所示:
basic.exception.MyException: close
at basic.exception.Connection.close(Connection.java:10)
at basic.exception.TryWithResource.test(TryWithResource.java:82)
at basic.exception.TryWithResource.main(TryWithResource.java:7)
......
複製程式碼
這就是try-catch-finally的異常遮蔽問題,而try-with-resources能很好地解決這一問題。那麼,它是如何解決這一問題的呢?
我們首先將這段程式碼用try-with-resources改寫:
public class TryWithResource {
public static void main(String[] args) {
try {
test();
}
catch (Exception e) {
e.printStackTrace();
}
}
private static void test() throws Exception {
Connection conn = null;
try (conn = new Connection();) {
conn.sendData();
}
}
}
複製程式碼
為了能清楚地瞭解Java編譯器在try-with-resources上所做的事情,我們反編譯這段程式碼,得到如下程式碼:
public class TryWithResource {
public TryWithResource() {
}
public static void main(String[] args) {
try {
// 資源宣告程式碼
Connection e = new Connection();
Throwable var2 = null;
try {
// 資源使用程式碼
e.sendData();
} catch (Throwable var12) {
var2 = var12;
throw var12;
} finally {
// 資源釋放程式碼
if(e != null) {
if(var2 != null) {
try {
e.close();
} catch (Throwable var11) {
var2.addSuppressed(var11);
}
} else {
e.close();
}
}
}
} catch (Exception var14) {
var14.printStackTrace();
}
}
}
複製程式碼
最核心的操作是22行var2.addSuppressed(var11);
。編譯器將try塊和catch塊中的異常先存入一個區域性變數,當finally塊中再次丟擲異常時,通過之前異常的addSuppressed()
方法將當前異常新增至其異常棧中,從而保證了try塊和catch塊中的異常不丟失。當使用了try-with-resources後,輸出結果如下所示:
java.lang.Exception: send data
at basic.exception.Connection.sendData(Connection.java:5)
at basic.exception.TryWithResource.main(TryWithResource.java:14)
......
Suppressed: basic.exception.MyException: close
at basic.exception.Connection.close(Connection.java:10)
at basic.exception.TryWithResource.main(TryWithResource.java:15)
... 5 more
複製程式碼
3. try-catch-finally執行流程
眾所周知,首先執行try中程式碼,若未發生異常,則直接執行finally中程式碼;若發生異常,則先執行catch中程式碼後,再執行finally中程式碼。
相信上述流程大家都爛熟於胸,但如果try塊和catch塊中出現了return呢?出現了throw呢?此時執行順序就會發生變化。
但是,萬變不離其中,大家只要記住一點:fianlly中的return、throw會覆蓋try、catch中的return、throw。此話怎講?請繼續往下閱讀。
要解釋這個問題,先來看一個例子,請問下面程式碼中的test()函式會返回什麼結果?
public int test() {
try {
int a = 1;
a = a / 0;
return a;
} catch (Exception e) {
return -1;
} finally{
return -2;
}
}
複製程式碼
答案是-2。
當執行程式碼a = a / 0;
時發生異常,try塊中它之後的程式碼便不再執行,而是直接執行catch中程式碼;
在catch塊中,當在執行return -1
前,先會執行finally塊;
由於finally塊中有return語句,因此catch中的return將會被覆蓋,直接執行fianlly中的return -2
後程式結束。因此輸出結果是-2。
同樣地,將return換成throw也是一樣的結果,finally會覆蓋try、catch塊中的return、throw。
特別提醒:禁止在finally塊中使用return語句!這裡舉例子只是告訴你Java的這一特性,在實際開發中禁止使用!
4. Optional優雅解決NPE問題
空指標異常是一個執行時異常,對於這一類異常,如果沒有明確的處理策略,那麼最佳實踐在於讓程式早點掛掉,但是很多場景下,不是開發人員沒有具體的處理策略,而是根本沒有意識到空指標異常的存在。當異常真的發生的時候,處理策略也很簡單,在存在異常的地方新增一個if語句判定即可,但是這樣的應對策略會讓我們的程式出現越來越多的null判定,我們知道一個良好的程式設計,應該讓程式碼中儘量少出現null關鍵字,而java8所提供的Optional類則在減少NullPointException的同時,也提升了程式碼的美觀度。但首先我們需要明確的是,它並 不是對null關鍵字的一種替代,而是對於null判定提供了一種更加優雅的實現,從而避免NullPointException。
假設存在如下Person類:
class Person{
private long id;
private String name;
private int age;
// 省略setter、getter
}
複製程式碼
當我們呼叫某一個介面,獲取到一個Person物件,此時可以通過如下方法對它進行處理:
ofNullable(person)
- 將Person物件轉化成Optional物件
- 允許person為空
Optional<Person> personOpt = Optional.ofNullable(person);
複製程式碼
T orElse(T other)
- 若為空,則賦予預設值
personOpt.orElse(new Person("柴毛毛"));
複製程式碼
T orElseGet(Supplier<? extends T> other)
- 若為空,則執行相應程式碼,並返回預設值
personOpt.orElseGet(()->{
Person person = new Person();
person.setName("柴毛毛");
person.setAge(20);
return person;
});
複製程式碼
<X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier)
- 若為空,則拋異常
personOpt.orElseThrow(CommonBizException::new);
複製程式碼
<U>Optional<U> map(Function<? super T,? extends U> mapper)
- 對映(獲取person中的姓名)
String name = personOpt
.map(Person::getName)
.orElseThrow(CommonBizException::new)
.map(Optional::get);
複製程式碼
5. 異常處理規約
- Java 類庫中定義的一類 RuntimeException 可以通過預先檢查進行規避,而不應該通過 catch 來處理,比如: IndexOutOfBoundsException,NullPointerException等等。
- 正例:
if (obj != null) {...}
- 反例:
try { obj.method() } catch (NullPointerException e) {...}
- 正例:
- 異常不要用來做流程控制,條件控制,因為異常的處理效率比條件分支低。
- 對大段程式碼進行 try-catch,這是不負責任的表現。catch 時請分清穩定程式碼和非穩定程式碼,穩定程式碼指的是無論如何不會出錯的程式碼。對於非穩定程式碼的catch儘可能進行區分異常型別,再做對應的異常處理。
- 捕獲異常是為了處理它,不要捕獲了卻什麼都不處理而拋棄之,如果不想處理它,請 將該異常拋給它的呼叫者。最外層的業務使用者,必須處理異常,將其轉化為使用者可以理解的內容。
- 有 try 塊放到了事務程式碼中,catch 異常後,如果需要回滾事務,一定要注意手動回滾事務。
- 不能在 finally 塊中使用 return,finally 塊中的 return 返回後方法結束執行,不會再執行 try 塊中的 return 語句。
- finally 塊必須對資源物件、流物件進行關閉,有異常也要做 try-catch。 說明:如果 JDK7 及以上,可以使用 try-with-resources 方式。
- 有 try 塊放到了事務程式碼中,catch 異常後,如果需要回滾事務,一定要注意手動回滾事務。
- 捕獲異常與拋異常,必須是完全匹配,或者捕獲異常是拋異常的父類。也就是丟擲的異常必須是所捕獲異常或其子類。這樣才能讓異常大而化小小而化了。
- 本規約明確防止 NPE 是呼叫者的責任。即使被呼叫方法返回空集合或者空物件,對呼叫 者來說,也並非高枕無憂,必須考慮到遠端呼叫失敗,執行時異常等場景返回 null 的情況。
- 定義時區分unchecked/checked 異常,避免直接使用RuntimeException丟擲, 更不允許丟擲 Exception 或者 Throwable,應使用有業務含義的自定義異常。推薦業界已定義 過的自定義異常,如:DAOException / ServiceException 等。
- 在程式碼中使用“拋異常”還是“返回錯誤碼”:
- 對於公司外的 http/api 開放介面必須 使用“錯誤碼”;
- 而應用內部推薦異常丟擲;
- 跨應用間 RPC 呼叫優先考慮使用 Result 方式,封裝 isSuccess、“錯誤碼”、“錯誤簡簡訊息”。