1. 程式人生 > >Java 中的異常和處理詳解

Java 中的異常和處理詳解

原文出處: 程式碼鋼琴家

簡介

程式執行時,發生的不被期望的事件,它阻止了程式按照程式設計師的預期正常執行,這就是異常。異常發生時,是任程式自生自滅,立刻退出終止,還是輸出錯誤給使用者?或者用C語言風格:用函式返回值作為執行狀態?。

Java提供了更加優秀的解決辦法:異常處理機制。

異常處理機制能讓程式在異常發生時,按照程式碼的預先設定的異常處理邏輯,針對性地處理異常,讓程式盡最大可能恢復正常並繼續執行,且保持程式碼的清晰。 Java中的異常可以是函式中的語句執行時引發的,也可以是程式設計師通過throw 語句手動丟擲的,只要在Java程式中產生了異常,就會用一個對應型別的異常物件來封裝異常,JRE就會試圖尋找異常處理程式來處理異常。

Throwable類是Java異常型別的頂層父類,一個物件只有是 Throwable 類的(直接或者間接)例項,他才是一個異常物件,才能被異常處理機制識別。JDK中內建了一些常用的異常類,我們也可以自定義異常。

Java異常的分類和類結構圖

Java標準庫內建了一些通用的異常,這些類以Throwable為頂層父類。

Throwable又派生出Error類和Exception類。

錯誤:Error類以及他的子類的例項,代表了JVM本身的錯誤。錯誤不能被程式設計師通過程式碼處理,Error很少出現。因此,程式設計師應該關注Exception為父類的分支下的各種異常類。

異常:Exception以及他的子類,代表程式執行時傳送的各種不期望發生的事件。可以被Java異常處理機制使用,是異常處理的核心。

858860-20170911125844719-1230755033

總體上我們根據Javac對異常的處理要求,將異常類分為2類。

非檢查異常(unckecked exception):Error 和 RuntimeException 以及他們的子類。javac在編譯時,不會提示和發現這樣的異常,不要求在程式處理這些異常。所以如果願意,我們可以編寫程式碼處理(使用try…catch…finally)這樣的異常,也可以不處理。對於這些異常,我們應該修正程式碼,而不是去通過異常處理器處理 。這樣的異常發生的原因多半是程式碼寫的有問題。如除0錯誤ArithmeticException,錯誤的強制型別轉換錯誤ClassCastException,陣列索引越界ArrayIndexOutOfBoundsException,使用了空物件NullPointerException等等。

檢查異常(checked exception):除了Error 和 RuntimeException的其它異常。javac強制要求程式設計師為這樣的異常做預備處理工作(使用try…catch…finally或者throws)。在方法中要麼用try-catch語句捕獲它並處理,要麼用throws子句宣告丟擲它,否則編譯不會通過。這樣的異常一般是由程式的執行環境導致的。因為程式可能被執行在各種未知的環境下,而程式設計師無法干預使用者如何使用他編寫的程式,於是程式設計師就應該為這樣的異常時刻準備著。如SQLException , IOException,ClassNotFoundException 等。

需要明確的是:檢查和非檢查是對於javac來說的,這樣就很好理解和區分了。

初識異常

下面的程式碼會演示2個異常型別:ArithmeticException 和 InputMismatchException。前者由於整數除0引發,後者是輸入的資料不能被轉換為int型別引發。

package com.example;
import java. util .Scanner ;
public class AllDemo
{
    public static void main (String [] args )
    {
        System . out. println( "----歡迎使用命令列除法計算器----" ) ;
        CMDCalculate ();
    }
    public static void CMDCalculate ()
    {
        Scanner scan = new Scanner ( System. in );
        int num1 = scan .nextInt () ;
        int num2 = scan .nextInt () ;
        int result = devide (num1 , num2 ) ;
        System . out. println( "result:" + result) ;
        scan .close () ;
    }
    public static int devide (int num1, int num2 ){
        return num1 / num2 ;
    }
}
/*****************************************

 ----歡迎使用命令列除法計算器----
 0
 Exception in thread "main" java.lang.ArithmeticException : / by zero
 at com.example.AllDemo.devide( AllDemo.java:30 )
 at com.example.AllDemo.CMDCalculate( AllDemo.java:22 )
 at com.example.AllDemo.main( AllDemo.java:12 )

 ----歡迎使用命令列除法計算器----
 r
 Exception in thread "main" java.util.InputMismatchException
 at java.util.Scanner.throwFor( Scanner.java:864 )
 at java.util.Scanner.next( Scanner.java:1485 )
 at java.util.Scanner.nextInt( Scanner.java:2117 )
 at java.util.Scanner.nextInt( Scanner.java:2076 )
 at com.example.AllDemo.CMDCalculate( AllDemo.java:20 )
 at com.example.AllDemo.main( AllDemo.java:12 )
 *****************************************/

