深入理解Java異常

dreamGong發表於2018-08-16

引言

說到異常,大家腦海中第一反應肯定是try-catch-finally這樣的固定的組合。的確,這是Java異常處理的基本正規化,下面我們就來好好聊聊Java異常機制,看看這個背後還有哪些我們忽略的細節。

Java異常介紹

異常時什麼?就是指阻止當前方法或作用域繼續執行的問題,當程式執行時出現異常時,系統就會自動生成一個Exception物件來通知程式進行相應的處理。Java異常的型別有很多種,下面我們就使用一張圖來看一下Java異常的繼承層次結構:

深入理解Java異常
圖片中我們看到Java異常的基類是Throwable型別,然後它有兩個派生類Error和Exception型別,然後Exception類有分為受檢查異常及RuntimeException(執行時異常)。下面我們就來逐一介紹。

Java異常中的Error

Error一般表示編譯時或者系統錯誤,例如:虛擬機器相關的錯誤,系統崩潰(例如:我們開發中有時會遇到的OutOfMemoryError)等。這種錯誤無法恢復或不可捕獲,將導致應用程式中斷,通常應用程式無法處理這些錯誤,因此也不應該試圖用catch來進行捕獲。

Java異常中的Exception

上面我們有介紹,Java異常的中的Exception分為受檢查異常和執行時異常(不受檢查異常)。下面我們展開介紹。

Java中的受檢查異常

相信大家在寫IO操作的程式碼的時候,一定有過這樣的記憶,對File或者Stream進行操作的時候一定需要使用try-catch包起來,否則編譯會失敗,這是因為這些異常型別是受檢查的異常型別。編譯器在編譯時,對於受檢異常必須進行try...catch或throws處理,否則無法通過編譯。常見的受檢查異常包括:IO操作、ClassNotFoundException、執行緒操作等。

Java中的非受檢查異常(執行時異常)

RuntimeException及其子類都統稱為非受檢查異常,例如:NullPointExecrption、NumberFormatException(字串轉換為數字)、ArrayIndexOutOfBoundsException(陣列越界)、ClassCastException(型別轉換錯誤)、ArithmeticException(算術錯誤)等。

Java的異常處理

Java處理異常的一般格式是這樣的:

try{
    ///可能會丟擲異常的程式碼
}catch(Type1 id1){
    //處理Type1型別異常的程式碼
}catch(Type2 id2){
    //處理Type2型別異常的程式碼
}
複製程式碼

try塊中放置可能會發生異常的程式碼(但是我們不知道具體會發生哪種異常)。如果異常發生了,try塊丟擲系統自動生成的異常物件,然後異常處理機制將負責搜尋引數與異常型別相匹配的第一個處理程式,然後進行catch語句執行(不會在向下查詢)。如果我們的catch語句沒有匹配到,那麼JVM虛擬機器還是會丟擲異常的。

Java中的throws關鍵字

如果在當前方法不知道該如何處理該異常時,則可以使用throws對異常進行丟擲給呼叫者處理或者交給JVM。JVM對異常的處理方式是:列印異常的跟蹤棧資訊並終止程式執行。 throws在使用時應處於方法簽名之後使用,可以丟擲多種異常並用英文字元逗號’,’隔開。下面是一個例子:

public void f() throws ClassNotFoundException,IOException{}
複製程式碼

這樣我們呼叫f()方法的時候必須要catch-ClassNotFoundException和IOException這兩個異常或者catch-Exception基類。
注意:
throws的這種使用方式只是Java編譯期要求我們這樣做的,我們完全可以只在方法宣告中throws相關異常,但是在方法裡面卻不丟擲任何異常,這樣也能通過編譯,我們通過這種方式間接的繞過了Java編譯期的檢查。這種方式有一個好處:為異常先佔一個位置,以後就可以丟擲這種異常而不需要修改已有的程式碼。在定義抽象類和介面的時候這種設計很重要,這樣派生類或者介面實現就可以丟擲這些預先宣告的異常。

列印異常資訊

異常類的基類Exception中提供了一組方法用來獲取異常的一些資訊.所以如果我們獲得了一個異常物件,那麼我們就可以列印出一些有用的資訊,最常用的就是void printStackTrace()這個方法,這個方法將返回一個由棧軌跡中的元素所構成的陣列,其中每個元素都表示棧中的一幀.元素0是棧頂元素,並且是呼叫序列中的最後一個方法呼叫(這個異常被建立和丟擲之處);他有幾個不同的過載版本,可以將資訊輸出到不同的流中去.下面的程式碼顯示瞭如何列印基本的異常資訊:

