異常是日常開發中大家都「敬而遠之」的一個東西,但實際上幾乎每種高階程式設計語言都有自己的異常處理機制,因為無論你是多麼厲害的程式設計師,都不可避免的出錯,換句話說:你再牛逼,你也有寫出 Bug 的時候。
而所謂的「異常處理機制」就是能夠在你出現邏輯錯誤的時候,儘可能的為你返回出錯資訊以及出錯的程式碼大致位置,方便你排查錯誤。
同時,你也不必把異常想的太高深,它只是一段錯誤的提示資訊,只是你的程式在執行過程中的一些邏輯錯誤被虛擬機器檢查出來了,它封裝了錯誤資訊並向你「報告」而已,而具體你如何處理,取決於你。
異常的繼承體系結構
Java 中,類 Throwable 是整個異常處理機制的最高父類,它有兩個子類 Error 和 Exception,分別代表著「錯誤」和「異常」。
而我們平常總是在說的異常其實指的是 Exception,因為錯誤是我們程式設計師不可控制的,往往是由於虛擬機器內部出現問題導致的,例如:記憶體不足導致棧空間溢位,虛擬機器執行故障等。
一般這種情況,虛擬機器將直接執行緒終止,並通過 Error 及其子類物件回撥錯誤資訊。因此,我們只關注能夠被我們控制的 Exception 及其子類的異常。
我們的 Exception 異常主要分為兩類,一類是 IOException(I/O 輸入輸出異常),另一類是 RuntimeException(執行時異常)。其中 IOException 及其子類異常又被稱作「受查異常」,RuntimeException 被稱作「非受查異常」。
所謂受查異常就是指,編譯器在編譯期間要求必須得到處理的那些異常。舉個例子:你寫一段程式碼讀寫檔案,而編譯器認為讀寫檔案很可能遇到檔案不存在的情況,於是強制你寫一段程式碼處理檔案不存在的異常情況。
而這裡的檔案不存在異常就是一個受查異常,你必須在編譯期處理了。
而我們的 RuntimeException 之所以叫做執行時異常,就是因為編譯器也不知道你的程式碼會出現哪些問題,於是就不強制你處理異常了,等到執行期間,如果出現異常,虛擬機器會回撥錯誤資訊的。
當然,如果你預判你的程式碼會出現某個異常,你也可以自己進行捕獲處理,但話又說回來了,如果你知道某個位置可能有問題,你幹嘛不直接給它解決了呢。
所以,執行時異常就是不可知的異常,並不強制你處理。
自定義異常型別
Java 的異常機制中所定義的所有異常不可能預見所有可能出現的錯誤,某些特定的情境下,則需要我們自定義異常型別來向上報告某些錯誤資訊。
而自定義異常型別也是相當簡單的,你可以選擇繼承 Throwable,Exception 或它們的子類,甚至你不需要實現和重寫父類的任何方法即可完成一個異常型別的定義。
例如:
public class MyException extends RuntimeException{
}
複製程式碼
public class MyException extends Exception{
}
複製程式碼
當然,如果你想要為你的異常提供更多的資訊,你也可以重寫多個過載構造器,例如:
public class MyException extends RuntimeException{
public MyException(){}
public MyException(String mess){
super(mess);
}
public MyException(String mess,Throwable cause){
super(mess,cause);
}
}
複製程式碼
我們知道,任意的一個異常型別,無論是 Java API 中的,或是我們自定義的,它們必然會直接或間接繼承 Throwable 類。
而這個 Throwable 類定義了一個 String 型別的 detailMessage 欄位儲存的由子類傳入有關子類異常的詳細資訊。例如:
public static void main(String[] args) {
throw new MyException("hello wrold failed");
}
複製程式碼
輸出結果:
Exception in thread "main" test.exception.MyException: hello wrold failed
at test.exception.Test.main(Test.java:7)
複製程式碼
每當程式遇到一個異常後,Java 會像建立其他物件一樣建立一個異常型別的物件,並儲存在堆中,接著異常機制接管程式,首先檢索當前方法的異常表是否能匹配到該異常(異常表中儲存了當前方法已經處理的所有異常集合)。
如果匹配到一個異常表中的異常,那麼將根據異常表中儲存的異常處理的相關資訊,跳轉到處理該異常的位元組碼位置繼續執行。
否則,虛擬機器將終止當前方法的呼叫並彈棧彈出該方法的棧幀,返回該方法的呼叫處,繼續檢索呼叫者的異常表能夠匹配到該異常的處理。
如果一直無法匹配,最終整個方法呼叫鏈中涉及到的所有方法都會彈棧,不會得到正常執行,並且最後虛擬機器將列印這個異常的錯誤資訊。
這就是大致的一個異常出現到最終得到處理的一個過程,足以見得,如果一個異常得到了處理,那麼程式將得到恢復並能夠繼續執行,否則的話所有涉及該異常的方法都將被終止執行。
至於這個異常資訊的內容,我們看看 printStackTrace 方法的具體實現:
總共有三個部分的資訊,第一部分由異常的名稱及其 detailMessage 構成,第二部分是異常的呼叫鏈資訊,由上往下的是異常的發生位置到外層方法的呼叫點,第三部分則是引起該異常的源異常。
異常的處理方式
關於異常的處理方式,想必大家最熟悉的就是 try-catch 了吧,try-catch 的基本語法格式如下:
try{
//你的程式
}catch(xxxException e){
//異常處理程式碼
}catch(xxxException e){
//異常處理程式碼
}
複製程式碼
try 程式碼塊中程式碼我們又稱作「監控區域」,catch 程式碼塊我們稱作「異常處理區域」。其中,每一個 catch 程式碼塊對應於一種異常處理,該異常將被儲存在方法的異常表中,一旦 try 程式碼塊中產生任何的異常,異常處理機制都會先從異常表檢索是否有處理該異常的程式碼塊。
準確來說,異常表儲存的已處理異常塊只能用於處理我們 try 塊中的程式碼,別處的相同異常不會被匹配處理。
當然,除此之外,我們處理異常還有一種方式,丟擲異常。例如:
public static void main(String[] args){
try{
calculate(22,0);
}catch (Exception e){
System.out.println("捕獲一個異常");
e.printStackTrace();
}
}
public static void calculate(int x,int y){
if (y == 0)
throw new MyException("除數為 0");
int z = x/y;
}
複製程式碼
輸出結果:
捕獲一個異常
test.exception.MyException: 除數為 0
at test.exception.Test_throw.calculate(Test_throw.java:14)
at test.exception.Test_throw.main(Test_throw.java:6)
複製程式碼
我們可以使用 throw 關鍵字手動丟擲一個異常,這種情況往往是被呼叫者無力處理某個異常,需要拋給呼叫者自己處理。
顯然,這種丟擲異常的方式算細緻的了,並且需要程式設計師有一定的預判,Java 裡還有另一種丟擲異常的方式,看:
public static void calculate2(int x,int y) throws ArithmeticException{
int z = x/y;
}
複製程式碼
這種方式比較「粗暴」,我不管你什麼位置會出現異常,只要你遇到 ArithmeticException 型別的異常,你就給我丟擲去。
其實第二種本質上和第一種也是一樣的,虛擬機器在進行 x/y 的時候,當發現 y 等於零,也會 new 一個 ArithmeticException 的物件,然後程式交給異常機制。
但是後者卻比前者省事,不用關心你哪個位置會出現異常,也不需要手動做判斷,一切都交給虛擬機器好了。但是顯然的不足點就是有關異常的控制權不在自己手上,某些自定義的異常虛擬機器在執行的時候無法判斷。
就比如,假如我們這裡的 calculate2 方法不允許 y 等於 1,如果等於 1 就要拋一個 MyException 異常。這種情況,後者怎麼也無法實現,因為除數為 1 在虛擬機器看來根本不存在任何問題,你叫它如何丟擲一個異常。而用前者手動拋一個異常是再簡單不過的事情了。
但是,你必須明確一點的是,無論是使用 throw 手動向上丟擲一個異常,還是使用 throws 讓虛擬機器為我們動態丟擲一個異常,你總是需要在某個位置處理這個異常的,這一點需要明確。
不是說你的垃圾你不想清理,你就扔給你前桌的同學,你前桌也不想清理,就一直往前扔,但最前面那個人總要處理的吧,不然你就等著你們班主任清理完後來收拾你們了。
try-catch-finally 的執行順序
try-catch-finally 執行順序的相關問題可以說是各種面試中的「常客」了,尤其是 finally 塊中帶有 return 語句的情況。我們直接看幾道面試題:
面試題一:
public static void main(String[] args){
int result = test1();
System.out.println(result);
}
public static int test1(){
int i = 1;
try{
i++;
System.out.println("try block, i = "+i);
}catch(Exception e){
i--;
System.out.println("catch block i = "+i);
}finally{
i = 10;
System.out.println("finally block i = "+i);
}
return i;
}
複製程式碼
大家不妨算一算程式設計師最終執行的結果是什麼。
輸出結果如下:
try block, i = 2
finally block i = 10
10
複製程式碼
這算一個相當簡單的問題了,沒有坑,下面我們稍微改動一下:
public static int test2(){
int i = 1;
try{
i++;
throw new Exception();
}catch(Exception e){
i--;
System.out.println("catch block i = "+i);
}finally{
i = 10;
System.out.println("finally block i = "+i);
}
return i;
}
複製程式碼
輸出結果如下:
catch block i = 1
finally block i = 10
10
複製程式碼
執行結果想必也是意料之中吧,程式丟擲一個異常,然後被本方法的 catch 塊捕獲並進行了處理。
面試題二:
public static void main(String[] args){
int result = test3();
System.out.println(result);
}
public static int test3(){
//try 語句塊中有 return 語句時的整體執行順序
int i = 1;
try{
i++;
System.out.println("try block, i = "+i);
return i;
}catch(Exception e){
i ++;
System.out.println("catch block i = "+i);
return i;
}finally{
i = 10;
System.out.println("finally block i = "+i);
}
}
複製程式碼
輸出結果如下:
try block, i = 2
finally block i = 10
2
複製程式碼
是不是有點疑惑?明明我 try 語句塊中有 return 語句,可為什麼最終還是執行了 finally 塊中的程式碼?
我們反編譯這個類,看看這個 test3 方法編譯後的位元組碼的實現:
0: iconst_1 //將 1 載入進運算元棧
1: istore_0 //將運算元棧 0 位置的元素存進區域性變數表
2: iinc 0, 1 //將區域性變數表 0 位置的元素直接加一(i=2)
5: getstatic #3 // 5-27 行執行的 println 方法
8: new #5
11: dup
12: invokespecial #6
15: ldc #7
17: invokevirtual #8
20: iload_0
21: invokevirtual #9 24: invokevirtual #10
27: invokevirtual #11
30: iload_0 //將區域性變數表 0 位置的元素載入進操作棧(2)
31: istore_1 //把操作棧頂的元素存入區域性變數表位置 1 處
32: bipush 10 //載入一個常量到操作棧(10)
34: istore_0 //將 10 存入區域性變數表 0 處
35: getstatic #3 //35-57 行執行 finally中的println方法
38: new #5
41: dup
42: invokespecial #6
45: ldc #12
47: invokevirtual #8
50: iload_0
51: invokevirtual #9
54: invokevirtual #10
57: invokevirtual #11
60: iload_1 //將區域性變數表 1 位置的元素載入進操作棧(2)
61: ireturn //將操作棧頂元素返回(2)
-------------------try + finally 結束 ------------
------------------下面是 catch + finally,類似的 ------------
62: astore_1
63: iinc 0, 1
.......
.......
複製程式碼
從我們的分析中可以看出來,finally 程式碼塊中的內容始終會被執行,無論程式是否出現異常的原因就是,編譯器會將 finally 塊中的程式碼複製兩份並分別新增在 try 和 catch 的後面。
可能有人會所疑惑,原本我們的 i 就被儲存在區域性變數表 0 位置,而最後 finally 中的程式碼也的確將 slot 0 位置填充了數值 10,可為什麼最後程式依然返回的數值 2 呢?
仔細看位元組碼,你會發現在 return 語句返回之前,虛擬機器會將待返回的值壓入運算元棧,等待返回,即使 finally 語句塊對 i 進行了修改,但是待返回的值已經確實的存在於運算元棧中了,所以不會影響程式返回結果。
面試題三:
public static int test4(){
//finally 語句塊中有 return 語句
int i = 1;
try{
i++;
System.out.println("try block, i = "+i);
return i;
}catch(Exception e){
i++;
System.out.println("catch block i = "+i);
return i;
}finally{
i++;
System.out.println("finally block i = "+i);
return i;
}
}
複製程式碼
執行結果:
try block, i = 2
finally block i = 3
3
複製程式碼
其實你從它的位元組碼指令去看整個過程,而不要單單四記它的執行過程。
你會發現程式最終會採用 finally 程式碼塊中的 return 語句進行返回,而直接忽略 try 語句塊中的 return 指令。
最後,對於異常的使用有一個不成文的約定:儘量在某個集中的位置進行統一處理,不要到處的使用 try-catch,否則會使得程式碼結構混亂不堪。
文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。