異常是在執行某個函式時引發的,而函式又是層級呼叫,形成呼叫棧的,因為,只要一個函式發生了異常,那麼他的所有的caller都會被異常影響。當這些被影響的函式以異常資訊輸出時,就形成的了異常追蹤棧。

異常最先發生的地方,叫做異常丟擲點。

858860-20170911121451422-233079767

從上面的例子可以看出,當devide函式發生除0異常時,devide函式將丟擲ArithmeticException異常,因此呼叫他的CMDCalculate函式也無法正常完成,因此也傳送異常,而CMDCalculate的caller——main 因為CMDCalculate丟擲異常,也發生了異常,這樣一直向呼叫棧的棧底回溯。這種行為叫做異常的冒泡,異常的冒泡是為了在當前發生異常的函式或者這個函式的caller中找到最近的異常處理程式。由於這個例子中沒有使用任何異常處理機制,因此異常最終由main函式拋給JRE,導致程式終止。

上面的程式碼不使用異常處理機制,也可以順利編譯,因為2個異常都是非檢查異常。但是下面的例子就必須使用異常處理機制,因為異常是檢查異常。

程式碼中我選擇使用throws宣告異常,讓函式的呼叫者去處理可能發生的異常。但是為什麼只throws了IOException呢?因為FileNotFoundException是IOException的子類,在處理範圍內。

@Test
public void testException() throws IOException
{
    //FileInputStream的建構函式會丟擲FileNotFoundException
    FileInputStream fileIn = new FileInputStream("E:\\a.txt");
 
    int word;
    //read方法會丟擲IOException
    while((word =  fileIn.read())!=-1) 
    {
        System.out.print((char)word);
    }
    //close方法會丟擲IOException
    fileIn.clos
}

異常處理的基本語法

在編寫程式碼處理異常時,對於檢查異常,有2種不同的處理方式:使用try…catch…finally語句塊處理它。或者,在函式簽名中使用throws 宣告交給函式呼叫者caller去解決。

try…catch…finally語句塊

try{
     //try塊中放可能發生異常的程式碼。
     //如果執行完try且不發生異常,則接著去執行finally塊和finally後面的程式碼(如果有的話)。
     //如果發生異常,則嘗試去匹配catch塊。
 
}catch(SQLException SQLexception){
    //每一個catch塊用於捕獲並處理一個特定的異常,或者這異常型別的子類。Java7中可以將多個異常宣告在一個catch中。
    //catch後面的括號定義了異常型別和異常引數。如果異常與之匹配且是最先匹配到的,則虛擬機器將使用這個catch塊來處理異常。
    //在catch塊中可以使用這個塊的異常引數來獲取異常的相關資訊。異常引數是這個catch塊中的區域性變數,其它塊不能訪問。
    //如果當前try塊中發生的異常在後續的所有catch中都沒捕獲到,則先去執行finally,然後到這個函式的外部caller中去匹配異常處理器。
    //如果try中沒有發生異常,則所有的catch塊將被忽略。
 
}catch(Exception exception){
    //...
}finally{
 
    //finally塊通常是可選的。
   //無論異常是否發生,異常是否匹配被處理,finally都會執行。
   //一個try至少要有一個catch塊,否則, 至少要有1個finally塊。但是finally不是用來處理異常的,finally不會捕獲異常。
  //finally主要做一些清理工作,如流的關閉,資料庫連線的關閉等。 
}

需要注意的地方

1、try塊中的區域性變數和catch塊中的區域性變數(包括異常變數),以及finally中的區域性變數,他們之間不可共享使用。

2、每一個catch塊用於處理一個異常。異常匹配是按照catch塊的順序從上往下尋找的,只有第一個匹配的catch會得到執行。匹配時,不僅執行精確匹配,也支援父類匹配,因此,如果同一個try塊下的多個catch異常型別有父子關係,應該將子類異常放在前面,父類異常放在後面,這樣保證每個catch塊都有存在的意義。

