1. 程式人生 > >JAVA異常機制介紹/如何正確的進行JAVA異常處理

JAVA異常機制介紹/如何正確的進行JAVA異常處理

1. 課前練習

在閱讀本文的內容之前,請從下面這段程式碼中找出異常處理有哪些不正確的地方。如果不能找出至少兩處錯誤,說明你還是一個“菜鳥”,對JAVA的異常處理機制還不夠了解,需要仔細閱讀本文的內容,並走查一下自己編寫的程式碼。如果你可以找出至少兩處錯誤,恭喜你,你已經是一個熟悉JVAVA異常編碼的老手了,如果有興趣的話,請耐心閱讀完本文,一起討論一下JAVA異常處理的原則。

OutputStreamWriter out = ... 
 java.sql.Connection conn = ... 
 try { 
    Statement stat = conn.createStatement(); 
    ResultSet rs = stat.executeQuery( 
      "select uid, name from user"); 
    while (rs.next()) 
    { 
       out.println("ID:" + rs.getString("uid") 
        ",姓名:" + rs.getString("name")); 
    } 
    conn.close(); 
    out.close(); 
 } 
 catch(Exception ex)
 { 
    e.printStackTrace();
  }

2. 為什麼需要異常處理機制

在JAVA語言出現以前,傳統的異常處理方式多采用返回值來標識程式出現的異常情況,這種方式雖然為程式設計師所熟悉,但卻有多個壞處。首先,一個API可以返回任意的返回值,而這些返回值本身並不能解釋該返回值是否代表一個異常情況發生了和該異常的具體情況,需要呼叫API的程式自己判斷並解釋返回值的含義。其次,並沒有一種機制來保證異常情況一定會得到處理,呼叫程式可以簡單的忽略該返回值,需要呼叫API的程式設計師記住去檢測返回值並處理異常情況。這種方式還讓程式程式碼變得晦澀冗長,當進行IO操作等容易出現異常情況的處理時,你會發現程式碼的很大部分用於處理異常情況的switch分支,程式程式碼的可讀性變得很差。

上面提到的問題,JAVA的異常處理機制提供了很好的解決方案。通過丟擲JDK預定義或者自定義的異常,能夠表明程式中出現了什麼樣的異常情況;而且JAVA的語言機制保證了異常一定會得到恰當的處理;合理的使用異常處理機制,會讓程式程式碼清晰易懂。

3. JAVA異常型別

JAVA異常的類層次如下圖所示:

Throwable是所有異常的基類,程式中一般不會直接丟擲Throwable物件,Exception和Error是Throwable的子類,Exception下面又有RuntimeException和一般的Exception兩類。可以把JAVA異常分為三類:

    第一類是Error,Error表示程式在執行期間出現了十分嚴重、不可恢復的錯誤,在這種情況下應用程式只能中止執行,例如JAVA 虛擬機器出現錯誤。Error是一種unchecked Exception,編譯器不會檢查Error是否被處理,在程式中不用捕獲Error型別的異常;一般情況下,在進行程式編碼時也不應該丟擲Error型別的異常。

    第二類是RuntimeException,RuntimeException 是一種unchecked Exception,即表示編譯器不會檢查程式是否對RuntimeException作了處理,因此在程式中不用捕獲RuntimException型別的異常,也不必在方法體宣告丟擲RuntimeException類。一般來說,RuntimeException發生的時候,表示程式中出現了程式設計錯誤,所以應該找出錯誤修改程式,而不是去捕獲RuntimeException。

    第三類是一般的checkedException,這也是在程式設計中使用最多的Exception,所有繼承自Exception並且不是RuntimeException的異常都是checked Exception,如圖1中的IOException和ClassNotFoundException。JAVA 語言規定必須對checked Exception作處理,編譯器會對此作檢查,要麼在方法體中宣告丟擲checked Exception,要麼使用catch語句捕獲checked Exception進行處理,不然不能通過編譯。

checked Exception用於以下的語義環境: 

(1)checked Exception不是通過正確的編碼就可以防止發生的問題,而是在程式執行期間經常會發生的異常情況。是我們在編碼階段需要考慮好如何處理的異常流程。例如進行ftp操作時網路斷鏈、開啟檔案時檔案已經被刪除、使用者登入時輸入了錯誤的密碼等。

(2)該異常發生後,可以通過對異常的恰當處理,恢復程式原來的正常處理流程。如一個Internet連線或者資料庫連線發生異常被中止後,可以重試連線,重試成功後再進行後續操作。