public void f() throws IOException{
    System.out.println("Throws SimpleException from f()"); 
    throw new IOException("Crash");
 }
 public static void main(String[] agrs) {
    try {
    	new B().f();
    } catch (IOException e) {
    	System.out.println("Caught  Exception");
        System.out.println("getMessage(): "+e.getMessage());
        System.out.println("getLocalizedMessage(): "+e.getLocalizedMessage());
        System.out.println("toString(): "+e.toString());
        System.out.println("printStackTrace(): ");
        e.printStackTrace(System.out);
    }
}
複製程式碼

我們來看輸出:

Throws SimpleException from f()
Caught  Exception
getMessage(): Crash
getLocalizedMessage(): Crash
toString(): java.io.IOException: Crash
printStackTrace(): 
java.io.IOException: Crash
	at com.learn.example.B.f(RunMain.java:19)
	at com.learn.example.RunMain.main(RunMain.java:26)
複製程式碼

使用finally進行清理

引入finally語句的原因是我們希望一些程式碼總是能得到執行,無論try塊中是否丟擲異常.這樣異常處理的基本格式變成了下面這樣:

try{
    //可能會丟擲異常的程式碼
}
catch(Type1 id1){
    //處理Type1型別異常的程式碼
}
catch(Type2 id2){
    //處理Type2型別異常的程式碼
}
finally{
    //總是會執行的程式碼
}
複製程式碼

在Java中希望除記憶體以外的資源恢復到它們的初始狀態的時候需要使用的finally語句。例如開啟的檔案或者網路連線,螢幕上的繪製的影象等。下面我們來看一下案例:

public class FinallyException {
    static int count = 0;

    public static void main(String[] args) {
        while (true){
            try {
                if (count++ == 0){
                    throw new ThreeException();
                }
                System.out.println("no Exception");
            }catch (ThreeException e){
                System.out.println("ThreeException");
            }finally {
                System.out.println("in finally cause");
                if(count == 2)
                    break;
            }
        }
    }
}

class ThreeException extends Exception{}
複製程式碼

我們來看輸出:

ThreeException
in finally cause
no Exception
in finally cause
複製程式碼

如果我們在try塊或者catch塊裡面有return語句的話,那麼finally語句還會執行嗎?我們看下面的例子:

public class MultipleReturns {
    public static void f(int i){
        System.out.println("start.......");
        try {
            System.out.println("1");
            if(i == 1)
                return;
            System.out.println("2");
            if (i == 2)
                return;
            System.out.println("3");
            if(i == 3)
                return;
            System.out.println("else");
            return;
        }finally {
            System.out.println("end");
        }
    }

    public static void main(String[] args) {
        for (int i = 1; i<4; i++){
            f(i);
        }
    }
}
複製程式碼

我們來看執行結果:

start.......
1
end
start.......
1
2
end
start.......
1
2
3
end
複製程式碼

我們看到即使我們在try或者catch塊中使用了return語句,finally子句還是會執行。那麼有什麼情況finally子句不會執行呢?
有下面兩種情況會導致Java異常的丟失

  • finally中重寫丟擲異常(finally中重寫丟擲另一種異常會覆蓋原來捕捉到的異常)
  • 在finally子句中返回(即return)

Java異常棧

前面稍微提到了點Java異常棧的相關內容,這一節我們通過一個簡單的例子來更加直觀的瞭解異常棧的相關內容。我們再看Exception異常的時候會發現,發生異常的方法會在最上層,main方法會在最下層,中間還有其他的呼叫層次。這其實是棧的結構,先進後出的。下面我們通過例子來看下:

public class WhoCalled {
    static void f() {
        try {
            throw new Exception();
        } catch (Exception e) {
            for (StackTraceElement ste : e.getStackTrace()){
                System.out.println(ste.getMethodName());
            }
        }
    }

    static void g(){
        f();
    }

    static void h(){
        g();
    }

    public static void main(String[] args) {
        f();
        System.out.println("---------------------------");
        g();
        System.out.println("---------------------------");
        h();
        System.out.println("---------------------------");

    }
}
複製程式碼

我們來看輸出結果:

f
main
---------------------------
f
g
main
---------------------------
f
g
h
main
---------------------------
複製程式碼

可以看到異常資訊都是從內到外的,按我的理解檢視異常的時候要從第一條異常資訊看起,因為那是異常發生的源頭。

重新丟擲異常及異常鏈

