【JAVA 提高班之三】異常處理

Java中異常提供了一種識別及響應錯誤情況的一致性機制,有效地異常處理能使程序更加健壯、易於調試。異常之所以是一種強大的調試手段,在於其回答了以下三個問題:

  • 什麼出了錯?
  • 在哪出的錯?
  • 爲什麼出錯?

在有效使用異常的情況下,異常類型回答了“什麼”被拋出,異常堆棧跟蹤回答了“在哪“拋出,異常信息回答了“爲什麼“會拋出,如果你的異常沒有回答以上全部問題,那麼可能你沒有很好地使用它們。有三個原則可以幫助你在調試過程中最大限度地使用好異常,這三個原則是:

  • 具體明確
  • 提早拋出
  • 延遲捕獲

具體明確

這裏寫圖片描述

在 Java 中,所有的異常都有一個共同的祖先 Throwable(可拋出)。Throwable 指定代碼中可用異常傳播機制通過 Java 應用程序傳輸的任何問題的共性。

  • Throwable: 有兩個重要的子類:Exception(異常)和 Error(錯誤),二者都是 Java 異常處理的重要子類,各自都包含大量子類。

  • Error(錯誤):是程序無法處理的錯誤,表示運行應用程序中較嚴重問題。大多數錯誤與代碼編寫者執行的操作無關,而表示代碼運行時 JVM(Java 虛擬機)出現的問題。例如,Java虛擬機運行錯誤(Virtual MachineError),當 JVM 不再有繼續執行操作所需的內存資源時,將出現 OutOfMemoryError。這些異常發生時,Java虛擬機(JVM)一般會選擇線程終止。這些錯誤表示故障發生於虛擬機自身、或者發生在虛擬機試圖執行應用時,如Java虛擬機運行錯誤(Virtual MachineError)、類定義錯誤(NoClassDefFoundError)等。這些錯誤是不可查的,因爲它們在應用程序的控制和處理能力之 外,而且絕大多數是程序運行時不允許出現的狀況。對於設計合理的應用程序來說,即使確實發生了錯誤,本質上也不應該試圖去處理它所引起的異常狀況。在 Java中,錯誤通過Error的子類描述。

  • Exception(異常):是程序本身可以處理的異常。Exception 類有一個重要的子類 RuntimeException。RuntimeException 類及其子類表示“JVM 常用操作”引發的錯誤。例如,若試圖使用空值對象引用、除數爲零或數組越界,則分別引發運行時異常(NullPointerException、ArithmeticException)和 ArrayIndexOutOfBoundException。

注意:異常和錯誤的區別:異常能被程序本身可以處理,錯誤是無法處理。

通常,Java的異常(包括Exception和Error)分爲可查的異常(checked exceptions)和不可查的異常(unchecked exceptions)。可查異常(編譯器要求必須處置的異常):正確的程序在運行中,很容易出現的、情理可容的異常狀況。可查異常雖然是異常狀況,但在一定程度上它的發生是可以預計的,而且一旦發生這種異常狀況,就必須採取某種方式進行處理。

除了RuntimeException及其子類以外,其他的Exception類及其子類都屬於可查異常。這種異常的特點是Java編譯器會檢查它,也就是說,當程序中可能出現這類異常,要麼用try-catch語句捕獲它,要麼用throws子句聲明拋出它,否則編譯不會通過。

不可查異常(編譯器不要求強制處置的異常):包括運行時異常(RuntimeException與其子類)和錯誤(Error)。

Exception 這種異常分兩大類運行時異常和非運行時異常(編譯異常)。程序中應當儘可能去處理這些異常。

運行時異常:都是RuntimeException類及其子類異常,如NullPointerException(空指針異常)、IndexOutOfBoundsException(下標越界異常)等,這些異常是不檢查異常,程序中可以選擇捕獲處理,也可以不處理。這些異常一般是由程序邏輯錯誤引起的,程序應該從邏輯角度儘可能避免這類異常的發生。

運行時異常的特點是Java編譯器不會檢查它,也就是說,當程序中可能出現這類異常,即使沒有用try-catch語句捕獲它,也沒有用throws子句聲明拋出它,也會編譯通過。

提早拋出
異常堆棧信息提供了導致異常出現的方法調用鏈的精確順序,包括每個方法調用的類名,方法名,代碼文件名甚至行數,以此來精確定位異常出現的現場。

java.lang.NullPointerException
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:103)
at jcheckbook.JCheckbook.readPreferences(JCheckbook.java:225)
at jcheckbook.JCheckbook.startup(JCheckbook.java:116)
at jcheckbook.JCheckbook.<init>(JCheckbook.java:27)
at jcheckbook.JCheckbook.main(JCheckbook.java:318)

以 上展示了FileInputStream類的open()方法拋出NullPointerException的情況。不過注意 FileInputStream.close()是標準Java類庫的一部分,很可能導致這個異常的問題原因在於我們的代碼本身而不是Java API。所以問題很可能出現在前面的其中一個方法,幸好它也在堆棧信息中打印出來了。