(3)該異常發生後,雖然不能繼續原來的處理流程,但進行一些處理後可以繼續進行其他操作。例如使用者登入時輸入密碼出錯,介面模組接收到安全模組的鑑權失敗異常後,通過對話方塊提示使用者,使用者可以選擇退出登入或者重試。

4. JAVA異常的處理機制

當程式中丟擲一個異常後,程式從程式中導致異常的程式碼處跳出,try塊出現異常後的程式碼不會再被執行,java虛擬機器檢測尋找和try關鍵字匹配的處理該異常的catch塊,如果找到,將控制權交到catch塊中的程式碼,然後繼續往下執行程式。

如果有finally關鍵字,程式中丟擲異常後,無論該異常是否被catch,都會保證執行finally塊中的程式碼。在try塊和catch塊採用return關鍵字退出本次函式呼叫,也會先執行finally塊程式碼,然後再退出。即finally塊中的程式碼始終會保證執行。由於finally塊的這個特性,finally塊被用於執行資源釋放等清理工作。

如果程式發生的異常沒有被catch(由於JAVA編譯器的限制,只有uncheck exception會出現這種情況),執行程式碼的執行緒將被異常中止,即我們常常說的“執行緒跑飛了”。

5. JAVA異常處理中的原則和建議

合理使用JAVA異常機制可以使程式健壯而清晰,但不幸的是,JAVA異常處理機制常常被錯誤的使用,下面就是一些關於Exception的注意事項:

5.1. 原則:不要忽略checked Exception

請看下面的程式碼:

try
{
    method1();  //method1丟擲ExceptionA
}
catch(ExceptionA e)
{
    e.printStackTrace();
}

JAVA編譯器強制要求處理checkedexception,在很多程式碼中出現了上面這種情況,catch異常後只打印一下,然後繼續執行下面的程式碼。上面的程式碼似乎沒有什麼問題,事實上在catch塊中對發生的異常情況並沒有作任何處理(列印異常不能是算是處理異常,因為列印並不能改變程式執行邏輯,修復異常)。這樣程式雖然能夠繼續執行,但是由於這裡的操作已經發生異常,將會導致以後的操作並不能按照預期的情況發展下去,可能導致兩個結果:

一是由於這裡的異常導致在程式中別的地方丟擲一個異常,這種情況會使程式設計師在除錯時感到迷惑,因為新的異常丟擲的地方並不是程式真正發生問題的地方,也不是發生問題的真正原因;

另外一個是程式繼續執行,並得出一個錯誤的輸出結果,這種問題更加難以捕捉,因為很可能把它當成一個正確的輸出。

那麼應該如何處理呢,這裡有四個選擇:

(1)處理異常,進行修復以讓程式繼續執行。例如在進行資料庫查詢時,資料庫連線斷鏈後重建連結成功。

(2)在對異常進行分析後發現這裡不能處理它,那麼重新丟擲異常,讓呼叫者處理。異常依次向上丟擲,如果所有方法都不能恰當地處理異常,最終會在使用者介面以恰當的方式提示使用者,由使用者來判斷下一步處理措施。例如在進行資料庫查詢時,斷鏈後重試幾次依然失敗的情況。

(3)將異常轉換為其他異常再丟擲,這時應該注意不要丟失原始異常資訊。這種情況一般用於將底層異常封裝為應用層異常。

(4)不要捕獲異常,直接在函式定義中使用throws宣告將丟擲該異常。讓呼叫者去處理該異常。

因此,當捕獲一個checked Exception的時候,必須對異常進行處理;如果認為不必要在這裡作處理,就不要捕獲該異常,在方法體中宣告方法丟擲異常,由上層呼叫者來處理該異常。

5.2. 建議:不要捕獲unchecked Exception

有兩種unchecked Exception:

Error:這種情況屬於JVM發生了不可恢復的故障,例如記憶體溢位,無法處理。

RuntimeException:這種情況屬於錯誤的編碼導致的,出現異常後需要修改程式碼才能修復,一般來說catch後沒有恰當的處理方式,因此不應該捕獲。(該規則有例外情況,參見:5.11守護執行緒中需要catch runtime exception)

例如由於編碼錯誤,下面的程式碼會產生ArrayIndexOutofBoundException。修改程式碼後才能修復該異常。

int[] intArray = new int[10];

intArray[10]=1;

5.3. 原則:不要一次捕獲所有的異常

請看下面的程式碼:

try
{
   method1();  //method1丟擲ExceptionA
   method2();  //method1丟擲ExceptionB
   method3();  //method1丟擲ExceptionC
}
catch(Exception e)
{
   ……
}

