優雅地處理異常真是一門學問啊!
01、
你有沒有這樣的印象,當你想要更新一款 APP 的時候,它的更新日誌裡總有這麼一兩句描述:
- 修復若干 bug
- 殺了某程式設計師祭天,併成功解決掉他遺留的 bug
作為一名負責任的程式設計師,我們當然希望程式不會出現 bug,因為 bug 出現的越多,間接地證明了我們的程式設計能力越差,至少領導是這麼看的。
事實上,領導是不會拿自己的腦袋宣言的:“我們的程式絕不存在任何一個 bug。”但當程式出現 bug 的時候,領導會毫不猶豫地選擇讓程式設計師背鍋。
為了讓自己少背鍋,我們可以這樣做:
- 在編碼階段合理使用異常處理機制,並記錄日誌以備後續分析
- 在測試階段進行大量有效的測試,在使用者發現錯誤之前發現錯誤
還有一點需要做的是,在敲程式碼之前,學習必要的程式設計常識,做到兵馬未動,糧草先行。
02、
在 Java 中,異常(Throwable)的層次結構大致如下。
Error 類異常描述了 Java 執行時系統的內部錯誤,比如最常見的 OutOfMemoryError
和 NoClassDefFoundError
。
導致 OutOfMemoryError
的常見原因有以下幾種:
- 記憶體中載入的資料量過於龐大,如一次從資料庫取出過多資料;
- 集合中的物件引用在使用完後未清空,使得 JVM 不能回收;
- 程式碼中存在死迴圈或迴圈產生過多重複的物件;
- 啟動引數中記憶體的設定值過小;
OutOfMemoryError
的解決辦法需要視情況而定,但問題的根源在於程式的設計不夠合理,需要通過一些效能檢測才能找得出引發問題的根源。
導致 NoClassDefFoundError
的原因只有一個,Java 虛擬機器在編譯時能找到類,而在執行時卻找不到。
NoClassDefFoundError
的解決辦法,我截了一張圖,如上所示。當一個專案引用了另外一個專案時,切記這一步!
Exception(例外)通常可分為兩類,一類是寫程式碼的人造成的,比如訪問空指標(NullPointerException
if (str == null || "".eqauls(str)) {
}
另外一類異常不是寫程式碼的人造成的,要麼需要丟擲,要麼需要捕獲,比如說常見的 IOException
。
丟擲的示例。
public static void main(String[] args) throws IOException {
InputStream is = new FileInputStream("沉默王二.txt");
int b;
while ((b = is.read()) != -1) {
}
}
捕獲的示例。
public static void main(String[] args) {
try {
InputStream is = new FileInputStream("沉默王二.txt");
int b;
while((b = is.read()) != -1) {
}
} catch (IOException e) {
e.printStackTrace();
}
}
03、
當丟擲異常的時候,剩餘的程式碼就會終止執行,這時候一些資源就需要主動回收。Java 的解決方案就是 finally
子句——不管異常有沒有被捕獲,finally
子句裡的程式碼都會執行。
在下面的示例當中,輸入流將會被關閉,以釋放資源。
public static void main(String[] args) {
InputStream is = null;
try {
is = new FileInputStream("沉默王二.txt");
int b;
while ((b = is.read()) != -1) {}
} catch (IOException e) {
e.printStackTrace();
} finally {
is.close();
}
}
但我總覺得這樣的設計有點問題,因為 close()
方法同樣會丟擲 IOException
:
public void close() throws IOException {}
也就是說,呼叫 close()
的 main 方法要麼需要丟擲 IOException
,要麼需要在 finally
子句裡重新捕獲 IOException
。
選擇前一種就會讓 try catch
略顯尷尬,就像下面這樣。
public static void main(String[] args) throws IOException {
InputStream is = null;
try {
is = new FileInputStream("沉默王二.txt");
int b;
while ((b = is.read()) != -1) {}
} catch (IOException e) {
e.printStackTrace();
} finally {
is.close();
}
}
選擇後一種會讓程式碼看起來很臃腫,就像下面這樣。
public static void main(String[] args) {
InputStream is = null;
try {
is = new FileInputStream("沉默王二.txt");
int b;
while ((b = is.read()) != -1) {}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
總之,我們需要另外一種更優雅的解決方案。JDK7 新增了 Try-With-Resource
語法:如果一個類(比如 InputStream
)實現了 AutoCloseable
介面,那麼就可以將該類的物件建立在 try
關鍵字後面的括號中,當 try-catch
程式碼塊執行完畢後,Java 會確保該物件的 close
方法被呼叫。示例如下。
public static void main(String[] args) {
try (InputStream is = new FileInputStream("沉默王二.txt")) {
int b;
while ((b = is.read()) != -1) {
}
} catch (IOException e) {
e.printStackTrace();
}
}
04、
關於異常處理機制的使用,我這裡總結了一些非常實用的建議,希望你能夠採納。
1)儘量捕獲原始的異常。
實際應該捕獲 FileNotFoundException
,卻捕獲了泛化的 Exception
。示例如下。
InputStream is = null;
try {
is = new FileInputStream("沉默王二.txt");
} catch (Exception e) {
e.printStackTrace();
}
這樣做的壞處顯而易見:假如你喊“王二”,那麼我就敢答應;假如你喊“老王”,那麼我還真不敢答應,萬一你喊的我妹妹“王三”呢?
很多初學者誤以為捕獲泛化的 Exception
更省事,但也更容易讓人“丈二和尚摸不著頭腦”。相反,捕獲原始的異常能夠讓協作者更輕鬆地辨識異常型別,更容易找出問題的根源。
2)儘量不要列印堆疊後再丟擲異常
當異常發生時列印它,然後重新丟擲它,以便呼叫者能夠適當地處理它。就像下面這段程式碼一樣。
public static void main(String[] args) throws IOException {
try (InputStream is = new FileInputStream("沉默王二.txt")) {
}catch (IOException e) {
e.printStackTrace();
throw e;
}
}
這似乎考慮得很周全,但是這樣做的壞處是呼叫者可能也列印了異常,重複的列印資訊會增添排查問題的難度。
java.io.FileNotFoundException: 沉默王二.txt (系統找不到指定的檔案。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at java.io.FileInputStream.<init>(FileInputStream.java:93)
at learning.Test.main(Test.java:10)
Exception in thread "main" java.io.FileNotFoundException: 沉默王二.txt (系統找不到指定的檔案。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at java.io.FileInputStream.<init>(FileInputStream.java:93)
at learning.Test.main(Test.java:10)
3)千萬不要用異常處理機制代替判斷
我曾見過類似下面這樣奇葩的程式碼,本來應該判 null
的,結果使用了異常處理機制來代替。
public static void main(String[] args) {
try {
String str = null;
String[] strs = str.split(",");
} catch (NullPointerException e) {
e.printStackTrace();
}
}
捕獲異常相對判斷花費的時間要多得多!我們可以模擬兩個程式碼片段來對比一下。
程式碼片段 A:
long a = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
try {
String str = null;
String[] strs = str.split(",");
} catch (NullPointerException e) {
}
}
long b = System.currentTimeMillis();
System.out.println(b - a);
程式碼片段 B:
long a = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
String str = null;
if (str != null) {
String[] strs = str.split(",");
}
}
long b = System.currentTimeMillis();
System.out.println(b - a);
100000 萬次的迴圈,程式碼片段 A(異常處理機制)執行的時間大概需要 1983 毫秒;程式碼片段 B(正常判斷)執行的時間大概只需要 1 毫秒。這樣的比較雖然不夠精確,但足以說明問題。
4)不要盲目地過早捕獲異常
如果盲目地過早捕獲異常的話,通常會導致更嚴重的錯誤和其他異常。請看下面的例子。
InputStream is = null;
try {
is = new FileInputStream("沉默王二.txt");
} catch (FileNotFoundException e) {
e.printStackTrace();
}
int b;
try {
while ((b = is.read()) != -1) {
}
} catch (IOException e) {
e.printStackTrace();
}
finally {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
假如檔案沒有找到的話,InputStream
的物件引用 is 就為 null
,新的 NullPointerException
就會出現。
java.io.FileNotFoundException: 沉默王二.txt (系統找不到指定的檔案。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at java.io.FileInputStream.<init>(FileInputStream.java:93)
at learning.Test.main(Test.java:12)
Exception in thread "main" java.lang.NullPointerException
at learning.Test.main(Test.java:28)
NullPointerException
並不是程式出現問題的本因,但實際上它出現了,無形當中干擾了我們的視線。正確的做法是延遲捕獲異常,讓程式在第一個異常捕獲後就終止執行。
05、
好了,關於異常我們就說到這。異常處理是程式開發中必不可少的操作之一,但如何正確優雅地對異常進行處理卻是一門學問,好的異常處理機制可以確保程式的健壯性,提高系統的可用率。