不幸的是,NullPointerException是Java中信息量最少的(卻也是最常遭遇且讓人崩潰的)異常。它壓根不提我們最關心的事情:到底哪裏是null。所以我們不得不回退幾步去找哪裏出了錯。

通過逐步回退跟蹤堆棧信息並檢查代碼,我們可以確定錯誤原因是向readPreferences()傳入了一個空文件名參數。既然readPreferences()知道它不能處理空文件名,所以馬上檢查該條件:

public void readPreferences(String filename)
throws IllegalArgumentException{
    if (filename == null){
         throw new IllegalArgumentException("filename is null");
    }  //if

   //...perform other operations...

   InputStream in = new FileInputStream(filename);

   //...read the preferences file...
}

通過提早拋出異常(又稱"迅速失敗"),異常得以清晰又準確。堆棧信息立即反映出什麼出了錯(提供了非法參數值),爲什麼出錯(文件名不能爲空值),以及哪裏出的錯(readPreferences()的前部分)。這樣我們的堆棧信息就能如實提供:

java.lang.IllegalArgumentException: filename is null
at jcheckbook.JCheckbook.readPreferences(JCheckbook.java:207)
at jcheckbook.JCheckbook.startup(JCheckbook.java:116)
at jcheckbook.JCheckbook.<init>(JCheckbook.java:27)
at jcheckbook.JCheckbook.main(JCheckbook.java:318)

另外,其中包含的異常信息("文件名爲空")通過明確回答什麼爲空這一問題使得異常提供的信息更加豐富,而這一答案是我們之前代碼中拋出的NullPointerException所無法提供的。

通過在檢測到錯誤時立刻拋出異常來實現迅速失敗,可以有效避免不必要的對象構造或資源佔用,比如文件或網絡連接。同樣,打開這些資源所帶來的清理操作也可以省卻。

延遲處理
在方法拋出異常之後,運行時系統將轉爲尋找合適的異常處理器(exception handler)。潛在的異常處理器是異常發生時依次存留在調用棧中的方法的集合。當異常處理器所能處理的異常類型與方法拋出的異常類型相符時,即爲合適 的異常處理器。運行時系統從發生異常的方法開始,依次回查調用棧中的方法,直至找到含有合適異常處理器的方法並執行。當運行時系統遍歷調用棧而未找到合適 的異常處理器,則運行時系統終止。同時,意味着Java程序的終止。

菜鳥和高手都可能犯的一個錯是,在程序有能力處理異常之前就捕獲它。Java編譯器通過要求檢查出的異常必須被捕獲或拋出而間接助長了這種行爲。自然而然的做法就是立即將代碼用try塊包裝起來,並使用catch捕獲異常,以免編譯器報錯。

問 題在於,捕獲之後該拿異常怎麼辦?最不該做的就是什麼都不做。空的catch塊等於把整個異常丟進黑洞,能夠說明何時何處爲何出錯的所有信息都會永遠丟失。把異常寫到日誌中還稍微好點,至少還有記錄可查。但我們總不能指望用戶去閱讀或者理解日誌文件和異常信息。讓readPreferences()顯示錯誤信息對話框也不合適,因爲雖然JCheckbook目前是桌面應用程序,但我們還計劃將它變成基於HTML的Web應用。那樣的話,顯示錯誤對話框顯然不是個選擇。同時,不管HTML還是C/S版本,配置信息都是在服務器上讀取的,而錯誤信息需要顯示給Web瀏覽器或者客戶端程序。 readPreferences()應當在設計時將這些未來需求也考慮在內。適當分離用戶界面代碼和程序邏輯可以提高我們代碼的可重用性。

在有條件處理異常之前過早捕獲它,通常會導致更嚴重的錯誤和其他異常。例如,如果上文的readPreferences()方法在調用FileInputStream構造方法時立即捕獲和記錄可能拋出的FileNotFoundException,代碼會變成下面這樣:

public void readPreferences(String filename){
   //...

   InputStream in = null;

   // DO NOT DO THIS!!!
try{
    in = new FileInputStream(filename);
}
catch (FileNotFoundException e){
    logger.log(e);
}

in.read(...);

//...
}

上 面的代碼在完全沒有能力從FileNotFoundException中恢復過來的情況下就捕獲了它。如果文件無法找到,下面的方法顯然無法讀取它。如果 readPreferences()被要求讀取不存在的文件時會發生什麼情況?當然,FileNotFoundException會被記錄下來,如果我們 當時去看日誌文件的話,就會知道。然而當程序嘗試從文件中讀取數據時會發生什麼?既然文件不存在,變量in就是空的,一個 NullPointerException就會被拋出。

調試程序時,本能告訴我們要看日誌最後面的信息。那將會是NullPointerException,非常讓人討厭的是這個異常非常不具體。錯誤信息不僅誤導我們什麼出了錯(真正的錯誤是FileNotFoundException而不是NullPointerException),還誤導了錯誤的出處。真正 的問題出在拋出NullPointerException處的數行之外,這之間有可能存在好幾次方法的調用和類的銷燬。我們的注意力被這條小魚從真正的錯誤處吸引了過來,一直到我們往回看日誌才能發現問題的源頭。

