流言粉碎機:JAVA 使用 try catch 會影響性能

作者:bokerr

原文:https://blog.csdn.net/bokerr/article/details/122655795

前言

不知道從何時起,傳出了這麼一句話: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;
    }
}
  1. 使用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 對的性能影響。

關於指令重排序

目前尚未可知 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 並不會影響即時編譯的結論。

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

七、結論

try catch 不會造成巨大的性能影響,換句話說,我們平時寫代碼最優先考慮的是程序的健壯性,當然大佬們肯定都知道了怎麼合理使用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 而是 搬磚業務不熟練啊。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/R3NHLYlMFhYt3wDTd920KA