我們知道每遇到一個異常資訊,我們都需要進行try…catch,一個還好,如果出現多個異常呢?分類處理肯定會比較麻煩,那就一個Exception解決所有的異常吧。這樣確實是可以,但是這樣處理勢必會導致後面的維護難度增加。最好的辦法就是將這些異常資訊封裝,然後捕獲我們的封裝類即可。
我們有兩種方式處理異常,一是throws丟擲交給上級處理,二是try…catch做具體處理。但是這個與上面有什麼關聯呢?try…catch的catch塊我們可以不需要做任何處理,僅僅只用throw這個關鍵字將我們封裝異常資訊主動丟擲來。然後在通過關鍵字throws繼續丟擲該方法異常。它的上層也可以做這樣的處理,以此類推就會產生一條由異常構成的異常鏈。
通過使用異常鏈,我們可以提高程式碼的可理解性、系統的可維護性和友好性。
我們捕獲異常以後一般會有兩種操作

  • 捕獲後丟擲原來的異常,希望保留最新的異常丟擲點--fillStackTrace
  • 捕獲後丟擲新的異常,希望丟擲完整的異常鏈--initCause

捕獲異常後重新丟擲異常

在函式中捕獲了異常,在catch模組中不做進一步的處理,而是向上一級進行傳遞 catch(Exception e){ throw e;},我們通過例子來看一下:

public class ReThrow {
    public static void f()throws Exception{
        throw new Exception("Exception: f()");
    }

    public static void g() throws Exception{
        try{
            f();
        }catch(Exception e){
            System.out.println("inside g()");
            throw e;
        }
    }
    public static void main(String[] args){
        try{
            g();
        }
        catch(Exception e){
            System.out.println("inside main()");
            e.printStackTrace(System.out);
        }
    }
}
複製程式碼

我們來看輸出:

inside g()
inside main()
java.lang.Exception: Exception: f()
        //異常的丟擲點還是最初丟擲異常的函式f()
	at com.learn.example.ReThrow.f(RunMain.java:5)
	at com.learn.example.ReThrow.g(RunMain.java:10)
	at com.learn.example.RunMain.main(RunMain.java:21)
複製程式碼

fillStackTrace——覆蓋前邊的異常丟擲點(獲取最新的異常丟擲點)

在此丟擲異常的時候進行設定 catch(Exception e){ (Exception)e.fillInStackTrace();} 我們通過例子看一下:(還是剛才的例子)

public void g() throws Exception{
    try{
        f();
    }catch(Exception e){
    	System.out.println("inside g()");
        throw (Exception)e.fillInStackTrace();
    }
}
複製程式碼

執行結果如下:

inside g()
inside main()
java.lang.Exception: Exception: f()
        //顯示的就是最新的丟擲點
	at com.learn.example.ReThrow.g(RunMain.java:13)
	at com.learn.example.RunMain.main(RunMain.java:21)
複製程式碼

捕獲異常後丟擲新的異常(保留原來的異常資訊,區別於捕獲異常之後重新丟擲)

如果我們在丟擲異常的時候需要保留原來的異常資訊,那麼有兩種方式

  • 方式1:Exception e=new Exception(); e.initCause(ex);
  • 方式2:Exception e =new Exception(ex);
class ReThrow {
    public void f(){
        try{
             g(); 
         }catch(NullPointerException ex){
             //方式1
             Exception e=new Exception();
             //將原始的異常資訊保留下來
             e.initCause(ex);
             //方式2
             //Exception e=new Exception(ex);
             try {
    		    throw e;
    		} catch (Exception e1) {
    		    e1.printStackTrace();
    		}
         }
    }

    public void g() throws NullPointerException{
    	System.out.println("inside g()");
        throw new NullPointerException();
    }
}

public class RunMain {
    public static void main(String[] agrs) {
    	try{
            new ReThrow().f();
        }
        catch(Exception e){
            System.out.println("inside main()");
            e.printStackTrace(System.out);
        }
    }
}
複製程式碼

在這個例子裡面,我們先捕獲NullPointerException異常,然後在丟擲Exception異常,這時候如果我們不使用initCause方法將原始異常(NullPointerException)儲存下來的話,就會丟失NullPointerException。只會顯示Eception異常。下面我們來看結果:

//沒有呼叫initCause方法的輸出
inside g()
java.lang.Exception
	at com.learn.example.ReThrow.f(RunMain.java:9)
	at com.learn.example.RunMain.main(RunMain.java:31)