這是一個很誘人的方案,程式碼中使用一個catch子句捕獲了所有異常,看上去完美而且簡潔,事實上很多程式碼也是這樣寫的。但這裡有兩個潛在的缺陷,一是針對try塊中丟擲的每種Exception,很可能需要不同的處理和恢復措施,而由於這裡只有一個catch塊,分別處理就不能實現。二是try塊中還可能丟擲RuntimeException,程式碼中捕獲了所有可能丟擲的RuntimeException而沒有作任何處理,掩蓋了程式設計的錯誤,會導致程式難以除錯。

下面是改正後的正確程式碼:

try
{
  method1();  //method1丟擲ExceptionA
   method2();  //method1丟擲ExceptionB
   method3();  //method1丟擲ExceptionC
}
catch(ExceptionA e)
{
   ……
}
catch(ExceptionB e)
{
   ……
}
catch(ExceptionC e)
{
   ……
}

5.4  原則:使用finally塊釋放資源

什麼是資源:程式中使用的數量有限的物件,或者只能獨佔式訪問的物件。例如:檔案、執行緒、執行緒池、資料庫連線、ftp連線。因為資源是“有限的”,因此資源使用後必須釋放,以避免程式中的資源被耗盡,影響程式執行。某些資源,使用完畢後會自動釋放,如執行緒。某些資源則需要顯示釋放,如資料庫連線。

   finally關鍵字保證無論程式使用任何方式離開try塊,finally中的語句都會被執行。在以下情況下,finally塊的程式碼都會執行:

(1) try塊中的程式碼正常執行完畢。

(2)在try塊中丟擲異常。

(3)在try塊中執行return、break、continue。

(4) catch塊中程式碼執行完畢。

(5)在catch塊中丟擲異常。

(6)在catch塊中執行return、break、continue。

因此,當你需要一個地方來執行在任何情況下都必須執行的程式碼時,就可以將這些程式碼放入finally塊中。當你的程式中使用了資源,如資料庫連線,檔案,Ftp連線,執行緒等,必須將釋放這些資源的程式碼寫入finally塊中。

finally關鍵字可和catch關鍵字一起使用。如下:

try
{
   ……
}
catch(MyException e)
{
   ……
}
finally
{
   ……
}


finally關鍵字也可以單獨使用,不catch異常,將異常throw給呼叫者處理。

try
{
   ……
}
finally
{
   ……
}


5.5.    原則:finally塊不能丟擲異常

JAVA異常處理機制保證無論在任何情況下都先執行finally塊的程式碼,然後再離開整個try,catch,finally塊。在try,catch塊中向外丟擲異常的時候,JAVA虛擬機器先轉到finally塊執行finally塊中的程式碼,finally塊執行完畢後,再將異常丟擲。但如果在finally塊中丟擲異常,try,catch塊的異常就不能丟擲,外部捕捉到的異常就是finally塊中的異常資訊,而try,catch塊中發生的真正的異常堆疊資訊則丟失了。

請看下面的程式碼:

Connection con = null;
try
{
   con = dataSource.getConnection();
   ……
}
catch(SQLException e)
{
   ……
   throw e;//進行一些處理後再將資料庫異常丟擲給呼叫者處理
}
finally
{
   try
    {
       con.close();
    }
   catch(SQLException e)
   {
        e.printStackTrace();
        ……
   }
}


執行程式後,呼叫者得到的資訊如下

      java.lang.NullPointerException

          atmyPackage.MyClass.method1(methodl.java:266)

而不是我們期望得到的資料庫異常。這是因為這裡的con是null的關係,在finally語句中丟擲了NullPointerException,在finally塊中增加對con是否為null的判斷可以避免產生這種情況。

 5.6.  原則:異常不能影響物件的狀態

異常產生後不能影響物件的狀態,這是異常處理中的一條重要規則。在一個函式

中發生異常後,物件的狀態應該和呼叫這個函式之前保持一致,以確保物件處於正確的狀態中。

如果物件是不可變物件(不可變物件指呼叫建構函式建立後就不能改變的物件,即

    建立後沒有任何方法可以改變物件的狀態),那麼異常發生後物件狀態肯定不會改變。如果是可變物件,必須在程式設計中注意保證異常不會影響物件狀態。有三個方法可以達到這個目的:

(1)將可能產生異常的程式碼和改變物件狀態的程式碼分開,先執行可能產生異常的程式碼,如果產生異常,就不執行改變物件狀態的程式碼。

(2)對不容易分離產生異常程式碼和改變物件狀態程式碼的方法,定義一個recover方法,在異常產生後呼叫recover方法修復被改變的類變數,恢復方法呼叫前的類狀態。

