當前位置: 妍妍網 > 碼農

Java 使用 try catch 嚴重影響效能?別再被騙了!

2024-05-09碼農

大家好,我是一航!

不知道從何時起,傳出了這麽一句話: Java中使用try catch 會嚴重影響效能 。然而,事實真的如此麽?我們對try catch 應該畏之如猛虎麽?

、JVM 例外處理邏輯

Java 程式中顯式丟擲異常由athrow指令支持,除了透過 throw 主動丟擲異常外,JVM規範中還規定了許多執行時異常會在檢測到異常狀況時自動丟擲(效果等同athrow), 例如除數為0時就會自動丟擲異常,以及大名鼎鼎的 NullPointerException 。

還需要註意的是,JVM 中 例外處理的catch語句不再由字節碼指令來實作(很早之前透過 jsr和 ret指令來完成,它們在很早之前的版本裏就被舍棄了),現在的JVM透過異常表(Exception table 方法體中能找到其內容)來完成 catch 語句;很多人說try catch 影響效能可能就是因為認識還停留於上古時代。

我們編寫如下的類,add 方法中計算 ++x; 並捕獲異常。

public classTest class{
privatestaticint len = 779;
publicintadd(int x){
try {
// 若執行時檢測到 x = 0,那麽 jvm會自動丟擲異常,(可以理解成由jvm自己負責 athrow 指令呼叫)
x = 100/x;
catch (Exception e) {
x = 100;
}
return x;
}
}

使用javap 工具檢視上述類的編譯後的 class檔

 # 編譯
 javac Test class.java
 # 使用javap 檢視 add 方法被編譯後的機器指令
 javap -verbose Test class. class

忽略常量池等其他資訊,下邊貼出add 方法編譯後的 機器指令集:

publicintadd(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
058 class java/lang/Exception
LineNumberTable:
line 60
line 95
line 78
line 89
line 1011
StackMapTable: number_of_entries = 2
frame_type = 72/* same_locals_1_stack_item */
stack = [  classjava/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. 即時編譯器

    了解如下的幾個 概念:

  • 解釋模式

    它不使用即時編譯器進行後端最佳化

    強制虛擬機器執行於 「解釋模式」 -Xint

    禁用後台編譯 -XX:-BackgroundCompilation

  • 編譯模式

    即時編譯器會在執行時,對生成的本地機器碼進行最佳化,其中重點關照熱點程式碼。

    # 強制虛擬機器執行於 "編譯模式"
    -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

    所謂 「即時」,它是在執行過程中發生的,所以它的缺點也也明顯:在執行期間需要耗費資源去做效能分析,也不太適合在執行期間去大刀闊斧的去做一些耗費資源的重負載最佳化操作。

  • 提前編譯器:jaotc

    它是後端編譯的另一個主角,它有兩個發展路線,基於Graal [新時代的主角] 編譯器開發,因為本文用的是 C2 編譯器,所以只對它做一個了解;

    遺憾的是它只支持 G1 或者 Parallel 垃圾收集器,且只存在JDK 9 以後的版本,暫不需要去關註它;JDK 9 以後的版本可以使用這個參數打印相關資訊:[-XX:PrintAOT]。

  • 第一條路線:與傳統的C、C++編譯做的事情類似,在程式執行之前就把程式程式碼編譯成機器碼;好處是夠快,不占用執行時系統資源,缺點是"啟動過程" 會很緩慢;

  • 第二條路線:已知即時編譯執行時做效能統計分析占用資源,那麽,我們可以把其中一些耗費資源的編譯工作,放到提前編譯階段來完成啊,最後在執行時即時編譯器再去使用,那麽可以大大節省即時編譯的開銷;這個分支可以把它看作是即時編譯緩存;

  • 、關於測試的約束

    執行用時統計

    System.naoTime() 輸出的是過了多少時間[微秒:10的負9次方秒],並不是完全精確的方法執行用時的合計,為了保證結果準確性,測試的運算次數將拉長到百萬甚至千萬次。

    編譯器最佳化的因素

    上一節花了一定的篇幅介紹編譯器最佳化,這裏我要做的是:對比完全不使用任何編譯最佳化,與使用即時編譯時,try catch 對的效能影響。

    透過指令禁用 JVM 的編譯最佳化,讓它以最原始的狀態執行,然後看有無 try catch 的影響。

    透過指令使用即時編譯,盡量做到把後端最佳化拉滿,看看 try catch 十有會影響到 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 classExecuteTryCatch{
    // 100W 
    privatestaticfinalint TIMES = 1000000;
    privatestaticfinalfloat STEP_NUM = 1f;
    privatestaticfinalfloat START_NUM = Float.MIN_VALUE;

    publicstaticvoidmain(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
    * */

    publicvoidexecuteMillionsNoneTry(){
    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
    * */

    publicvoidexecuteMillionsOneTry(){
    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
    * */

    publicvoidexecuteMillionsEveryTry(){
    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
    * */

    publicvoidexecuteMillionsEveryTryWithFinally(){
    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
    * */

    publicvoidexecuteMillionsTestReOrder(){
    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 並不會影響即時編譯的結論。

    我們可以再上升到億級計算,依舊難分勝負,波動在毫秒級。

    、結論

    try catch 不會造成巨大的效能影響,換句話說,我們平時寫程式碼最優先考慮的是程式的健壯性,當然大佬們肯定都知道了怎麽合理使用try catch了,但是對萌新來說,你如果不確定,那麽你可以使用 try catch;

    在未發生異常時,給程式碼外部包上 try catch,並不會造成影響。

    舉個栗子吧,我的程式碼中使用了:URLDecoder.decode,所以必須得捕獲異常。

    privateintgetThenAddNoJudge(JSONObject json, String key){
    if (Objects.isNull(json))
    thrownew 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;
    }
    privateintgetThenAddWithJudge(JSONObject json, String key){
    if (Objects.isNull(json))
    thrownew 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;
    }
    publicstaticvoidmain(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 而是 搬磚業務不熟練啊。

    來源:cnblogs.com/bokers/p/15835629.html

    >>

    END

    精品資料,超贊福利,免費領

    微信掃碼/長按辨識 添加【技術交流群

    群內每天分享精品學習資料

    最近開發整理了一個用於速刷面試題的小程式;其中收錄了上千道常見面試題及答案(包含基礎並行JVMMySQLRedisSpringSpringMVCSpringBootSpringCloud訊息佇列等多個型別),歡迎您的使用。

    👇👇

    👇點選"閱讀原文",獲取更多資料(持續更新中