最近在閱讀《Java編程思想》的時候看到了書中對異常的描述,結合自己閱讀源碼經歷,談談自己對異常的理解。首先記住下面兩句話:
除非你能解決(或必須要處理)這個異常,否則不要捕獲它,如果打算記錄錯誤消息,那么別忘了把它再拋出去。
異常既代表一種錯誤,又可以代表一個消息。
一、為什么會有異常
這個問題其實不難理解,如果一切都按我們設計好的進行,那么一般(不一般的情況是我們設計的就是有缺陷的)是不會出現異常的,比如說一個除法操作:
public int div(int x,int y){ return x/y; }
當然我們設計的是除數不能為0,我們也在方法名上添加了注釋,輸出不能為0,如果用戶按照我們的要求使用這個方法,當然不會有異常產生。可是很多時候,用戶不一定閱讀我們的注釋,或者說,輸入的數據不是用戶主動指定的,而是程序計算的中間結果,這個時候就會導致除數為0的情況出現。
現在異常情況出現了,程序應該怎么辦呢,直接掛掉肯定是不行的,但是程序確實不能自己處理這種突發情況,所以得想辦法把這種情況告訴用戶,讓用戶自己來決定,也就是說程序需要把遇到的這種異常情況包裝一下發送出去,由用戶來決定如何處理。
異常表示着一種信息。熟悉EOFException的程序員一般都會了解,這個異常,表示信息的成分大於表示出現了異常,不熟悉的參照我之前的博客:http://www.cnblogs.com/yiwangzhibujian/p/7107084.html。當這種情形下的異常(包括用戶自定義的大部分異常都屬於此類)出現時,是不需要解決的。
二、Java異常的分類
在繼續講解下面部分之前,還是有必要了解下Java的異常分類的,通過Java API可以看到如下繼承關系:
簡單介紹一點:
- Throwable是所有異常的父類
- Error表示很嚴重的問題發生了,可以捕獲但是不要捕獲,因為捕獲了也解決不了,這個不是由程序產出的,底層出現問題就讓他它掛了吧。
三、異常的處理的理解
再把一開始說的那句話重復一遍,除非你能解決這個異常,否則不要捕獲它,如果打算記錄錯誤消息,那么別忘了把它再拋出去。不過說真的,一個異常既然產生了,基本都是不能解決的,因為我們的程序不能倒退到出現異常的代碼,更不能在相同輸入(不能改變輸入,不然結果還有什么用),相同代碼(不能動態改變原有代碼)的情況下來來讓它不再出現異常,不然同一段代碼,在同一個輸入的情況下有兩種不同的結果,誰還敢用呢?
除非我們的程序需要依賴外部條件,而由外部條件導致的異常,我們可以改變外部條件使之滿足程序要求,不過這種情況基本都可以在程序執行前檢測出來。
3.1 怎么才算解決異常
舉兩個簡單的例子方便理解下,第一個是關於Socket的,具體Socket的知識可以參考我之前的博客:http://www.cnblogs.com/yiwangzhibujian/p/7107785.html。
3.1.1 重復嘗試解決偶發問題
在Socket建立連接以后,我們可以通過Socket發送消息,高效的Socket利用方法是建立一個連接來持續使用,可是在這種情況下,有一個需要注意的問題,那就是我在每次發送消息的時候,要不要檢測Socket是否還在連接中,我的在上面博客中介紹了,不需要。偽代碼如下:
//有一個連接中的socket Socket socket=... //要發送的數據 String data=""; try{ socket.write(data); }catch (Excetption e){ //打印日志,並重連Socket socket=new Socket(host,port); socket.write(data); }
可以看到,假如當前連接不可用(長時間不用被服務器主動斷開,或者網絡抖動導致的斷開),那么我們捕獲這個異常,然后重新建立一個連接來發送。這是最基本的解決方法,再高級一點的就是設置一個重復次數,當出現異常的時候重復發送指定的次數。
如果我們仔細想想,這個連接異常我們沒有真正的解決它,而是通過又新建了一個連接來處理的,我們解決的不是這個異常,而是發送數據出現了問題,我們解決的是發送數據沒有成功這個問題。
同樣的,重復嘗試解決的偶發問題,這個偶發也是外部的條件導致的偶發,而不是程序自身問題。
3.1.2 不想看到錯誤堆棧
一般的Web三層架構,action,server,Dao,如果出現異常后,再不滿足上面解決條件的情況下,如果都不捕獲異常,那么用戶將會看到一個500頁面,附帶着堆棧信息,這種事不友好的表現方式,這種情況下,我們就需要在action層,用一個最大的try catch包住一個個方法,當出現異常的時候跳轉到錯誤頁面。
public String method(String param){ try { //邏輯處理 } catch (Exception e) { e.printStackTrace(); //跳轉到錯誤頁面 } }
實際上,我們沒有解決異常,我們只是解決了異常導致的問題,異常本身還在那,真正的解決方法就是程序員解決bug然后重新上線。
這種也算另類的解決,迫不得已不得不這么做,實際上異常是被吞掉了,吞掉前留下了一點點信息。
3.2 我們應該怎么做
首要條件還是那句話,如果不能解決到出現異常的情況,那就不要捕獲它,更不要吞掉他。
當然有的時候你會打算記錄異常的日志,但是最開始也說過,異常也代表一個消息,就像IndexOutOfBoundsException、IOException本身的名字已經可以表明異常的大部分信息,也就是說通過異常堆棧基本就能得到關於異常部分的信息,但是有些異常堆棧沒有的是什么呢,那就是發生異常條件時的外部信息。
當然在拋出異常的時候,虛擬機本身會盡可能的打印出直接導致異常產生的輸入,可是當我們還想獲取額外的環境信息的時候,我們就需要捕獲異常,然后打印出來。
就像簡單的除0異常,以及字符串轉數字異常,本身異常堆棧就會提供基本的信息,但是如果我們在一個用戶交互的環境下,假如我們想要知道是哪個用戶的輸入導致了異常的產生,這個時候系統產生的異常堆棧信息就不能滿足我們的要求了,而這個信息在當前類的一個字段中,這時候我們就要主動捕獲然后打印出我們想要的。
四、異常的處理
現在就這各種實例來說明異常怎么處理。
4.1 對認為一定不會出現的異常
假如說你寫了一個工具類,用於字符串和字節數組的UTF-8的轉換,假如如下:
package yiwangzhibujian.util; import java.io.UnsupportedEncodingException; public class Utils { public static String utf8(byte[] bytes) throws UnsupportedEncodingException{ return new String(bytes,"UTF-8"); } public static byte[] utf8(String str) throws UnsupportedEncodingException{ return str.getBytes("UTF-8"); } }
那么用你工具類的人會頭疼死,明明不會有錯誤的,要么拋出這個異常,要么捕獲,實際上使用者根本不能解決這個異常。
所以有的人可能這么做,他想既然這個異常一定不可能出現(本質上jvm一定能解析UTF-8的編碼,如果不能解析jvm也就不需要繼續運行了),那么我就吞了它,什么都不做:
package yiwangzhibujian.util; import java.io.UnsupportedEncodingException; public class Utils { public static String utf8(byte[] bytes){ try { return new String(bytes,"UTF-8"); } catch (UnsupportedEncodingException e) { } return null; } public static byte[] utf8(String str){ try { return str.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return null; } }
這么做的人也有,不過這么做的人也分為兩種,一種是catch內什么都不做,還有一種是catch內把異常信息打印出來,這兩種做法我比較傾向於后面那種,因為要考慮以下條件。
你認為jvm一定能解析UTF-8,我不反對,可是你能保證你沒有拼錯UTF-8嗎,假如你寫成UFO-8呢?
public static String utf8(byte[] bytes){ try { return new String(bytes,"UFO-8"); } catch (UnsupportedEncodingException e) { } return null; }
那么調用你的方法不僅沒有錯誤提示,還導致返回了錯誤的結果,並導致后續一系列問題的產生,最致命的是 ,我們根部不知道錯誤的根源在哪。
再舉一個對象克隆的例子。
package yiwangzhibujian.util; public class CloneTest { public static void main(String[] args) { Dog d1=new Dog("zhuzhuxia",26); Dog d2=d1.clone();//此處要么捕獲要么拋出 System.out.println(d1); System.out.println(d2); } } class Dog{ public String name; public int age; public Dog(String name, int age) { super(); this.name = name; this.age = age; } @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }
可以看到用戶調用你的對象的克隆方法是不是很痛苦,你既然提供給我克隆方法,就一定要能用,如果不能用,那么拿回去重寫吧,我不會給你擦屁股的。所以我們就會這么做:
package yiwangzhibujian.util; public class CloneTest { public static void main(String[] args) { Dog d1=new Dog("zhuzhuxia",26); Dog d2=d1.clone();//此處要么捕獲要么拋出 System.out.println(d1); System.out.println(d2); } } class Dog{ public String name; public int age; public Dog(String name, int age) { super(); this.name = name; this.age = age; } @Override protected Dog clone() { try { return (Dog) super.clone(); } catch (Exception e) { e.printStackTrace();//不要省 } return null; } }
如果你運行上面的代碼的話,那么就會拋出異常,因為我們的類沒有實現Cloneable接口,原因就是忘了寫,這在測試運行的首次就會發現並糾正。
java.lang.CloneNotSupportedException: yiwangzhibujian.util.Dog at java.lang.Object.clone(Native Method) at yiwangzhibujian.util.Dog.clone(CloneTest.java:22) at yiwangzhibujian.util.CloneTest.main(CloneTest.java:6) yiwangzhibujian.util.Dog@2a139a55 null
所以,我可以假定這種情況下不會出異常,但是我們不能保證我們沒有犯最基本的錯誤,所以錯誤堆棧還是不能省的。
我們來看一下jdk8中的HashMap關於克隆的處理:
@SuppressWarnings("unchecked") @Override public Object clone() { HashMap<K,V> result; try { result = (HashMap<K,V>)super.clone(); } catch (CloneNotSupportedException e) { // this shouldn't happen, since we are Cloneable throw new InternalError(e); } result.reinitialize(); result.putMapEntries(this, false); return result; }
是不是不會拋必須捕獲的異常,它還做了更高級的事,那就是我拋一個ERROR,一般我們的程序都是捕獲Exception,不會捕捉這個異常,這個異常會一直向上傳播。
那么打印異常堆棧和拋出ERROR哪種更好呢,我的建議是拋出ERROR:
- 能出現這種情況也就代表jvm出現了問題,或許其他基本功能也出現了問題,應該立即停掉重啟並解決問題,不然數據都有可能出現錯誤。
- 如果打印堆棧信息,那么下次調用還是會出錯,不如直接拋ERROR,如果上層沒有做具體的應對jvm應該會停止。
4.2 對假定不應該出現的異常
我們再拿上面的字符串,字節數組例子來說明,我們對它進行了升級,下面是不完整代碼:
public static String byteToStr(byte[] bytes, String charsetName) { return new String(bytes, charsetName); }
應該怎么做,拋異常?捕獲異常打印日志?兩種做法都不好:
- 如果拋異常:那么使用你工具類的人依然很頭疼,他必須在每次調用你方法的時候做處理,要么拋要么捕獲,而他在想我明明傳入一個UTF-8,非得給我拋異常,難用死了。
- 如果捕獲打印日志:這個更不可取,如果用戶輸錯了編碼類型,那么你將不能給出任何信息給調用者(打印日志只能事后找錯),用戶認為寫的沒錯而你也給出了返回值,這也會導致一系列錯誤的產生。
這種情況下應該怎么做呢,比較推薦的做法就是包裝成運行時異常拋出:
public static String byteToStr(byte[] bytes, String charsetName) { try { return new String(bytes, charsetName); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } }
這么做就解決了上面的兩個問題。
4.3 對假定一定出異常的情況
你的代碼一定會出異常,那你還是拿回去重寫吧。除非你不想讓別人調用你的方法,比如說不可變容器的操作類方法都將拋出異常。
五、異常的一些特殊情況
5.1 防止異常丟失
在你不主動吞並異常的情況下,異常是不會丟失的,但是有一種特殊情形需要注意,那就是finally中有return的情況(代碼參照Java編程思想):
public static void ExceptionSilencer(){ try { throw new RuntimeException(); } finally { return; } }
這種情況下,異常就會丟了,完完全全消失不見了,所以要避免這么使用,避免finally中使用return。
5.2 線程中ThreadDeath異常
這個異常是歸於ERROR級別的,Java api也對此有相應介紹:
The ThreadDeath error, though a "normal" condition, is also a subclass of Error because most applications should not try to catch it.
就是說ThreadDeath本身是一個普通的異常,這個異常出現應該導致線程死亡,但是不把它歸於Exception的原因就是,jdk的開發者也料到Java程序員最喜歡try catch異常然后吞掉了,這樣將會導致本該死亡的線程繼續運行下去,這是不應該的。而且當這個異常出現時會終結線程,但是不會打印出任何異常堆棧信息。
這個異常比較少見,Thread的stop方法,會產生這個異常。
如果你的線程經常莫名其妙的消失,而沒有任何相關日志,你可以嘗試捕獲這個異常,但是記住,打印完相關日志再把它重新拋出去。
六、Java編程思想中關於總結的解讀
下面摘自Java編程思想的異常使用指南,特別好,一定要深入理解一下:
- 在恰當的級別處理問題。(在知道該如何處理的情況下了捕獲異常。)
- 解決問題並且重新調用產生異常的方法。
- 進行少許修補,然后繞過異常發生的地方繼續執行。
- 用別的數據進行計算,以代替方法預計會返回的值。
- 把當前運行環境下能做的事盡量做完,然后把相同的異常重拋到更高層
- 把當前運行環境下能做的事盡量做完,然后把不同的異常拋到更高層
- 終止程序
- 進行簡化(如果你的異常模式使問題變得太復雜,那么用起來會非常痛苦)。
- 讓類庫和程序更安全。
下面依次說下我的想法。
- 第1條:上面章節已經介紹了,此處不再說明
- 第2條:上面也介紹過,就是外部條件導致的,可以重復執行可能正常的代碼
- 第3條:這種情況實質上也是吞並異常,比如說網絡爬蟲,當遇到死鏈接的時候,可能會拋出連接異常等,此時拋棄這個連接也是可以的,這個誤差可以接收
- 第4條:有的程序員會這么設計,當出現用戶輸出錯誤數據導致異常的時候,就用一個默認的值來代替,我不喜歡這么做,我會直接拋異常讓使用者去更改,如果非要這么做一定要打印好相關日志
- 第5條:這種情況看需求,如果要求要么全部成功,要么都不做,那么就不適合這種情況
- 第6條:同上,但是我不太理解這個
- 第7條:這個就不要了吧,出現一個異常程序就掛了,那也太脆了,不過當程序在正常啟動過程中,如果出現異常就直接掛掉還是合理的,讓用戶修改外部條件保障啟動沒有問題,比如說用戶指定的配置文件不存在(或許他寫錯了路徑),那么不要使用默認配置,程序直接掛掉就可以了,不然會給用戶一種按照他的配置成功啟動的錯覺。
- 第8條:這個和上面說到的用運行時異常來包裹捕獲異常一個性質。
- 第9條:這個是終極目標,考慮所有的情況,把異常消滅在萌芽中,過於理想了。一般越安全越健壯的程序考慮的異常條件就越多。一般都會在使用前做各種判斷,條件是否滿足,輸入是否正確等。
以上就是我對異常的理解,希望可以幫助到有需要的人,如果你能認真看完我相信你會有收獲的,如果錯誤請指出,禁止轉載。