3、java中,異常處理的任務就是將執行控制流從異常發生的地方轉移到能夠處理這種異常的地方去。也就是說:當一個函式的某條語句發生異常時,這條語句的後面的語句不會再執行,它失去了焦點。執行流跳轉到最近的匹配的異常處理catch程式碼塊去執行,異常被處理完後,執行流會接著在“處理了這個異常的catch程式碼塊”後面接著執行。 有的程式語言當異常被處理後,控制流會恢復到異常丟擲點接著執行,這種策略叫做:resumption model of exception handling(恢復式異常處理模式 ) 而Java則是讓執行流恢復到處理了異常的catch塊後接著執行,這種策略叫做:termination model of exception handling(終結式異常處理模式)

public static void main(String[] args){
        try {
            foo();
        }catch(ArithmeticException ae) {
            System.out.println("處理異常");
        }
    }
    public static void foo(){
        int a = 5/0;  //異常丟擲點
        System.out.println("為什麼還不給我漲工資!!!");  //////////////////////不會執行
    }

throws 函式宣告

throws宣告:如果一個方法內部的程式碼會丟擲檢查異常(checked exception),而方法自己又沒有完全處理掉,則javac保證你必須在方法的簽名上使用throws關鍵字宣告這些可能丟擲的異常,否則編譯不通過。

throws是另一種處理異常的方式,它不同於try…catch…finally,throws僅僅是將函式中可能出現的異常向呼叫者宣告,而自己則不具體處理。

採取這種異常處理的原因可能是:方法本身不知道如何處理這樣的異常,或者說讓呼叫者處理更好,呼叫者需要為可能發生的異常負責。

public void foo() throws ExceptionType1 , ExceptionType2 ,ExceptionTypeN
{ 
     //foo內部可以丟擲 ExceptionType1 , ExceptionType2 ,ExceptionTypeN 類的異常,或者他們的子類的異常物件。
}

finally塊

finally塊不管異常是否發生,只要對應的try執行了,則它一定也執行。只有一種方法讓finally塊不執行:System.exit()。因此finally塊通常用來做資源釋放操作:關閉檔案,關閉資料庫連線等等。

良好的程式設計習慣是:在try塊中開啟資源,在finally塊中清理釋放這些資源。

需要注意的地方:

1、finally塊沒有處理異常的能力。處理異常的只能是catch塊。

2、在同一try…catch…finally塊中 ,如果try中丟擲異常,且有匹配的catch塊,則先執行catch塊,再執行finally塊。如果沒有catch塊匹配,則先執行finally,然後去外面的呼叫者中尋找合適的catch塊。

3、在同一try…catch…finally塊中 ,try發生異常,且匹配的catch塊中處理異常時也丟擲異常,那麼後面的finally也會執行:首先執行finally塊,然後去外圍呼叫者中尋找合適的catch塊。

這是正常的情況,但是也有特例。關於finally有很多噁心,偏、怪、難的問題,我在本文最後統一介紹了,電梯速達->:finally塊和return

throw 異常丟擲語句

throw exceptionObject

程式設計師也可以通過throw語句手動顯式的丟擲一個異常。throw語句的後面必須是一個異常物件。

throw 語句必須寫在函式中,執行throw 語句的地方就是一個異常丟擲點,它和由JRE自動形成的異常丟擲點沒有任何差別。

public void save(User user)
{
      if(user  == null) 
          throw new IllegalArgumentException("User物件為空");
      //......
 
}

異常的鏈化

在一些大型的,模組化的軟體開發中,一旦一個地方發生異常,則如骨牌效應一樣,將導致一連串的異常。假設B模組完成自己的邏輯需要呼叫A模組的方法,如果A模組發生異常,則B也將不能完成而發生異常,但是B在丟擲異常時,會將A的異常資訊掩蓋掉,這將使得異常的根源資訊丟失。異常的鏈化可以將多個模組的異常串聯起來,使得異常資訊不會丟失。

異常鏈化:以一個異常物件為引數構造新的異常物件。新的異物件將包含先前異常的資訊。這項技術主要是異常類的一個帶Throwable引數的函式來實現的。這個當做引數的異常,我們叫他根源異常(cause)。