//呼叫initCasue方法儲存原始異常資訊的輸出
inside g()
java.lang.Exception
	at com.learn.example.ReThrow.f(RunMain.java:9)
	at com.learn.example.RunMain.main(RunMain.java:31)
Caused by: java.lang.NullPointerException
	at com.learn.example.ReThrow.g(RunMain.java:24)
	at com.learn.example.ReThrow.f(RunMain.java:6)
	... 1 more
複製程式碼

我們看到我們使用initCause方法儲存後,原始的異常資訊會以Caused by的形式輸出。

Java異常的限制

當Java異常遇到繼承或者介面的時候是存在限制的,下面我們來看看有哪些限制。

  • 規則一:子類在重寫父類丟擲異常的方法時,要麼不丟擲異常,要麼丟擲與父類方法相同的異常或該異常的子類。如果被重寫的父類方法只丟擲受檢異常,則子類重寫的方法可以丟擲非受檢異常。例如,父類方法丟擲了一個受檢異常IOException,重寫該方法時不能丟擲Exception,對於受檢異常而言,只能丟擲IOException及其子類異常,也可以丟擲非受檢異常。 我們通過例子來看下:
class A {  
    public void fun() throws Exception {}  
}  
class B extends A {  
    public void fun() throws IOException, RuntimeException {}  
}
複製程式碼

父類丟擲的異常包含所有異常,上面的寫法正確。

class A {  
    public void fun() throws RuntimeException {}  
}  
class B extends A {  
    public void fun() throws IOException, RuntimeException {}  
}
複製程式碼

子類IOException超出了父類的異常範疇,上面的寫法錯誤。

class A {  
    public void fun() throws IOException {}  
}  
class B extends A {  
    public void fun() throws IOException, RuntimeException, ArithmeticException{}
}
複製程式碼

RuntimeException不屬於IO的範疇,並且超出了父類的異常範疇。但是RuntimeException和ArithmeticException屬於執行時異常,子類重寫的方法可以丟擲任何執行時異常。所以上面的寫法正確。

  • 規則兒:子類在重寫父類丟擲異常的方法時,如果實現了有相同方法簽名的介面且介面中的該方法也有異常宣告,則子類重寫的方法要麼不丟擲異常,要麼丟擲父類中被重寫方法宣告異常與介面中被實現方法宣告異常的交集。
class Test {
    public Test() throws IOException {}
    void test() throws IOException {}
}

interface I1{
    void test() throw Exception;
}

class SubTest extends Test implements I1 {
    public SubTest() throws Exception,NullPointerException, NoSuchMethodException {}
    void test() throws IOException {}
}
複製程式碼

在SubTest類中,test方法要麼不丟擲異常,要麼丟擲IOException或其子類(例如,InterruptedIOException)。

Java異常與構造器

如果一個構造器中就發生異常了,那我們如何處理才能正確的清呢?也許你會說使用finally啊,它不是一定會執行的嗎?這可不一定,如果構造器在其執行過程中遇到了異常,這時候物件的某些部分還沒有正確的初始化,而這時候卻會在finally中對其進行清理,顯然這樣會出問題的。
原則:
對於在構造器階段可能會丟擲異常,並且要求清理的類,最安全的方式是使用巢狀的try子句。

try {
    InputFile in=new InpputFile("Cleanup.java");
    try {
    	String string;
    	int i=1;
    	while ((string=in.getLine())!=null) {}
    }catch (Exception e) {
    	System.out.println("Cause Exception in main");
    	e.printStackTrace(System.out);
    }finally {
    	in.dispose();
    }
}catch (Exception e) {
    System.out.println("InputFile construction failed");
}
複製程式碼

我們來仔細看一下這裡面的邏輯,對InputFile的構造在第一個try塊中是有效的,如果構造器失敗,丟擲異常,那麼會被最外層的catch捕獲到,這時候InputFile物件的dispose方法是不需要執行的。如果構造成功,那麼進入第二層try塊,這時候finally塊肯定是需要被呼叫的(物件需要dispose)。

異常的使用指南(下列情況下使用異常)

  • 在恰當的級別處理異常(在知道如何處理的情況下才捕獲異常)
  • 努力解決問題並且重新呼叫產生異常的方法
  • 進行少許修補,然後繞過異常的地方重新執行
  • 把當前執行環境下能做的事情儘量做完,然後把相同的異常重拋到更高層
  • 把當前執行環境下能做的事情儘量做完,然後把不相同的異常重拋到更高層
  • 努力讓類庫和程式更安全

相關文章