一、前言
??只要寫過Java代碼,基本上都會遇到異常,由于以前學習的不夠系統,所以趁現在有時間,再來重新回顧及梳理下Java的異常處理。
二、異常處理
1. 概念
當一個用戶在使用我們的程序期間,如果由于程序的錯誤或一些外部環境的影響造成用戶數據的丟失,用戶可能就不會再使用這個程序了,為了避免這種事情的發生,一般我們的程序應該能做到如下幾點:
- 向用戶通報錯誤;
- 保存所有的工作結果;
- 允許用戶以妥善的形式退出程序;
針對這種異常情況,Java中使用一種稱為異常處理的錯誤捕獲機制處理。所謂異常處理,就是將控制權從錯誤產生的地方轉移給能夠處理這種情況的異常處理器(Exception Handler),其實處理器的目的就是讓我們以一種友好的交互方式將異常展示給用戶。
2. 異常分類
在Java語言中,所有的異常都是繼承自Throwable這個基礎類,我們可以看一個簡單示意圖:
可以看到,在Throwable的直屬子類中又分為了兩部分:Error和Exception。
??Error 表示Java運行時系統的內部錯誤和資源耗盡錯誤,應用程序不應該拋出這種類型的錯誤,如果真的出現了這樣的內部錯誤,處理通告給用戶,并盡力使程序安全的終止之外,我們就再也無能為力了,不過這種情況極少出現;
而我們所關注的重點,也就是Exception結構,而Exception 的實現類又分為兩部分:繼承自RuntimeException的異常和其他異常。而劃分這兩種分類的規則是:
由程序錯誤導致的異常屬于RuntimeException,而程序本身沒有問題,但由于像I/O 錯誤這類問題導致的異常屬于其他異常。
繼承自RuntimeException的異常,通常包含以下幾種情況:
- 錯誤的類型轉換;
- 數組訪問越界;
- 訪問的時候產生空指針等;
而不是繼承自RuntimeException的異常則包括:
- 試圖在文件尾部后面讀取數據;
- 打開一個不存在的文件;
- 根據類的名稱查找類,但該類不存在等情況;
所以說,有這么一句話還是很有道理的:
如果出現了RuntimeException異常,那么就一定是你的問題。
3 已檢查異常和未檢查異常
??Java語言規范將繼承自Error類或者RuntimeException類的所有異常稱為未檢查異常(unchecked),而所有其他的異常則稱為已檢查異常(checked),而編譯器則會檢查是否為所有的已檢查異常提供了異常處理器。我們先來看下已檢查異常。
3.1 已檢查異常
??已檢查異常也就是說,一個方法不僅需要告訴編譯器將要返回什么值,還要告訴編譯器有可能發生什么錯誤,比如,一段讀取問文件的代碼 知道有可能讀取的文件不存在,因此在讀取的時候就需要拋出FileNotFoundException。方法應該在其首部聲明所有可能拋出的異常,這樣就可以從首部反映出這個方法可能拋出哪類已檢查異常。
比如FileInputStream的構造方法:
public FileInputStream(String name) throws FileNotFoundException
針對我們定義的有可能被他人調用的方法,我們應該根據異常規范,在方法的首部聲明這個方法可能拋出的已檢查異常,如果有多個的話,需要使用逗號分開:
public static void callable() throws ExecutionException, InterruptedException, TimeoutException
而通常情況下,有兩種情況需要我們拋出異常:
- 調用一個拋出已檢查異常的方法,例如FileInputStream構造器;
- 程序運行中出現錯誤,并且利用throw 語句拋出一個已檢查異常;
3.2 未檢查異常
??對未檢查異常,我們不用像已檢查異常那樣在方法的首部聲明可能拋出的異常,因為運行時異常完全是可控的,就是說,我們應該通過程序盡量避免未檢查異常的發生。而如果是Error異常的話,自然不用手動拋出,任何代碼都有拋出Error異常的風險,而我們是無法控制該類異常的。
所以說,一個方法需要聲明所有可能拋出的已檢查異常,而未檢查異常要么不可控制(Error),要么就應該避免發生(RuntimeException)。
可能需要注意一點,就是子類繼承父類方法問題:
子類方法中聲明的異常不能比父類的范圍更大,也就是說,子類可以拋出更特定的異?;蛘卟粧伋霎惓?;而如果父類中沒有拋出任何異常,那么子類也不能拋出任何已檢查異常。
當然,除了拋出異常,還可以捕獲異常,在下文我們會來介紹異常的捕獲。
3. 拋出異常
??我們是通過 throw
關鍵字來拋出異常的,比如說,讀取文件的時候,如果文件內容不包含我們所需要的,那我們可以手動拋出一個IOException;再比如,如果某一個對象不能為空,那么我們可以拋出一個繼承自RuntimeException的自定義異常:
public static void main(String[] args) throws IOException {
...
String content = "";
if (!content.contains("hello")) {
throw new IOException();
}
}
public static void main(String[] args) {
String content = null;
if (content == null) {
throw new RuntimeException();
}
}
相應的,如果是拋出已檢查異常,需要在方法首部進行聲明該異常。
4. 自定義異常
??如果已有的異常滿足不了我們的需求的話,我們可以選擇自定義異常,一般情況下可以選擇繼承自Exception或者Exception子類的類。而習慣上,該類應該至少包含兩個構造器,一個是默認的構造器,另一個是帶有詳細描述信息的構造器:
public class OrderException extends Exception {
public OrderException() {
}
public OrderException(String message) {
super(message);
}
}
同樣,我們可以選擇自定義的異常時check exception或者unchek exception。
5. 捕獲異常
5.1 捕獲單個異常
??前面我們了解了拋出異常,拋出異常其實很簡單,但有些情況下我們需要把異常給捕獲,比如說直接展示給用戶的時候,我們需要把異常以一種更直觀更優雅的方式展示給用戶。這時候我們就可以通過 try catch finally語句塊來處理。
- 對于資源的關閉,在JDK1.7 之前,我們一般都是通過在finally塊中手動關閉,而JDK7引入了
try-with-resources
代碼塊,也就是說 try可以添加資源,這樣該資源會在try語句介紹的時候自動關閉(多個按順序),不用再手動在finally塊中進行關閉;而如果是多個資源的話,同樣和普通的代碼一樣,使用分號進行分割;但是需要注意的是,在try里面的資源,需要實現了java.lang.AutoCloseable接口;
static String readFirstLineFromFileWithFinallyBlock(String path)
throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
if (br != null) br.close();
}
}
static String readFirstLineFromFile(String path) throws IOException {
try (BufferedReader br =
new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
針對異常的捕獲,通常,最好的選擇是什么也不做,而是將異常傳遞給調用者:
如果read方法出現了異常,就讓read方法的調用者去操心!但如果采用這種處理方法,就必須聲明這個方法可能會拋出的異常。
針對捕獲異常,同樣需要注意的是繼承問題,比如說,如果父類的方法沒有拋出異常,那么子類的方法就必須捕獲方法中出現的每一個已檢查異常,不允許在子類的throws 中出現超過父類方法所列出的異常類范圍。
至于為什么try里面的資源要實現java.lang.AutoCloseable接口,因為該接口提供了一個close方法,try塊正常退出或者發生異常退出時,都會自動調用res.close(),相當于使用了finally塊:
void close() throws Exception;
不過可能還需要注意另一個接口:Closeable
,該接口實現自AutoCloseable
,也包含一個close方法,不過,這個方法聲明拋出的異常是IOException。
5.2 捕獲多個異常
在一個try語句塊中可以使用多個catch捕獲多個異常,但JDK7之后,同一個catch子句中可以捕獲多個異常類型:
// before JDK 7
try {
...
} catch (FileNotFoundException ex) {
...
} catch (Exception e) {
...
}
// after JDK 7
try {
...
} catch (FileNotFoundException | UnknownHostException e) {
...
} catch (Exception ex) {
...
}
- 針對第一種多個catch的情況,捕獲的時候注意異常的包含關系,異常范圍越大的越往后,比如Exception應該放到最后;
- 第二種情況,使用 | 捕獲多個異常的時候,捕獲的異常類型彼此之間不能存在繼承關系,并且這種情況下,異常變量e隱含為final類型,所以不能對該類型執行賦值等操作;
6. 再次拋出異常于異常鏈
??在catch子句中可以拋出一個異常,這樣做的目的是改變異常的類型,比如說方法中發生了一個已檢查異常但該方法不允許拋出異常,所以我們可以捕獲后將其包裝成一個運行時異常再拋出,如:
try {
} catch (SQLException e) {
throw new MyException("database error:" + e.getMessage());
}
但還有一種有好的處理方法,并且將原始異常信息設置為新異常的信息:
try {
} catch (SQLException e) {
Throwable se = new MyException("database error");
se.initCause(e);
throw se;
}
這樣當捕獲到異常時,就可以使用下面這條語句重新得到原始異常:
Throwable e = se.getCause();
官方建議使用這種包裝技術,這樣可以讓用戶拋出子系統中的高級異常,而不會丟失原始異常的細節。
有一點可能需要注意,就是下面這種:
public static void main(String[] args) throws FileNotFoundException {
try {
InputStream inputStream = new FileInputStream("");
...
} catch (Exception e) {
// 日志記錄
throw e;
}
}
首先,try塊里面有已檢查異常FileNotFoundException,這時候我們捕獲Exception異常之后,只想做個記錄,然后再直接拋出,如果try塊里只有一個已檢查異常,并且在catch中該異常未發生任何變化,這時候我們在方法首部聲明的要拋出的異??梢允莟ry塊里唯一的一個已檢查類型FileNotFoundException,因為編譯器會跟蹤try塊里的異常(注意JDK7之后)。
7. finally塊
??finally塊通常用來執行一些資源的關閉,鎖的釋放等操作,因為無論是否發生異常,finall塊中的代碼都會被執行。這里官方建議使用嵌套的try/catch 和try/finally語句塊,比如:
InputStream inputStream = null;
try {
try {
new FileInputStream("");
// do something
} finally {
inputStream.close();
}
} catch (Exception e) {
// 日志記錄
}
??內層的try/finally 只有一個職責,就是確保關閉輸入流。外出的try/catch就是捕獲出現的異常,這種設計方式不僅清楚,而且還有一個功能就是會捕獲finally 子句中出現的異常。
而當try/catch/finally塊包含return語句時,是一件比較有意思的事,我們放到最后借助例子來了解。
8. 堆棧跟蹤
??堆棧跟蹤(stack trace)是一個方法調用過程的列表,它包含了程序執行過程中方法調用的特定位置,當Java程序正常終止,而沒有捕獲異常時,這個列表就會展示出來,比如:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at jdk8.thread.CallableTest.test(CallableTest.java:21)
at jdk8.thread.CallableTest.main(CallableTest.java:17)
我們可以調用Throwable類的printStackTrace
方法訪問堆棧跟蹤的文本描述信息,另一種更靈活的辦法是使用getStackTrace
方法,它會得到一個StackTraceElement對象數組:
Throwable throwable = new Throwable();
StackTraceElement[] stackTraceElements = throwable.getStackTrace();
for (StackTraceElement stackTraceElement : stackTraceElements) {
// className, fileName, methodName, lineName
stackTraceElement.getClassName();
stackTraceElement.getFileName();
stackTraceElement.getMethodName();
stackTraceElement.getLineNumber();
stackTraceElement.toString();
}
StackTraceElement 類能夠獲得類名(包含包路徑),文件名,方法名及代碼行號,而toString方法則會產生一個格式化的字符串,就是上面例子中的一行異常信息。
而靜態的Thread.getAllStackTrace
方法則可以產生所有線程的堆棧跟蹤:
Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();
for (Thread t : map.keySet()) {
StackTraceElement[] stackTraceElements = map.get(t);
// doSomething
}
三、總結
1. 注意事項
- 異常處理機制的一個目標,將正常處理與錯誤處理分開;比如說展示給用戶的錯誤信息盡量不要和程序混淆到一塊,可以使用枚舉或者配置文件統一管理錯誤信息;
- 只在有必要的時候才使用異常,不要用異常來控制程序的流程,因為異常使用過多會影響程序的性能,能用代碼解決的異常就不要拋出;
- 不要只拋出RuntimeException,應該尋找更加適當的子類或創建自己的異常類;不要只捕獲Throwable異常,會使代碼不太容易維護;
- 不要使用空的catch塊,如果我們想忽略掉異常,可以在catch塊中添加日志,這樣假如這里出現了問題可以及時排查到;
- 避免多次在日志信息中記錄同一個異常,一般情況下異常都是往上層拋出,如果每次拋出都log一下的話,則不利于我們定位到問題所在;
- 異常的拋出與捕獲可以遵循“早拋出,晚捕獲”這種規則;
2. 異常所能解決的問題
我們之所以使用異常,在于異??梢越鉀Q如下問題:
- 出了什么問題;
- 在哪里出的問題;
- 為什么會出問題;
在有效使用異常的情況下,異常類型回答了“什么錯誤”被拋出,異常堆棧跟蹤回答了“在哪里”拋出,異常信息回答了“為什么”會拋出,所以我們在進行拋出或捕獲異常的時候,要能準備的解決這些問題。
3. try/catch/finally中包含return的執行順序問題
當try/catch/finally中包含return語句的時候,很有意思。這個問題以前專門測試過,不過后來忘記記錄到哪了,這次記錄下,目前可以分為以下幾種情況:
3.1 try有return,那么finally里的代碼會不會執行,在try的return前還是后執行?
public static void main(String[] args) {
System.out.println(returnTest());
}
public static boolean returnTest() {
try {
System.out.println("try塊");
return true;
} finally {
System.out.println("finally塊");
}
}
/*
try塊
finally塊
true
*/
這個問題就比較簡單了,finally里的代碼一定會執行,并且是在try的return前執行;
3.2 try有return,finally也有return,那么執行結果是?
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try塊");
return true;
} finally {
System.out.println("finally塊");
return false;
}
}
/*
try塊
finally塊
false
*/
首先,finally塊代碼一定會執行,可以看到,finally里的return覆蓋掉了try里的return;
3.3 try后面有catch,catch里也有return,怎么執行?
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try塊");
int temp = 23 / 0;
return true;
} catch (Exception e) {
System.out.println("catch塊");
return false;
} finally {
System.out.println("finally塊");
}
}
/*
try塊
catch塊
finally塊
false
*/
可以看到,在流程都執行完成之后,catch塊中的return覆蓋了try塊的return;接下來如果給finally也加上return的話,可以看下執行結果:
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try塊");
int temp = 23 / 0;
return true;
} catch (Exception e) {
System.out.println("catch塊");
return true;
} finally {
System.out.println("finally塊");
return false;
}
}
/*
try塊
catch塊
finally塊
false
*/
可以看到,首先finally里的代碼一定會執行,并且如果finally里沒有return語句,而catch里有return語句,則catch里的return語句會覆蓋掉try的;而如果finally里也有return語句,則finally里的return語句會覆蓋掉前面的。
3.4 數字相加問題
直接看代碼:
public static int returnTest() {
int temp = 23;
try {
System.out.println("try塊");
return temp += 88;
} catch (Exception e) {
System.out.println("catch塊");
} finally {
if (temp > 25) {
System.out.println("temp>25:" + temp);
}
System.out.println("finally塊");
}
return temp;
}
大家可以先猜一下結果,然后再看結果:
try塊
temp>25:111
finally塊
111
從表面上看,temp的值先變為了111,然后再執行的finally,那是不是try里先return了,再執行的finally呢?其實,并不是try語句中return執行完之后才執行的finally,而是在執行return temp+=88
時,分成了兩步,先temp+=88;
再return temp;
,如果我們將return temp;
放到System.out.println("finally塊");
后面,則輸出結果不變;我們來修改下finally語句:
} finally {
if (temp > 25) {
System.out.println("temp>25:" + temp);
}
System.out.println("finally塊");
temp = 100;
}
因為finally沒有return,那么不管你是不是改變了要返回的那個變量,返回的值依然不變。
3.5 總結
可以來簡單總結下:
- 當包含finally語句時,無論try/catch有沒有return語句,finally塊中的代碼一定會執行;
- 當finally塊中也包含return語句時,finally塊的return會覆蓋掉try/catch中的return語句;
本文參考自:
《Java核心技術 卷I》
海子 - Java異常處理和設計
知乎 - Java中如何優雅的處理異常
IBM - Java 異常處理的誤區和經驗總結
Java 8 Exceptions