檢視Throwable類原始碼,可以發現裡面有一個Throwable欄位cause,就是它儲存了構造時傳遞的根源異常引數。這種設計和連結串列的結點類設計如出一轍,因此形成鏈也是自然的了。

public class Throwable implements Serializable {
    private Throwable cause = this;
 
    public Throwable(String message, Throwable cause) {
        fillInStackTrace();
        detailMessage = message;
        this.cause = cause;
    }
     public Throwable(Throwable cause) {
        fillInStackTrace();
        detailMessage = (cause==null ? null : cause.toString());
        this.cause = cause;
    }
 
    //........
}

下面是一個例子,演示了異常的鏈化:從命令列輸入2個int,將他們相加,輸出。輸入的數不是int,則導致getInputNumbers異常,從而導致add函式異常,則可以在add函式中丟擲

一個鏈化的異常。

public static void main(String[] args)
{
 
    System.out.println("請輸入2個加數");
    int result;
    try
    {
        result = add();
        System.out.println("結果:"+result);
    } catch (Exception e){
        e.printStackTrace();
    }
}
//獲取輸入的2個整數返回
private static List<Integer> getInputNumbers()
{
    List<Integer> nums = new ArrayList<>();
    Scanner scan = new Scanner(System.in);
    try {
        int num1 = scan.nextInt();
        int num2 = scan.nextInt();
        nums.add(new Integer(num1));
        nums.add(new Integer(num2));
    }catch(InputMismatchException immExp){
        throw immExp;
    }finally {
        scan.close();
    }
    return nums;
}
 
//執行加法計算
private static int add() throws Exception
{
    int result;
    try {
        List<Integer> nums =getInputNumbers();
        result = nums.get(0)  + nums.get(1);
    }catch(InputMismatchException immExp){
        throw new Exception("計算失敗",immExp);  /////////////////////////////鏈化:以一個異常物件為引數構造新的異常物件。
    }
    return  result;
}
 
/*
請輸入2個加數
r 1
java.lang.Exception: 計算失敗
    at practise.ExceptionTest.add(ExceptionTest.java:53)
    at practise.ExceptionTest.main(ExceptionTest.java:18)
Caused by: java.util.InputMismatchException
    at java.util.Scanner.throwFor(Scanner.java:864)
    at java.util.Scanner.next(Scanner.java:1485)
    at java.util.Scanner.nextInt(Scanner.java:2117)
    at java.util.Scanner.nextInt(Scanner.java:2076)
    at practise.ExceptionTest.getInputNumbers(ExceptionTest.java:30)
    at practise.ExceptionTest.add(ExceptionTest.java:48)
    ... 1 more
 
*/

858860-20170913181056469-1080507673

自定義異常

如果要自定義異常類,則擴充套件Exception類即可,因此這樣的自定義異常都屬於檢查異常(checked exception)。如果要自定義非檢查異常,則擴充套件自RuntimeException。

按照國際慣例,自定義的異常應該總是包含如下的建構函式:

  • 一個無參建構函式
  • 一個帶有String引數的建構函式,並傳遞給父類的建構函式。
  • 一個帶有String引數和Throwable引數,並都傳遞給父類建構函式
  • 一個帶有Throwable 引數的建構函式,並傳遞給父類的建構函式。

下面是IOException類的完整原始碼,可以借鑑。

public class IOException extends Exception
{
    static final long serialVersionUID = 7818375828146090155L;
 
    public IOException()
    {
        super();
    }
 
    public IOException(String message)
    {
        super(message);
    }
 
    public IOException(String message, Throwable cause)
    {
        super(message, cause);
    }
 
    public IOException(Throwable cause)
    {
        super(cause);
    }
}

異常的注意事項

1、當子類重寫父類的帶有 throws宣告的函式時,其throws宣告的異常必須在父類異常的可控範圍內——用於處理父類的throws方法的異常處理器,必須也適用於子類的這個帶throws方法 。這是為了支援多型。

例如,父類方法throws 的是2個異常,子類就不能throws 3個及以上的異常。父類throws IOException,子類就必須throws IOException或者IOException的子類。

至於為什麼?我想,也許下面的例子可以說明。

class Father
{
    public void start() throws IOException
    {
        throw new IOException();
    }
}
 
