不知道從何時起,傳出了這麼一句話:Java中使用try catch 會嚴重影響性能。
然而,事實真的如此麼?我們對try catch 應該畏之如猛虎麼?
一、JVM 異常處理邏輯
Java 程式中顯示抛出異常由athrow指令支援,除了通過 throw 主動抛出異常外,JVM規範中還規定了許多運作時異常會在檢測到異常狀況時自動抛出(效果等同athrow), 例如除數為0時就會自動抛出異常,以及大名鼎鼎的 NullPointerException 。
還需要注意的是,JVM 中 異常處理的catch語句不再由位元組碼指令來實作(很早之前通過 jsr和 ret指令來完成,它們在很早之前的版本裡就被舍棄了),現在的JVM通過異常表(Exception table 方法體中能找到其内容)來完成 catch 語句;很多人說try catch 影響性能可能就是因為認識還停留于上古時代。
1、 我們編寫如下的類,add方法中計算++x;并捕獲異常;
public class TestClass {
private static int len = 779;
public int add(int x){
try {
// 若運作時檢測到 x = 0,那麼 jvm會自動抛出異常,(可以了解成由jvm自己負責 athrow 指令調用)
x = 100/x;
} catch (Exception e) {
x = 100;
}
return x;
}
}
2、 使用javap工具檢視上述類的編譯後的class檔案;
編譯
javac TestClass.java
使用javap 檢視 add 方法被編譯後的機器指令
javap -verbose TestClass.class
忽略常量池等其他資訊,下邊貼出add 方法編譯後的 機器指令集:
public int add(int);
descriptor: (I)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=2
0: bipush 100 // 加載參數100
2: iload_1 // 将一個int型變量推至棧頂
3: idiv // 相除
4: istore_1 // 除的結果值壓入本地變量
5: goto 11 // 跳轉到指令:11
8: astore_2 // 将引用類型值壓入本地變量
9: bipush 100 // 将單位元組常量推送棧頂<這裡與數值100有關,可以嘗試修改100後的編譯結果:iconst、bipush、ldc>
10: istore_1 // 将int類型值壓入本地變量
11: iload_1 // int 型變量推棧頂
12: ireturn // 傳回
// 注意看 from 和 to 以及 targer,然後對照着去看上述指令
Exception table:
from to target type
0 5 8 Class java/lang/Exception
LineNumberTable:
line 6: 0
line 9: 5
line 7: 8
line 8: 9
line 10: 11
StackMapTable: number_of_entries = 2
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 2 /* same */
再來看Exception table:
from=0, to=5。 指令 0~5 對應的就是 try 語句包含的内容,而targer = 8 正好對應 catch 語句塊内部操作。
個人了解,from 和 to 相當于劃分區間,隻要在這個區間内抛出了type 所對應的,“java/lang/Exception” 異常(主動athrow 或者 由jvm運作時檢測到異常自動抛出),那麼就跳轉到target 所代表的第八行。
若執行過程中,沒有異常,直接從第5條指令跳轉到第11條指令後傳回,由此可見未發生異常時,所謂的性能損耗幾乎不存在;
如果硬是要說的話,用了try catch 編譯後指令篇幅變長了;goto 語句跳轉會耗費性能,當你寫個數百行代碼的方法的時候,編譯出來成百上千條指令,這時候這句goto的帶來的影響顯得微乎其微。
如圖所示為去掉try catch 後的指令篇幅,幾乎等同上述指令的前五條。
綜上所述:“Java中使用try catch 會嚴重影響性能” 是民間說法,它并不成立。如果不信,接着看下面的測試吧。
二、關于JVM的編譯優化
其實寫出測試用例并不是很難,這裡我們需要重點考慮的是編譯器的自動優化,是否會是以得到不同的測試結果?
本節會粗略的介紹一些jvm編譯器相關的概念,講它隻為更精确的測試結果,通過它我們可以窺探 try catch 是否會影響JVM的編譯優化。
前端編譯與優化:我們最常見的前端編譯器是 javac,它的優化更偏向于代碼結構上的優化,它主要是為了提高程式員的編碼效率,不怎麼關注執行效率優化;例如,資料流和控制流分析、解文法糖等等。
後端編譯與優化:後端編譯包括 “即時編譯[JIT]” 和 “提前編譯[AOT]”,差別于前端編譯器,它們最終作用展現于運作期,緻力于優化從位元組碼生成本地機器碼的過程(它們優化的是代碼的執行效率)。
1. 分層編譯
PS* JVM 自己根據主控端決定自己的運作模式, “JVM 運作模式”;[用戶端模式-Client、服務端模式-Server],它們代表的是兩個不同的即時編譯器,C1(Client Compiler) 和 C2 (Server Compiler)。
PS* 分層編譯分為:“解釋模式”、“編譯模式”、“混合模式”;
- 解釋模式下運作時,編譯器不介入工作;
- 編譯模式模式下運作,會使用即時編譯器優化熱點代碼,有可選的即時編譯器[C1 或 C2];
- 混合模式為:解釋模式和編譯模式搭配使用。
如圖,我的環境裡JVM 運作于 Server 模式,如果使用即時編譯,那麼就是使用的:C2 即時編譯器。
2. 即時編譯器
了解如下的幾個 概念:
1. 解釋模式
它不使用即時編譯器進行後端優化
- 強制虛拟機運作于 “解釋模式” -Xint
- 禁用背景編譯 -XX:-BackgroundCompilation
2. 編譯模式
即時編譯器會在運作時,對生成的本地機器碼進行優化,其中重點關照熱點代碼。
強制虛拟機運作于 "編譯模式"
-Xcomp
方法調用次數計數器門檻值,它是基于計數器熱點代碼探測依據[Client模式=1500,Server模式=10000]
-XX:CompileThreshold=10
關閉方法調用次數熱度衰減,使用方法調用計數的絕對值,它搭配上一配置項使用
-XX:-UseCounterDecay
除了熱點方法,還有熱點回邊代碼[循環],熱點回邊代碼的門檻值計算參考如下:
-XX:BackEdgeThreshold = 方法計數器門檻值[-XX:CompileThreshold] * OSR比率[-XX:OnStackReplacePercentage]
OSR比率預設值:Client模式=933,Server模式=140
-XX:OnStackReplacePercentag=100
所謂 “即時”,它是在運作過程中發生的,是以它的缺點也也明顯:在運作期間需要耗費資源去做性能分析,也不太适合在運作期間去大刀闊斧的去做一些耗費資源的重負載優化操作。
3. 提前編譯器:jaotc
它是後端編譯的另一個主角,它有兩個發展路線,基于Graal [新時代的主角] 編譯器開發,因為本文用的是 C2 編譯器,是以隻對它做一個了解;
第一條路線:與傳統的C、C++編譯做的事情類似,在程式運作之前就把程式代碼編譯成機器碼;好處是夠快,不占用運作時系統資源,缺點是"啟動過程" 會很緩慢;
第二條路線:已知即時編譯運作時做性能統計分析占用資源,那麼,我們可以把其中一些耗費資源的編譯工作,放到提前編譯階段來完成啊,最後在運作時即時編譯器再去使用,那麼可以大大節省即時編譯的開銷;這個分支可以把它看作是即時編譯緩存;
遺憾的是它隻支援 G1 或者 Parallel 垃圾收集器,且隻存在JDK 9 以後的版本,暫不需要去關注它;JDK 9 以後的版本可以使用這個參數列印相關資訊:[-XX:PrintAOT]。
三、關于測試的限制
執行用時統計
System.naoTime() 輸出的是過了多少時間[微秒:10的負9次方秒],并不是完全精确的方法執行用時的合計,為了保證結果準确性,測試的運算次數将拉長到百萬甚至千萬次。
編譯器優化的因素
上一節花了一定的篇幅介紹編譯器優化,這裡我要做的是:對比完全不使用任何編譯優化,與使用即時編譯時,try catch 對的性能影響。
1、 通過指令禁用JVM的編譯優化,讓它以最原始的狀态運作,然後看有無trycatch的影響;
2、 通過指令使用即時編譯,盡量做到把後端優化拉滿,看看trycatch十有會影響到jvm的編譯優化;
關于指令重排序
目前尚未可知 try catch 的使用影響指令重排序;
我們這裡的讨論有一個前提,當 try catch 的使用無法避免時,我們應該如何使用 try catch 以應對它可能存在的對指令重排序的影響。
指令重排序發生在多線程并發場景,這麼做是為了更好的利用CPU資源,在單線程測試時不需要考慮。不論如何指令重排序,都會保證最終執行結果,與單線程下的執行結果相同;
雖然我們不去測試它,但是也可以進行一些推斷,參考 volatile 關鍵字禁止指令重排序的做法:插入記憶體屏障;
假定 try catch 存在屏障,導緻前後的代碼分割;那麼最少的try catch代表最少的分割。
是以,是不是會有這樣的結論呢:我們把方法體内的 多個 try catch 合并為一個 try catch 是不是反而能減少屏障呢?這麼做勢必造成 try catch 的範圍變大。
當然,上述關于指令重排序讨論内容都是基于個人的猜想,猶未可知 try catch 是否影響指令重排序;本文重點讨論的也隻是單線程環境下的 try catch 使用影響性能。
四、測試代碼
- 循環次數為100W ,循環内10次預算[給編譯器優化預留優化的可能,這些指令可能被合并];
- 每個方法都會到達千萬次浮點計算。
- 同樣每個方法外層再循環跑多次,最後取其中的衆數更有說服力。
public class ExecuteTryCatch {
// 100W
private static final int TIMES = 1000000;
private static final float STEP_NUM = 1f;
private static final float START_NUM = Float.MIN_VALUE;
public static void main(String[] args){
int times = 50;
ExecuteTryCatch executeTryCatch = new ExecuteTryCatch();
// 每個方法執行 50 次
while (--times >= 0){
System.out.println("times=".concat(String.valueOf(times)));
executeTryCatch.executeMillionsEveryTryWithFinally();
executeTryCatch.executeMillionsEveryTry();
executeTryCatch.executeMillionsOneTry();
executeTryCatch.executeMillionsNoneTry();
executeTryCatch.executeMillionsTestReOrder();
}
}
/**
* 千萬次浮點運算不使用 try catch
* */
public void executeMillionsNoneTry(){
float num = START_NUM;
long start = System.nanoTime();
for (int i = 0; i < TIMES; ++i){
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
}
long nao = System.nanoTime() - start;
long million = nao / 1000000;
System.out.println("noneTry sum:" + num + " million:" + million + " nao: " + nao);
}
/**
* 千萬次浮點運算最外層使用 try catch
* */
public void executeMillionsOneTry(){
float num = START_NUM;
long start = System.nanoTime();
try {
for (int i = 0; i < TIMES; ++i){
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
}
} catch (Exception e){
}
long nao = System.nanoTime() - start;
long million = nao / 1000000;
System.out.println("oneTry sum:" + num + " million:" + million + " nao: " + nao);
}
/**
* 千萬次浮點運算循環内使用 try catch
* */
public void executeMillionsEveryTry(){
float num = START_NUM;
long start = System.nanoTime();
for (int i = 0; i < TIMES; ++i){
try {
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
} catch (Exception e) {
}
}
long nao = System.nanoTime() - start;
long million = nao / 1000000;
System.out.println("evertTry sum:" + num + " million:" + million + " nao: " + nao);
}
/**
* 千萬次浮點運算循環内使用 try catch,并使用 finally
* */
public void executeMillionsEveryTryWithFinally(){
float num = START_NUM;
long start = System.nanoTime();
for (int i = 0; i < TIMES; ++i){
try {
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
} catch (Exception e) {
} finally {
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
}
}
long nao = System.nanoTime() - start;
long million = nao / 1000000;
System.out.println("finalTry sum:" + num + " million:" + million + " nao: " + nao);
}
/**
* 千萬次浮點運算,循環内使用多個 try catch
* */
public void executeMillionsTestReOrder(){
float num = START_NUM;
long start = System.nanoTime();
for (int i = 0; i < TIMES; ++i){
try {
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
} catch (Exception e) { }
try {
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
} catch (Exception e){}
try {
num = num + STEP_NUM + 1f;
num = num + STEP_NUM + 2f;
} catch (Exception e) { }
try {
num = num + STEP_NUM + 3f;
num = num + STEP_NUM + 4f;
num = num + STEP_NUM + 5f;
} catch (Exception e) {}
}
long nao = System.nanoTime() - start;
long million = nao / 1000000;
System.out.println("orderTry sum:" + num + " million:" + million + " nao: " + nao);
}
}
五、解釋模式下執行測試
設定如下JVM參數,禁用編譯優化
-Xint
-XX:-BackgroundCompilation
結合測試代碼發現,即使百萬次循環計算,每個循環内都使用了 try catch 也并沒用對造成很大的影響。
唯一發現了一個問題,每個循環内都是使用 try catch 且使用多次。發現性能下降,千萬次計算內插補點為:5~7 毫秒;4個 try 那麼執行的指令最少4條goto ,前邊闡述過,這裡造成這個差異的主要原因是 goto 指令占比過大,放大了問題;當我們在幾百行代碼裡使用少量try catch 時,goto所占比重就會很低,測試結果會更趨于合理。
六、編譯模式測試
設定如下測試參數,執行10 次即為熱點代碼
-Xcomp
-XX:CompileThreshold=10
-XX:-UseCounterDecay
-XX:OnStackReplacePercentage=100
-XX:InterpreterProfilePercentage=33
執行結果如下圖,難分勝負,波動隻在微秒級别,執行速度也快了很多,編譯效果拔群啊,甚至連 “解釋模式” 運作時多個try catch 導緻的,多個goto跳轉帶來的問題都給順帶優化了;由此也可以得到 try catch 并不會影響即時編譯的結論。
我們可以再上升到億級計算,依舊難分勝負,波動在毫秒級。
七、結論
trycatch 不會造成巨大的性能影響,換句話說,我們平時寫代碼最優先考慮的是程式的健壯性,當然大佬們肯定都知道了怎麼合理使用try catch了,但是對萌新來說,你如果不确定,那麼你可以使用 try catch;
在未發生異常時,給代碼外部包上 try catch,并不會造成影響。
舉個栗子吧,我的代碼中使用了:URLDecoder.decode,是以必須得捕獲異常。
private int getThenAddNoJudge(JSONObject json, String key){
if (Objects.isNull(json))
throw new IllegalArgumentException("參數異常");
int num;
try {
// 不校驗 key 是否未空值,直接調用 toString 每次觸發空指針異常并被捕獲
num = 100 + Integer.parseInt(URLDecoder.decode(json.get(key).toString(), "UTF-8"));
} catch (Exception e){
num = 100;
}
return num;
}
private int getThenAddWithJudge(JSONObject json, String key){
if (Objects.isNull(json))
throw new IllegalArgumentException("參數異常");
int num;
try {
// 校驗 key 是否未空值
num = 100 + Integer.parseInt(URLDecoder.decode(Objects.toString(json.get(key), "0"), "UTF-8"));
} catch (Exception e){
num = 100;
}
return num;
}
public static void main(String[] args){
int times = 1000000;// 百萬次
long nao1 = System.nanoTime();
ExecuteTryCatch executeTryCatch = new ExecuteTryCatch();
for (int i = 0; i < times; i++){
executeTryCatch.getThenAddWithJudge(new JSONObject(), "anyKey");
}
long end1 = System.nanoTime();
System.out.println("未抛出異常耗時: millions=" + (end1 - nao1) / 1000000 + "毫秒 nao=" + (end1 - nao1) + "微秒");
long nao2 = System.nanoTime();
for (int i = 0; i < times; i++){
executeTryCatch.getThenAddNoJudge(new JSONObject(), "anyKey");
}
long end2 = System.nanoTime();
System.out.println("每次必抛出異常: millions=" + (end2 - nao2) / 1000000 + "毫秒 nao=" + (end2 - nao2) + "微秒");
}
調用方法百萬次,執行結果如下:
經過這個例子,我想你知道你該如何 編寫你的代碼了吧?可怕的不是 try catch 而是 搬磚業務不熟練啊。
最後,如果我的文章對你有所幫助或者有所啟發,歡迎關注公衆号(微信搜尋公衆号:首席架構師專欄),裡面有許多技術幹貨,也有我對技術的思考和感悟,還有作為架構師的驗驗分享;關注後回複 【面試題】,有我準備的面試題、架構師大型項目實戰視訊等福利 , 小編會帶着你一起學習、成長,讓我們一起加油!!!