(3)在方法中使用物件的拷貝,這樣當異常發生後,被影響的只是拷貝,物件本身不會受到影響。

 5.7. 原則:丟擲自定義異常異常時帶上原始異常資訊

請看下面的程式碼:

public void method2()
{
    try
    {
         ……
        method1();  //method1進行了資料庫操作
    } 
    catch(SQLExceptione)
   {
         ……
        throw new MyException(“發生了資料庫異常:”+e.getMessage);
    }
}
public void method3()
{
    try
    {
        method2();
    }
    catch(MyExceptione)
    {
        e.printStackTrace();
          ……
    }
}


上面method2的程式碼中,try塊捕獲method1丟擲的資料庫異常SQLException後,丟擲了新的自定義異常MyException。這段程式碼是否並沒有什麼問題,但看一下控制檯的輸出:

MyException:發生了資料庫異常:物件名稱'MyTable' 無效。

at MyClass.method2(MyClass.java:232)

at MyClass.method3(MyClass.java:255)

原始異常SQLException的資訊丟失了,這裡只能看到method2裡面定義的MyException的堆疊情況;而method1中發生的資料庫異常的堆疊則看不到,如何排錯呢,只有在method1的程式碼行中一行行去尋找資料庫操作語句了,祈禱method1的方法體短一些吧。

JDK的開發者們也意識到了這個情況,在JDK1.4.1中,Throwable類增加了兩個構造方法,publicThrowable(Throwable cause)和public Throwable(String message,Throwable cause),在建構函式中傳入的原始異常堆疊資訊將會在printStackTrace方法中打印出來。

5.8.    原則:列印異常資訊時帶上異常堆疊

為便於分析異常原因,在進行異常輸出時需要帶上異常的堆疊,在進行編碼時該問題容易忽視,需要注意,如下:

public void method3()
{
    try
    {
         method2();
    }
    catch(MyExceptione)
   {
              ……//對異常進行處理
             System.out.println(e);//列印異常資訊
   }
}


System.out.println(e)相當於System.out.println(e.toString()),不能列印異常堆疊,不利於事後對異常進行分析。

 正確的列印方式:

public void method3()
{
    try
    {
         method2();
    }
    catch(MyExceptione)
    {
             ……//對異常進行處理
              e.printStackTrace();//列印異常資訊
    }
}


5.9. 原則:不要使用同時使用異常機制和返回值來進行異常處理

請看下面一段程式碼

try
{
    doSomething();
}
catch(MyException e)
{
    if(e.getErrcode == -1)
    {
       ……
    }
    if(e.getErrcode == -2)
    {
       ……
    }
    ……
}


假如在過一段時間後來看這段程式碼,你能弄明白是什麼意思嗎?混合使用JAVA異常處理機制和返回值使程式的異常處理部分變得“醜陋不堪”,並難以理解。如果有多種不同的異常情況,就定義多種不同的異常,而不要像上面程式碼那樣綜合使用Exception和返回值。

修改後的正確程式碼如下:

try
{
   doSomething();  //丟擲MyExceptionA和MyExceptionB
}
catch(MyExceptionA e)
{
    ……
}
catch(MyExceptionB e)
{
   ……
}


5.10.  建議:不要讓try塊過於龐大

出於省事的目的,很多人習慣於用一個龐大的try塊包含所有可能產生異常的程式碼,

這樣有兩個壞處:

閱讀程式碼的時候,在try塊冗長的程式碼中,不容易知道到底是哪些程式碼會丟擲哪些異常,不利於程式碼維護。

使用try捕獲異常是以程式執行效率為代價的,將不需要捕獲異常的程式碼包含在try塊中,影響了程式碼執行的效率。

5.11. 原則:守護執行緒中需要catch runtime exception

守護執行緒是指在需要長時間執行的執行緒,其生命週期一般和整個程式的時間一樣長,用於提供某種持續的服務。例如伺服器的告警定時同步執行緒,客戶端的告警分發執行緒。由於守護執行緒需要長時間提供服務,因此需要對runtime exception進行保護,避免因為某一次偶發的異常而導致執行緒被終止。

while (true)
{
    try
    {
         doSomethingRepeted();
    }
    catch(MyExceptionA e)
    {
        //對checkedexception進行恰當的處理
        ……
    }
    catch(RuntimeException e)
    {
         //列印執行期異常,用於分析並修改程式碼
         e.printStackTrace();
    }
}


參考資料
[1]   Joshua Bloch  Effective Java Programming Language Guide 
[2]   http://java.sun.com/