class Son extends Father
{
    public void start() throws Exception
    {
        throw new SQLException();
    }
}
/**********************假設上面的程式碼是允許的(實質是錯誤的)***********************/
class Test
{
    public static void main(String[] args)
    {
        Father[] objs = new Father[2];
        objs[0] = new Father();
        objs[1] = new Son();
 
        for(Father obj:objs)
        {
        //因為Son類丟擲的實質是SQLException,而IOException無法處理它。
        //那麼這裡的try。。catch就不能處理Son中的異常。
        //多型就不能實現了。
            try {
                 obj.start();
            }catch(IOException)
            {
                 //處理IOException
            }
         }
   }
}

2、Java程式可以是多執行緒的。每一個執行緒都是一個獨立的執行流,獨立的函式呼叫棧。如果程式只有一個執行緒,那麼沒有被任何程式碼處理的異常 會導致程式終止。如果是多執行緒的,那麼沒有被任何程式碼處理的異常僅僅會導致異常所在的執行緒結束。

也就是說,Java中的異常是執行緒獨立的,執行緒的問題應該由執行緒自己來解決,而不要委託到外部,也不會直接影響到其它執行緒的執行。

finally塊和return

首先一個不容易理解的事實:在 try塊中即便有return,break,continue等改變執行流的語句,finally也會執行。

public static void main(String[] args)
{
    int re = bar();
    System.out.println(re);
}
private static int bar() 
{
    try{
        return 5;
    } finally{
        System.out.println("finally");
    }
}
/*輸出:
finally
*/

很多人面對這個問題時,總是在歸納執行的順序和規律,不過我覺得還是很難理解。我自己總結了一個方法。用如下GIF圖說明。

858860-20170913191914985-125646869

也就是說:try…catch…finally中的return 只要能執行,就都執行了,他們共同向同一個記憶體地址(假設地址是0×80)寫入返回值,後執行的將覆蓋先執行的資料,而真正被呼叫者取的返回值就是最後一次寫入的。那麼,按照這個思想,下面的這個例子也就不難理解了。

finally中的return 會覆蓋 try 或者catch中的返回值。

public class MyNode {
    int num = 1;
}

public static void main(String[] args)
    {
        int result;
 
        result  =  foo();
        System.out.println(result);     /////////2
 
        result = bar();
        System.out.println(result);    /////////2
    
    	//下面是我自己加的
    	result = test();
        System.out.println(result);    /////////1
    
   		MyNode node;
        node = test1();
        System.out.println(node.num);    /////////2
    }
 
    @SuppressWarnings("finally")
    public static int foo()
    {
        try{
            int a = 5 / 0;
        } catch (Exception e){
            return 1;
        } finally{
            return 2;
        }
 
    }
 
    @SuppressWarnings("finally")
    public static int bar()
    {
        try {
            return 1;
        }finally {
            return 2;
        }
    }

//按照前面gif中的思路,這裡就很好理解了,return result其實已經把結果寫到了返回的地址中,再來改result的值是無效的
	public static int test() {
        int result = 1;
        try {
            return result;
        } finally {
            result++;
//            return 2;
        }
    }

//再看看這個是不是就恍然大霧了呢?
	public static MyNode test1() {
        MyNode node = new MyNode();

        try {
            return node;
        } finally {
            node.num++;
        }
    }

finally中的return會抑制(消滅)前面try或者catch塊中的異常

class TestException
{
    public static void main(String[] args)
    {
        int result;
        try{
            result = foo();
            System.out.println(result);           //輸出100
        } catch (Exception e){
            System.out.println(e.getMessage());    //沒有捕獲到異常
        }
 
        try{
            result  = bar();
            System.out.println(result);           //輸出100
        } catch (Exception e){
            System.out.println(e.getMessage());    //沒有捕獲到異常
        }
    }
 
    //catch中的異常被抑制
    @SuppressWarnings("finally")
    public static int foo() throws Exception
    {
        try {
            int a = 5/0;
            return 1;
        }catch(ArithmeticException amExp) {
            throw new Exception("我將被忽略,因為下面的finally中使用了return");
        }finally {
            return 100;
        }
    }
 
    //try中的異常被抑制
    @SuppressWarnings("finally")
    public static int bar() throws Exception
    {
        try {
            int a = 5/0;
            return 1;
        }finally {
            return 100;
        }
    }
}

finally中的異常會覆蓋(消滅)前面try或者catch中的異常

class TestException
{
    public static void main(String[] args)
    {
        int result;
        try{
            result = foo();
        } catch (Exception e){
            System.out.println