【系列】重新認識Java語言——異常(Exception)
異常,是Java中非常常用的功能,它可以簡化程式碼,並且增強程式碼的安全性。本文將介紹一些異常高階知識,也是學習Java一來的一次總結。包括以下內內容:
- 異常的基礎知識
- 異常特點
- 異常誤用
- 如何正確地使用異常
- 異常的實現原理
關於異常
異常機制,是指程式不正常時的處理方式。具體來說,異常機制提供了程式退出的安全通道。當出現錯誤後,程式執行的流程發生改變,程式的控制權轉移到異常處理器。
異常的一般性語法為:
try {
// 有可能丟擲異常的程式碼
} catch (Exception e) {
// 異常處理
} finally {
// 無論是否捕獲到異常都會執行的程式
}
Java異常體系
Java異常中的體系結構如下圖所示。
- Throwable類是整個Java異常體系的超類,都有的異常類都是派生自這個類。包含Error和Exception兩個直接子類。
- Error表示程式在執行期間出現了十分嚴重、不可恢復的錯誤,在這種情況下應用程式只能中止執行,例如JAVA虛擬機器出現錯誤。在程式中不用捕獲Error型別的異常。一般情況下,在程式中也不應該丟擲Error型別的異常。
- Exception是應用層面上最頂層的異常類,包含RuntimeException(執行時異常)和 Checked Exception(受檢異常)。
- RuntimeException
- Checked Exception是相對於Unchecked Exception而言的,Java中並沒有一個名為Checked Exception的類。它是在程式設計中使用最多的Exception,所有繼承自Exception並且不是RuntimeException的異常都是Checked Exception。JAVA 語言規定必須對checked Exception作處理,編譯器會對此作檢查,要麼在方法體中宣告丟擲checked Exception,要麼使用catch語句捕獲checked Exception進行處理,不然不能通過編譯。常用的Checked Exception有IOException、ClassNotFoundException等。
- RuntimeException
異常的特點
通用特點
JVM捕獲並處理未被應用程式捕獲的異常
無論是受檢異常(Checked Exception)還是執行時異常(Runtime Exception),如果異常沒有被應用程式捕獲,那麼最終這個異常會交由JVM來進行處理,會明顯出現下面兩個結果:
1. 當前執行緒會停止執行,異常觸發點後面的程式碼將得不到執行。
2. 異常棧資訊會通過標準錯誤流輸出。
/**
* 應用程式沒有處理丟擲的異常時,會交由JVM來處理這個異常。結果是:
* 1. 當前執行緒會停止執行,異常觸發點後面的程式碼將得不到執行。
* 2. 異常棧資訊會通過標準錯誤流輸出。
*
* @author xialei
* @version 1.0 2016年5月18日下午9:53:54
*/
public class UncatchedException {
public static void main(String[] args) throws Exception {
throwException();
System.out.println("這一行不會被打印出來");
}
public static void throwException() throws Exception {
int i = 0;
if (i == 0) {
throw new Exception();
}
}
}
異常catch有順序性
在catch異常時,如果有多個異常,那麼是會有順序要求的。子型別必須要在父型別之前進行catch,catch與分支邏輯是一致,如果父型別先被catch,那麼後被catch的分支根本得不到執行機會。
/*
* 個人主頁:http://hinylover.space
*
* Creation Date: 2016年4月7日 下午2:29:42
*/
package demo.blog.java.exception;
/**
* 在catch異常時,如果有多個異常,那麼是會有順序要求的。子型別必須要在父型別之前進行catch,
* catch與分支邏輯是一致,如果父型別先被catch,那麼後被catch的分支根本得不到執行機會。
*
* @author xialei
* @version 1.0 2016年5月18日下午10:00:40
*/
public class ExceptionCatchOrder {
public void wrongCatchOrder() {
try {
Integer i = null;
int j = i;
} catch (Exception e) {
} catch (NullPointerException e) { // 編譯不通過,eclipse提示“Unreachable catch block for NullPointerException. It is already handled by the catch block for Exception”
}
}
}
異常被吃掉
如果在finally中返回值,那麼在程式中丟擲的異常資訊將會被吞噬掉。這是一個非常值得注意的問題,因為異常資訊是非常重要的,在出現問題時,我們通常憑它來查詢問題。如果編碼不小心而導致異常被吞噬,排查起來是相當困難的,這將是一個大隱患。
/*
* 個人主頁:http://hinylover.space
*
* Creation Date: 2016年4月7日 下午2:29:42
*/
package demo.blog.java.exception;
/**
* 如果在finally中返回值,那麼在程式中丟擲的異常資訊將會被吞噬掉。
* @author xialei
* @version 1.0 2016年5月18日下午10:08:43
*/
public class FinallySwallowException {
public static void main(String[] args) throws Exception {
System.out.println(swallowException()); // 打印出2,而不是打印出異常棧
}
public static int swallowException() throws Exception {
try {
throw new Exception();
} finally {
return 2;
}
}
}
重寫Exception的fillInStackTrace()方法
使用自定義異常時,可以重寫fillInStackTrace()方法來控制Exception的異常棧資訊。預設情況下,在程式丟擲異常時,最終會通過呼叫private native Throwable fillInStackTrace(int dummy)
這個本地方法來獲取當前執行緒的堆疊資訊,這是一個非常耗時的操作。如果我們僅僅需要用到異常的傳播性質,而不關係異常的堆疊資訊,那麼完全可以通過重寫fillInStackTrace()方法來實現。
/*
* 個人主頁:http://hinylover.space
*
* Creation Date: 2016年4月7日 下午2:29:42
*/
package demo.blog.java.exception;
/**
* 重寫Exception的fillInStackTrace()方法
*
* @author xialei
* @version 1.0 2016年5月18日下午10:18:57
*/
public class MyException extends Exception {
public MyException(String message) {
super(message);
}
/*
* 重寫fillInStackTrace方法會使得這個自定義的異常不會收集執行緒的整個異常棧資訊,會大大
* 提高減少異常開銷。
*/
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public static void main(String[] args) {
try {
throw new MyException("由於MyException重寫了fillInStackTrace方法,那麼它不會收集執行緒執行棧資訊。");
} catch (MyException e) {
e.printStackTrace(); // 在控制檯的列印結果為:demo.blog.java.exception.MyException: 由於MyException重寫了fillInStackTrace方法,那麼它不會收集執行緒執行棧資訊。
}
}
}
受檢異常(checked exception)
必須處理或者向上丟擲
我們必須要對底層丟擲來的受檢異常進行處理,處理方式有try...catch...
或者向上丟擲(throws),否則程式無法通過編譯。
package demo.blog.java.exception;
/**
* 必須對底層丟擲的異常進行處理
* @author xialei
* @version 1.0 2016年5月18日下午10:42:53
*/
public class CheckedException {
public static void main(String[] args) {
throwException(); // 編譯不通過,必須對底層丟擲的異常進行處理
}
public static void throwException() throws Exception {
throw new Exception();
}
}
不能捕獲未被丟擲的受檢異常
如果我們試圖去捕獲一個未被丟擲的受檢異常,程式將無法通過編譯(Exception除外)。
/*
* 個人主頁:http://hinylover.space
*
* Creation Date: 2016年5月18日 下午10:45:32
*/
package demo.blog.java.exception;
import java.io.IOException;
/**
* 不能捕獲一個沒有被丟擲的受檢異常(Exception除外)
* @author xialei
* @version 1.0 2016年5月18日下午10:45:32
*/
public class CantCatchUnthrowedException {
public void cantCatchUnthrowedException() {
try {
int i = 0;
} catch (IOException e) { // 編譯不通過,eclipse提示:Unreachable catch block for IOException. This exception is never thrown from the try statement body
e.printStackTrace();
}
}
}
執行時異常(runtime exception)
執行時異常(runtime exception)與受檢異常(checked exception)的最大區別是不強制對丟擲的異常進行處理。所有的執行時異常都繼承自RuntimeException這個類,別問為什麼,Java是這麼規定的。與受檢異常類似的例子,如果丟擲的是執行時異常,就算不捕獲這個異常,程式也可以編譯通過。
/*
* 個人主頁:http://hinylover.space
*
* Creation Date: 2016年5月18日 下午11:02:57
*/
package demo.blog.java.exception;
/**
* 編譯通過
* @author xialei
* @version 1.0 2016年5月18日下午11:02:57
*/
public class MyRuntimeException {
public void myRuntimeException() {
throw new RuntimeException(); // 可以正常編譯
}
}
異常的使用
用受檢異常還是執行時異常?
在使用異常時,筆者經常為使用何種異常而犯難,在實際使用過程中總結了一些小經驗。
大概率發生時使用執行時異常
從概率上來說,如果這個異常發生的頻率非常高,那麼因為使用執行時異常,最典型的就是NullPointException。Java中呼叫每個物件的方法時,都有可能會發生NullPointException。如果這是一個受檢異常,那麼在每次呼叫物件方法要麼try {} catch {},那麼使用throws關鍵字向上丟擲。無論哪種方式,程式碼無疑都會是非常醜陋的,那畫面太“美”不敢看。如果程式碼裡充斥著各種異常處理塊,可讀性將會大打折扣。
異常無法恢復時使用執行時異常
當異常發生時,如果開發者無法從異常狀態恢復到正常狀態,那麼這種異常應該是執行時異常。如果使用受檢異常,這除了加重開發者的負擔之外,別無它用。當在呼叫其他方法時,如果方法丟擲受檢異常,那麼筆者就會比較緊張。因為這意味著需要停止業務邏輯開發,然後開始思考如何處理這該死的異常。執行時異常通常是由於開發者程式設計不當所引起的,譬如空指標異常、除零異常等。如果開發者在開發過程中小心謹慎,考慮周全,就可能避免這種異常的發生。
可恢復時優先使用受檢異常
如果我們能夠從異常中恢復到正常狀態,那麼應該優先使用受檢異常。為什麼是優先而不是一定呢?因為從原理上來說,使用執行時異常也可以恢復到正常狀態,而且使用執行時異常的程式碼無疑會比較乾淨整潔。而使用受檢異常,明確地說明了呼叫方式時可能發生異常情況,強制開發者去處理這些異常情況常常會增強程式碼的健壯性。受檢異常通常是由外部環境所引起的,譬如IOException等。
使用受檢異常做流程控制
從Java語義上來說,應該是當程式層面真正發生異常狀況時才應該使用異常(Exception),《Effect Java》一書中也建議只有真正的異常情況才使用異常。但我們有時也會利用異常來達到業務流程控制的目的。這樣做主要有下面的好處:
簡化程式碼邏輯。我們無需為多分枝業務流程編寫各種
if...else...
語句來處理不同的情況。相反地,我們只需處理正常的業務流程即可,異常流程只需要通過異常向上丟擲去即可,至於誰去處理這些異常,則不需要我們過多地關心。可讀性增強。如果一段程式碼中充斥著分枝邏輯,那麼整個程式碼的可讀性會非常差。在閱讀程式碼時,很難理清楚程式碼的主幹。說到底,主幹程式碼才是我們重點關注的。如果使用異常進行流程控制,主幹程式碼就清晰地顯示在面前,兩個字:舒服!
儘量集中處理異常
在各種有關程式碼重構的書本中,都會提到一個核心原則:一個方法應該僅做一件事情。如果一個方法中,既包含業務邏輯,又包含異常處理程式,那麼實際上這個方法就做了兩件事情。如果異常上層可以處理,那麼就不應該在下層處理。在上層進行處理的好處是,可以對異常進行統一地處理。而至於將異常處理程式分散到程式碼的各個地方,導致維護起來十分困難。在進行異常處理時,應該優先考慮使用AOP(面向切面程式設計)技術,這樣降低了核心業務邏輯與異常處理的耦合性。
自定義異常體系
在應用系統中應該要建立自己的異常體系,這樣便於統一處理系統中出現的異常。筆者在開發過程中,通常會建立類似下圖所示的系統體系。越靠近底層,越使用更加底層的、具體的異常。如果是其他系統中的異常(譬如Java自身的異常),也應該將其轉化為自定義體系中的對應異常。
異常誤用
e.printTrace()處理所有異常。
使用e.printTrace()
來粗暴地處理所有異常是新手經常犯的毛病,筆者在初學時也是這麼幹的。為什麼會出現這種情況呢?因為如果未處理的受檢異常,程式碼將編譯不通過,IDE(如eclipse)中會無情地打上各種紅叉叉。這是IDE可以幫我們處理的情況,於是按照IDE的提示,try{}catch{}這段程式碼,其預設的異常處理就是呼叫e.printTrace()
方法。初學者只顧著程式碼順利通過編譯,而完全沒有考慮這樣做存在的風險。這樣做的風險如下:
- 錯失正確的處理方法。相當一部分受檢異常通過正確地處理是可以恢復正常,簡單粗暴地使用
e.printTrace()
將錯失恢復機會。 - “丟失”異常資訊。
printTrace()
方法是Throwable
類中的一個方法,它的作用是將異常棧資訊列印到標準錯誤流中。如果Web專案,會將異常資訊列印到容器的日誌檔案中;如果是普通專案,通常會將標準輸出和標準錯誤重定向到dev/null(空裝置)中。無論是何種情況,都有可能導致異常資訊“丟失”(web容器中的日誌其實不代表丟失了,但是我們通常不會利用容器級別的日誌排錯),給排錯帶來很大的麻煩。
全部使用執行時異常
為了“偷懶”,無論什麼情況都使用執行時異常,這樣就可以不用費勁處理異常了,輕輕鬆鬆。但是,現在沒事不代表以後不出事,這無疑為程式碼埋下了隱患。如果在呼叫一個方法時,該方法並沒有顯示地丟擲異常,也沒有在javadoc中強調,我們就不會知道呼叫這段程式碼可能發生的異常情況,那些可能出現的異常情況對我們來說是透明的。
總是catch Exception物件
在捕獲和處理異常時,不管3721,一股腦地catch Exception物件。沒錯,這樣就可以一次性處理所有異常情況。但是,所有地異常都使用相同的處理程式真的對嗎?在這樣做之前應該先要打個大大的問號。這樣做的後果是我們會無形中忽略那些重要的異常。
正確使用異常
- 正確地處理異常。針對不用的異常採取合適的、正確的異常處理方式,不要遇到任何異常都
printTrace()
或者列印一個日誌。 - catch時指定具體的異常。不要一股腦地catch Exception,具體的異常應該單獨catch住,越具體的異常越早catch。
- 涉及到資源時,需要finally。如果涉及到資源的關閉時,應該將關閉資源的程式碼寫在finally程式碼塊內。
- 最小化try{ } catch{ }範圍。try的範圍應該儘量小,最好就是try住丟擲異常的那個方法即可。
異常的實現原理(位元組碼級別)
從位元組碼層面上來分析一下Java異常的實現原理,編寫如下所示的原始碼,使用javac
命令進行編譯,然後使用javap
命令檢視編譯後的位元組碼細節內容。
public class ExceptionClassCode {
public int demo() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3
}
}
}
public int demo();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1 // 生成整數1
1: istore_1 // 將生成的整數1賦予第1號區域性變數(x=1)
2: iload_1 // 將x(=1)的值入棧
3: istore_2 // 將棧頂的值(=1)賦予第2號變數(returnValue)
4: iconst_3 // 生成整數3
5: istore_1 // x=3
6: iload_2 // returnValue=當前棧頂值(=1)
7: ireturn // 返回returnValue(=1)
8: astore_2 // 將Exception物件引用值賦予第2號區域性變數
9: iconst_2 // 生成整數2
10: istore_1 // x=2
11: iload_1 // x(=2)壓入棧頂
12: istore_3 // 將棧頂的值(=2)賦予第3號變數(returnValue)
13: iconst_3 // 生成整數3
14: istore_1 // x=3
15: iload_3 // returnValue(=2)壓入棧頂
16: ireturn // 返回returnValue(=2)
17: astore 4 // 將異常資訊儲存到第4號區域性變數
19: iconst_3 // 生成整數3
20: istore_1 // x=3
21: aload 4 // 將異常引用值壓入棧
23: athrow // 丟擲棧頂所引用的異常
Exception table:
from to target type
0 4 8 Class java/lang/Exception # 如果0~4行位元組碼(try程式碼塊)中出現Exception及其子類異常,則執行第8行(catch程式碼行)
0 4 17 any # 無論0~4行位元組碼(try程式碼塊)是否丟擲異常,都執行第17行(finally程式碼行)
8 13 17 any # 無論8~13行位元組碼(catch程式碼塊)是否丟擲異常,都執行第17行(finally程式碼行)
17 19 17 any
看到位元組碼中有一個Exception table(異常表)區域,這個就是與異常相關的位元組碼內容。它表示在from
到to
所指示的位元組碼行中,如果丟擲type
所對應的異常(及其子類),那麼就跳到target
指定的位元組碼行開始執行。