編碼時我們常常被要求儘量減少try-catch語句塊,理由就是就算不拋異常它們也會影響性能。然而影響究竟有多大呢?語句塊應該放在循環體內部還是外部呢?下面譯文將詳細闡釋Java虛擬機處理異常的機制。
雖然文中沒有進行性能分析,但文末提供了一些基準測試的文章,先把結論寫在前頭:try-catch語句塊幾乎不會影響程序運行性能!在開啓JIT的情況下,throw也不會增加多少系統開銷,但是創建異常對象的代價是非常大的。
異常機制
異常機制可以讓你順利的處理程序運行過程中所遇到的許多意想不到的情況。爲了說明Java虛擬機處理異常的方式,我們來看一個名爲NitPickyMath
的類,它提供了針對整型的求模運算。和直接進行運算操作不同的是,該方法除零情況下將拋出受檢查的異常(checked exceptions)。在Java虛擬機中除零時同樣也會拋出ArithmeticException
異常。NitPickyMath
類拋出的異常定義如下:
class DivideByZeroException extends Exception {
}
NitPickyMath
類的remainder
方法簡單地捕獲並拋出了異常:
static int remainder(int dividend, int divisor)
throws DivideByZeroException {
try {
return dividend % divisor;
}
catch (ArithmeticException e) {
throw new DivideByZeroException();
}
}
remainder
方法僅僅只是將兩個int入參進行了求模運算(也使用了除法)。當除數爲0時,求模運算將拋出ArithmeticException
異常,該方法將捕獲這個異常並拋出一個自定義DivideByZeroException
異常。
DivideByZeroException
和ArithmeticException
的不同之處在於前者是受檢查異常,而後者是非受檢查異常。因此後者拋出時不需要在方法頭添加throws語句。Error
或RuntimeException
類的所有子類都是非受檢查異常(例如ArithmeticException
就是RuntimeException
的子類)。
使用javac對remainder方法進行編譯,將得到如下字節碼:
remainder方法主體的字節碼序列:
0 iload_0 // 壓入局部變量0 (傳入的除數)
1 iload_1 // 壓入局部變量0 (傳入的被除數)
2 irem // 彈出除數, 彈出被除數, 壓入餘數
3 ireturn // 返回棧頂的int值 (餘數)
catch語句的的字節碼序列 (ArithmeticException):
4 pop // 彈出ArithmeticException引用(因爲沒被用到)
5 new #5 <Class DivideByZeroException>
// 創建並壓入新對象DivideByZeroException的引用
DivideByZeroException
8 dup // 複製棧頂的DivideByZeroException引用,因爲它既要被初始化又要被拋出,初始化將消耗掉棧頂的一個引用
9 invokenonvirtual #9 <Method DivideByZeroException.<init>()V>
// 調用DivideByZeroException的構造器來初始化,棧頂引用出棧
12 athrow // 彈出Throwable對象的引用並拋出異常
可以看到remainder
的字節碼序列主要分成了兩部分,第一部分是方法正常執行的路徑,這部分對應的pc程序計數器偏移爲0到3。第二部分是catch語句,pc偏移爲4到12。
運行時,字節碼序列中的irem指令將拋出ArithmeticException
異常,虛擬機將會根據異常查表來找到可以跳轉到的catch語句位置。每個含有catch語句的方法的字節碼中都附帶了一個異常表,它包含每個異常try語句塊的條目(entry)。每個條目都有四項信息:起點、終點、跳轉的pc偏移位置以及該異常類所在常量池中的索引。remainder方法的異常表如下所示:
Exception table:
from | to | target | type |
---|---|---|---|
0 | 4 | 4 | <Class java.lang.ArithmeticException> |
上面的異常表顯示了try語句塊的起始位置爲0,結束位置爲4(不包含4),如果ArithmeticException
異常在0-3的語句塊中拋出,那麼pc計數器將直接跳轉到偏移爲4的位置。
如果在運行時拋出了一個異常,那麼java虛擬機會按順序搜索整個異常表找到匹配的條目,並且僅會匹配到在其指定範圍內的異常。當找到第一個匹配的條目後,虛擬機便將程序計數器設置爲新的偏移位置,然後繼續執行指令。如果沒有條目被匹配到,java虛擬機會彈出當前的棧幀(停止執行當前方法),並繼續向上(調用remainder方法的方法)拋出同樣的異常。當然上級方法也不會繼續正常執行的,它同樣需要查表來處理該異常,如此反覆。
開發者可以使用throw申明來拋出一個異常,就像remainder方法的catch塊中那樣。相應的字節碼描述如下:
操作碼 | 操作數 | 描述 |
---|---|---|
athrow | 無 | 彈出Throwable對象引用,並拋出該異常 |
athrow指令彈出操作數棧棧頂的引用,該引用應當爲Throwable
的子類 (或者就是 Throwable
自身)。
以下內容與譯文無關
思考
回到開頭討論的話題,你覺得下面兩段代碼性能差異有多大
A:
for (int i = 0; i < 1000000; i++) {
try {
//throw exception;
} catch (Exception e) {
}
}
B:
try {
for (int i = 0; i < 1000000; i++) {
}
} catch (Exception e) {
}
這篇博客給出了結果以及基準測試方法:try catch 對性能影響 。
我也使用JMH進行了測試,環境和細節就不列出了(注意此處是拋出同一個創建好的異常對象)。其中使用了-Xint參數控制JIT熱點編譯,結果如下:
異常拋出 | 關閉JIT | 開啓JIT(默認開啓) |
---|---|---|
A無異常拋出 | 兩者耗時幾乎相同 | 兩者耗時幾乎相同 |
A**每次**都拋異常 | A耗時約是B的30倍 | 兩者耗時幾乎相同 |
瞭解了譯文中的異常的機制後,我們知道try-catch其實不過是在class文件中加了一個異常表用於異常查表,如果沒有異常拋出,程序的執行方式和不包含try-catch塊完全相同。如果有異常拋出,那麼性能的確會下降,而這是有throw導致的,與try-catch無關。此時需要根據實際的業務來預估該方法拋出異常的頻率有多高,就算你不去管,當方法被執行次數過多時,java虛擬機也會通過JIT來編譯這段方法,編譯過後兩者的執行效率也是幾乎相同的。注意,這兒不考慮異常對象的創建時間,而實際項目中每次都需要創建Exception對象,此時需要收集堆棧信息,系統消耗很大,花費的時間也是遠超上述的時間。
所以當你遇到有人說try-catch一定要少用會影響性能時,你可能應該把重點放在異常是否有必要被創建以及被拋出的頻率。同時,我們更應該去思考如何從業務和代碼邏輯的角度來適當地使用try-catch寫出更漂亮的代碼。