既然readPreferences() 真正應該做的事情不是捕獲這些異常,那應該是什麼?看起來有點有悖常理,通常最合適的做法其實是什麼都不做,不要馬上捕獲異常。把責任交給 readPreferences()的調用者,讓它來研究處理配置文件缺失的恰當方法,它有可能會提示用戶指定其他文件,或者使用默認值,實在不行的話也 許警告用戶並退出程序。

把異常處理的責任往調用鏈的上游傳遞的辦法,就是在方法的throws子句聲明異常。在聲明可能拋出的異常時,注意越具體越好。這用於標識出調用你方法的程序需要知曉並且準備處理的異常類型。例如,“延遲捕獲”版本的readPreferences()可能是這樣的:

public void readPreferences(String filename)
throws IllegalArgumentException,
FileNotFoundException, IOException{
    if (filename == null){
           throw new IllegalArgumentException("filename is null");
     }  //if

     //...

     InputStream in = new FileInputStream(filename);

//...
}

技 術上來說,我們唯一需要聲明的異常是IOException,但我們明確聲明瞭方法可能拋出FileNotFoundException。 IllegalArgumentException不是必須聲明的,因爲它是非檢查性異常(即RuntimeException的子類)。然而聲明它是爲 了文檔化我們的代碼(這些異常也應該在方法的JavaDocs中標註出來)。

當 然,最終你的程序需要捕獲異常,否則會意外終止。但這裏的技巧是在合適的層面捕獲異常,以便你的程序要麼可以從異常中有意義地恢復並繼續下去,而不導致更 深入的錯誤;要麼能夠爲用戶提供明確的信息,包括引導他們從錯誤中恢復過來。如果你的方法無法勝任,那麼就不要處理異常,把它留到後面捕獲和在恰當的層面處理。

我們現在來說說到底該如何處理捕獲到的編譯時異常:
一、恢復並繼續執行:這個結果是最完美的,也是編譯時異常出生的目的——捕獲異常,並恢復繼續執行程序。所以如果你捕獲了一個異常是先盡力恢復,這種情況其實就是在主方案行不通時,用備選方案,而且主方案能否行通不能事先知道,必須執行的時候才能知道,所以在一般情況下,備選方案比主方案要的運行結果要差。

二、向上傳播異常:向上傳播就是在本方法上用throws申明,本方法裏的代碼不對某異常做任何處理。如果不能用上述恢復措施,就檢查能不能向上傳播,什麼情況下可以向上傳播呢?有多種說法,一種說法是當本方法恢復不了時,這個說法顯然是錯誤,因爲上層也不一定能恢復。另外還有兩種說法是:1.當上層邏輯可以恢復程序時;2.當本方法除了打印之外不能做任何處理,而且不確定上層能否處理。這種兩種說法都是正確的,但還不夠,因爲也有的情況,明確知道上層恢復不了也需要上層處理,所以我認爲正確的做法是:當你認爲本異常應該由上層處理時,才向上傳播。不過這得根據你程序的設計來靈活思考,比如你的類設計了一個上層方法集中處理異常,而下層有一些private方法只是簡單的用throws申明。當上層方法捕獲到異常時,雖然不能恢復執行,但可以做一些處理,如轉換成便於閱讀的文本,或者用下面討論的轉譯。

三、轉譯異常:轉譯即把低層邏輯的異常轉化成爲高層邏輯的異常,因爲有可能低層邏輯的異常在高層邏輯中不能被理解,主要實現是新寫一個Exception的子類,然後在低層邏輯捕獲異常,改拋這個新寫的異常。

四、改拋爲運行時異常:這個很好玩,也是一條很方便的處理手法(我常用,我用這個還發現了一個Android系統的bug),即當你捕獲到異常時,重新拋出,這跟轉譯很相似,有一點區別,這裏拋的是運行時異常,而轉譯拋的是編譯時異常。那什麼時候使用這個手法呢?簡單的說就是當某個異常出現時,你必須讓程序掛掉。解釋一下:如果某個異常情況一旦出現,程序便無法繼續執行,而且你明確知道本方法和上層邏輯做不出任何有意義的處理,你只能讓程序退出。所以你就拋一個運行時異常讓程序掛掉。舉個例子,比如在加密通信中,服務器捕獲到了一個非法數據異常,這是無法恢復的,而且就是拋一運行異常,讓線程掛掉,連接便會自動中斷。

五、記錄並消耗掉異常:這個手法就是把異常記錄下來(到文件或控制檯)然後忽略掉異常,有可能隨後就讓本方法返回null,這個手法一般用在不是很嚴重的異常,相當於是warning級別的錯誤,出現這個異常對程序的執行可能影響不太,比如程序的某個偏好設置文件(如窗口位置,最近文件等)損壞,但這個文件信息很少,程序只要使用默認配置即可。

參考文獻:
http://www.importnew.com/1701.html
http://blog.csdn.net/hguisu/article/details/6155636
http://redhacker.iteye.com/blog/1924071
http://blog.csdn.net/yanquan345/article/details/19633623

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章