try catch 對性能影響

引言

之前一直沒有去研究try catch的內部機制,只是一直停留在了感覺上,正好這週五開會交流學習的時候,有人提出了相關的問題。藉着週末,正好研究一番。

討論的問題

當時討論的是這樣的問題:
比較下面兩種try catch寫法,哪一種性能更好。

        for (int i = 0; i < 1000000; i++) {
            try {
                Math.sin(j);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        try {
            for (int i = 0; i < 1000000; i++) {
                Math.sin(j);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

結論

在沒有發生異常時,兩者性能上沒有差異。如果發生異常,兩者的處理邏輯不一樣,已經不具有比較的意義了。


分析

要知道這兩者的區別,最好的辦法就是查看編譯後生成的Java字節碼。看一下try catch到底做了什麼。
下面是我的測試代碼

package com.kevin.java.performancetTest;

import org.openjdk.jmh.annotations.Benchmark;

/**
 * Created by kevin on 16-7-10.
 */
public class ForTryAndTryFor {

    public static void main(String[] args) {
        tryFor();
        forTry();
    }

    public static void tryFor() {
        int j = 3;
        try {
            for (int i = 0; i < 1000; i++) {
                Math.sin(j);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void forTry() {
        int j = 3;
        for (int i = 0; i < 1000; i++) {
            try {
                Math.sin(j);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

使用javap -c fileName.class輸出對應的字節碼

$ javap -c ForTryAndTryFor.class
Compiled from "ForTryAndTryFor.java"
public class com.kevin.java.performancetTest.ForTryAndTryFor {
  public com.kevin.java.performancetTest.ForTryAndTryFor();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method tryFor:()V
       3: invokestatic  #3                  // Method forTry:()V
       6: return

  public static void tryFor();
    Code:
       0: iconst_3
       1: istore_0
       2: iconst_0
       3: istore_1
       4: iload_1
       5: sipush        1000
       8: if_icmpge     23
      11: iload_0
      12: i2d
      13: invokestatic  #4                  // Method java/lang/Math.sin:(D)D
      16: pop2
      17: iinc          1, 1
      20: goto          4
      23: goto          31
      26: astore_1
      27: aload_1
      28: invokevirtual #6                  // Method java/lang/Exception.printStackTrace:()V
      31: return
    Exception table:
       from    to  target type
           2    23    26   Class java/lang/Exception

  public static void forTry();
    Code:
       0: iconst_3
       1: istore_0
       2: iconst_0
       3: istore_1
       4: iload_1
       5: sipush        1000
       8: if_icmpge     31
      11: iload_0
      12: i2d
      13: invokestatic  #4                  // Method java/lang/Math.sin:(D)D
      16: pop2
      17: goto          25
      20: astore_2
      21: aload_2
      22: invokevirtual #6                  // Method java/lang/Exception.printStackTrace:()V
      25: iinc          1, 1
      28: goto          4
      31: return
    Exception table:
       from    to  target type
          11    17    20   Class java/lang/Exception
}

指令含義不是本文的重點,所以這裏就不介紹具體的含義,感興趣可以到Oracle官網查看相應指令的含義The Java Virtual Machine Instruction Set

好了讓我們來關注一下try catch 到底做了什麼。我們就拿forTry方法來說吧,從輸出看,字節碼分兩部分,code(指令)和exception table(異常表)兩部分。當將java源碼編譯成相應的字節碼的時候,如果方法內有try catch異常處理,就會產生與該方法相關聯的異常表,也就是Exception table:部分。異常表記錄的是try 起點和終點,catch方法體所在的位置,以及聲明捕獲的異常種類。通過這些信息,當程序出現異常時,java虛擬機就會查找方法對應的異常表,如果發現有聲明的異常與拋出的異常類型匹配就會跳轉到catch處執行相應的邏輯,如果沒有匹配成功,就會回到上層調用方法中繼續查找,如此反覆,一直到異常被處理爲止,或者停止進程。(具體介紹可以看這篇文章How the Java virtual machine handles exceptions。)所以,try 在反映到字節碼上的就是產生一張異常表,只有發生異常時纔會被使用。由此得到出開始的結論。

這裏再對結論擴充:
try catch與未使用try catch代碼區別在於,前者阻止Java對try塊的代碼的一些優化,例如重排序。try catch裏面的代碼是不會被編譯器優化重排的。對於上面兩個函數而言,只是異常表中try起點和終點位置不一樣。至於剛剛說到的指令重排的問題,由於for循環條件部分符合happens- before原則,因此兩者的for循環都不會發生重排。當然只是針對這裏而言,在實際編程中,還是提倡try代碼塊的範圍儘量小,這樣纔可以充分發揮Java對代碼的優化能力。

測試驗證

既然通過字節碼已經分析出來了,兩者性能沒有差異。那我們就來檢測一下吧,看看到底是不是如前面分析的那樣。
在正式開始測試時,首先我們要明白,一個正確的測試方法,就是保證我們的測試能產生不被其他因素所歪曲污染的有效結果。那應該使用什麼方法來測試我們的代碼呢?

不正確的測試

這裏首先說一下一種常見的錯誤的測量方法,測量一個方法的執行時間,最容易想到的應該是下面這種了:

long startTime = System.currentTimeMillis();

doReallyLongThing();

long endTime = System.currentTimeMillis();

System.out.println("That took " + (endTime - startTime) + " milliseconds");

但是我會跟你說,這個方式十分的不準確,我這裏給大家展示一下我的使用上面的方式來進行測試的結果

package com.kevin.java.performancetTest;

/**
 * Created by kevin on 16-7-10.
 */
public class ForTryAndTryFor {

    public static void main(String[] args) {
        forTry();
        tryFor();
    }

    public static void tryFor() {

        long startTime = System.currentTimeMillis();

        int j = 3;
        try {
            for (int i = 0; i < 1000000; i++) {
                Math.sin(j);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        long endTime = System.currentTimeMillis();

        System.out.println("tryFor " + (endTime - startTime) + " milliseconds");
    }

    public static void forTry() {

        long startTime = System.currentTimeMillis();


        int j = 3;
        for (int i = 0; i < 1000000; i++) {
            try {
                Math.sin(j);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        long endTime = System.currentTimeMillis();

        System.out.println("forTry " + (endTime - startTime) + " milliseconds");

    }
}

測試結果
測試結果

看到這個你是不是會認爲tryFor比forTry快了呢?當然一般而言,不會這麼快就下定論,所以接着你就會繼續運行數次,然後可能還是會看見類似上面的結果。很有可能你就會確信forTry比tryFor快。但是這個測試的結果是不準確的,確切的說是無效的。

爲什麼呢?如果你測試的次數足夠多(其實也不用很多,我這裏就運行了十幾次這樣),你就會發現問題。我再列出這十幾次測試中比較有代表性的測試結果的截圖。

圖1
圖1

圖2
這裏寫圖片描述

圖3
這裏寫圖片描述

從上面結果看來,絕大多數時候,tryFor比forTry快。那是不是可以說tryFor比forTry快了呢?如果沒有前面分析,如果你只是測試了幾次,並且結果都類似的時候,你是不是會就因此下定論了呢?

上面問題就出現在這個絕大多數,當你運行的次數越多,就越發的體會到結果的撲朔迷離。

至少有下面兩點給人撲朔迷離的感覺

  1. 每次的執行時間都相差很大。
    同一個函數會出現,兩次執行結果可能相差好幾倍的情況。比如圖1中的forTry竟然比圖2的forTry快了近6倍。
  2. 偶爾forTry會比tryFor快(我上面的截取的是比較有代表性的結果,實際運行的時候絕大多數情況顯示的是tryFor快)

那是什麼導致了結果如此的撲朔迷離?原因至少有下面這些

  1. System.currentTimeMillis()測量的只是逝去的時間,並沒有反映出cpu執行該函數真正消耗的時間。
    這導致線程未被分配cpu資源時,等待cpu的時間也會被計算進去
  2. JIT優化導致結果出現偏差。
    像這種多次循環非常容易觸發JIT的優化機制,關於JIT,這裏簡短的介紹一下

    在Java編程語言和環境中,即時編譯器(JIT compiler,just-in-time compiler)是一個把Java的字節碼(包括需要被解釋的指令的程序)轉換成可以直接發送給處理器的指令的程序。當你寫好一個Java程序後,源語言的語句將由Java編譯器編譯成字節碼,而不是編譯成與某個特定的處理器硬件平臺對應的指令代碼(比如,Intel的Pentium微處理器或IBM的System/390處理器)。字節碼是可以發送給任何平臺並且能在那個平臺上運行的獨立於平臺的代碼。

    JIT 編譯器概述
    Just-In-Time (JIT) 編譯器是 Java™ Runtime Environment 的一個組件,用於提高運行時的 Java
    應用程序的性能。

    Java 程序由多個類組成,它包含可在許多不同計算機體系結構上由 JVM 解釋的與平臺無關的字節碼。在運行時,JVM 裝入類文件,確定每個單獨的字節碼的語義,並執行相應的計算。解釋期間額外使用處理器和內存意味着 Java應用程序的執行速度要慢於本機應用程序。JIT 編譯器通過在運行時將字節碼編譯爲本機代碼以幫助提高 Java 程序的性能。

    JIT 編譯器在缺省情況下爲已啓用,並在調用 Java 方法時被激活。JIT 編譯器將該方法的字節碼編譯爲本機機器碼,“即時”編譯該代碼以便運行。在編譯方法時,JVM 直接調用該方法的已編譯代碼,而不是對代碼進行解釋。理論上,如果編譯不需要佔用處理器時間和內存,那麼編譯每個方法都可能使 Java程序速度接近於本機應用程序的速度。

    JIT 編譯需要佔用處理器時間和內存。在 JVM首次啓動時,將調用數千種方法。即使程序最終實現了較高的峯值性能,編譯所有這些方法也會對啓動時間產生顯著影響。

    實際上,第一次調用方法時不會對方法進行編譯。 對於每個方法,JVM 都會保留一個調用計數,每次調用方法時該計數都將遞增。JVM對方法進行解釋,直至其調用計數超過 JIT 編譯閾值。因此,在 JVM啓動後將立即編譯常用方法,而在較長時間之後(或者根本不編譯)不常使用的方法。JIT 編譯閾值幫助 JVM 快速啓動並且還可提高性能。 謹慎選擇閾值,以在啓動時與長期性能之間實現最佳平衡。

    在編譯方法後,調用計數將重置爲 0,並且對該方法的後續調用將繼續使其計數遞增。在方法的調用計數到達 JIT 重新編譯閾值時,JIT 編譯器將執行第二次編譯,與前一次編譯相比,其優化選擇更多。此過程將重複,直至到達最大優化級別。Java程序的最忙碌方法始終是最積極地進行優化,從而實現使用 JIT 編譯器的性能優勢最大化。JIT編譯器還可在運行時度量運作數據,並使用該數據來提高進一步重新編譯的質量。

    可禁用 JIT 編譯器,在這種情況下,將解釋整個 Java 程序。除診斷或解決 JIT 編譯問題外,不推薦禁用 JIT 編譯器。

    簡單來說,JIT會將某些符合條件(比如,頻繁的循環)的字節碼被編譯成目標的機器指令直接執行,從而加快執行速度。可以通過配置-XX:+PrintCompilation參數,在控制檯觀察JIT做了哪些優化。當JIT執行優化時,會在終端輸出相應的優化信息。

    我們代碼的JIT輸出的信息,可以看到我們測試的兩個函數已經被JIT編譯優化了。

     67   46     n 0       sun.misc.Unsafe::getObjectVolatile (native)   
     67   45       3       java.util.concurrent.ConcurrentHashMap::tabAt (21 bytes)
     67   47       3       java.lang.AbstractStringBuilder::<init> (12 bytes)
     68   49       3       java.lang.AbstractStringBuilder::expandCapacity (50 bytes)
     68   48       3       java.lang.ref.SoftReference::get (29 bytes)
     78   50 % !   3       com.kevin.java.performancetTest.ForTryAndTryFor::forTry @ 8 (73 bytes)
     78   51   !   3       com.kevin.java.performancetTest.ForTryAndTryFor::forTry (73 bytes)
     79   52 % !   4       com.kevin.java.performancetTest.ForTryAndTryFor::forTry @ 8 (73 bytes)
     79   50 % !   3       com.kevin.java.performancetTest.ForTryAndTryFor::forTry @ -2 (73 bytes)   made not entrant
    131   52 % !   4       com.kevin.java.performancetTest.ForTryAndTryFor::forTry @ -2 (73 bytes)   made not entrant
    forTry 63 milliseconds
    141   53 % !   3       com.kevin.java.performancetTest.ForTryAndTryFor::tryFor @ 8 (71 bytes)
    142   54   !   3       com.kevin.java.performancetTest.ForTryAndTryFor::tryFor (71 bytes)
    142   55 % !   4       com.kevin.java.performancetTest.ForTryAndTryFor::tryFor @ 8 (71 bytes)
    143   53 % !   3       com.kevin.java.performancetTest.ForTryAndTryFor::tryFor @ -2 (71 bytes)   made not entrant
    192   55 % !   4       com.kevin.java.performancetTest.ForTryAndTryFor::tryFor @ -2 (71 bytes)   made not entrant
    tryFor 61 milliseconds
    
  3. 類加載時間也被統計進來了。
    類首次被使用時,會觸發類加載,產生了時間消耗。

從上面分析的原因不難看出,爲什麼絕大多數時候tryFor會比forTry快了。JIT編譯耗時和類加載時間會被統計到第一個執行的函數forTry裏面。這就直接導致了第一個執行的函數(forTry)要比第二個函數(tryFor)執行的時間要長。最爲重要的使用System.currentTimeMillis()測量的是(等待cpu+真正被執行的時間),這就導致出現圖1完全與絕大多數測試結果完全相反的情況。

那有什麼可以讓我們穿過這層層迷霧,直抵真相呢?

穿透迷霧,直抵真相!

  1. 不要使用System.currentTimeMillis()亦或者使用System.nanoTime()
    這裏說明一下,可能你會看到有些建議使用System.nanoTime()來測試,但是它跟System.currentTimeMillis()區別,僅僅在於時間的基準不同和精度不同,但都表示的是逝去的時間,所以對於測試執行時間上,並沒有什麼區別。因爲都無法統計CPU真正執行時間。
    要測試cpu真正執行時間,這裏推薦使用JProfiler性能測試工具,它可以測量出cpu真正的執行時間。具體安裝使用方法可以自行google百度。因爲這不是本文最終使用的測試方法,所以就不做詳細介紹了。但是你使用它來測試上面的代碼,至少可以排除等待CPU消耗的時間
  2. 對於後兩者,需要加入Warmup(預熱)階段。
    預熱階段就是不斷運行你的測試代碼,從而使得代碼完成初始化工作(類加載),並足以觸發JIT編譯機制。一般來說,循環幾萬次就可以預熱完畢。

那是不是做到以上兩點就可以了直抵真相了?非常不幸,並沒有那麼簡單,JIT機制和JVM並沒有想象的這麼簡單,要做到以下這些點你才能得到比較真實的結果。下面摘錄至how-do-i-write-a-correct-micro-benchmark-in-java排名第一的答案

Tips about writing micro benchmarks from the creators of Java
HotSpot
:

Rule 0: Read a reputable paper on JVMs and micro-benchmarking. A good one is Brian Goetz, 2005. Do not expect too much from
micro-benchmarks; they measure only a limited range of JVM performance
characteristics.

Rule 1: Always include a warmup phase which runs your test kernel all the way through, enough to trigger all initializations and
compilations before timing phase(s). (Fewer iterations is OK on the
warmup phase. The rule of thumb is several tens of thousands of inner
loop iterations.)

Rule 2: Always run with -XX:+PrintCompilation, -verbose:gc, etc., so you can verify that the compiler and other parts of the JVM
are not doing unexpected work during your timing phase.

Rule 2.1: Print messages at the beginning and end of timing and warmup phases, so you can verify that there is no output from Rule 2
during the timing phase.

Rule 3: Be aware of the difference between -client and -server, and OSR and regular compilations. The -XX:+PrintCompilation flag
reports OSR compilations with an at-sign to denote the non-initial
entry point, for example: Trouble$1::run @ 2 (41 bytes). Prefer
server to client, and regular to OSR, if you are after best
performance.

Rule 4: Be aware of initialization effects. Do not print for the first time during your timing phase, since printing loads and
initializes classes. Do not load new classes outside of the warmup
phase (or final reporting phase), unless you are testing class loading
specifically (and in that case load only the test classes). Rule 2 is
your first line of defense against such effects.

Rule 5: Be aware of deoptimization and recompilation effects. Do not take any code path for the first time in the timing phase, because
the compiler may junk and recompile the code, based on an earlier
optimistic assumption that the path was not going to be used at all.
Rule 2 is your first line of defense against such effects.

Rule 6: Use appropriate tools to read the compiler’s mind, and expect to be surprised by the code it produces. Inspect the code
yourself before forming theories about what makes something faster or
slower.

Rule 7: Reduce noise in your measurements. Run your benchmark on a quiet machine, and run it several times, discarding outliers. Use
-Xbatch to serialize the compiler with the application, and consider
setting -XX:CICompilerCount=1 to prevent the compiler from running
in parallel with itself.

Rule 8: Use a library for your benchmark as it is probably more efficient and was already debugged for this sole purpose. Such as
JMH, Caliper or Bill and Paul’s Excellent UCSD Benchmarks
for Java
.

還可以參考Java theory and practice: Anatomy of a flawed microbenchmark
認真看完這些,你就會發現,要保證microbenchmark結果的可靠,真不是一般的難!!!

那就沒有簡單可靠的測試方法了嗎?如果你認真看完上面提到的點,你應該會注意到Rule 8,沒錯,我就是使用Rule8提到的JMH來。這裏摘錄一段網上的介紹

JMH是新的microbenchmark(微基準測試)框架(2013年首次發佈)。與其他衆多框架相比它的特色優勢在於,它是由Oracle實現JIT的相同人員開發的。結果可信度很高。

JMH官方主頁:http://openjdk.java.net/projects/code-tools/jmh/

正確的測試

測試環境:

JVM版本:
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

系統:
Linux Mint 17.3 Rosa 64bit

配置:i7-4710hq+16g

工具:Intellij IDEA 2016+JMH的jar包+JMH intellij plugin

插件具體使用可以看JMH插件Github項目地址,上面有介紹使用細節

測試代碼:

package com.kevin.java.performancetTest;

import org.openjdk.jmh.annotations.Benchmark;

/**
 * Created by kevin on 16-7-10.
 */
public class ForTryAndTryFor {

    public static void main(String[] args) {
        tryFor();
        forTry();
    }

    @Benchmark
    public static void tryFor() {
        int j = 3;
        try {
            for (int i = 0; i < 1000; i++) {
                Math.sin(j);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Benchmark
    public static void forTry() {
        int j = 3;
        for (int i = 0; i < 1000; i++) {
            try {
                Math.sin(j);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

測試結果

JMH會做執行一段時間的WarmUp,之後纔開始進行測試。這裏只是截取結果部分,運行過程輸出就不放出來了

# Run complete. Total time: 00:02:41 

Benchmark                                 Mode  Cnt   Score   Error   Units
performancetTest.ForTryAndTryFor.forTry  thrpt   40  26.122 ± 0.035  ops/ms
performancetTest.ForTryAndTryFor.tryFor  thrpt   40  25.535 ± 0.087  ops/ms
# Run complete. Total time: 00:02:41

Benchmark                                  Mode     Cnt  Score    Error  Units
performancetTest.ForTryAndTryFor.forTry  sample  514957  0.039 ±  0.001  ms/op
performancetTest.ForTryAndTryFor.tryFor  sample  521559  0.038 ±  0.001  ms/op

每個函數都測試了兩編,總時長都是2分41秒
主要關注Score和Error兩列,±表示偏差。
第一個結果的意思是,每毫秒調用了 26.122 ± 0.035次forTry函數,每毫秒調用了 25.535 ± 0.087次tryFor函數,第二個結果表示的是調用一次函數的時間。

從結果中,可以看到兩個函數性能並沒有差異,與之前的分析吻合。

最終總結

本文由Try catch與for循環的位置關係開始討論,通過分析得出了結論,並最終通過測試,驗證了分析的結論——兩者在沒有拋出異常時,是沒有區別的。在分析的過程中,我們也瞭解到try catch的實質,就是跟方法關聯的異常表,在拋出異常的時候,這個就決定了異常是否會被該方法處理。

最後回到標題討論的,try catch對性能的影響。try catch對性能還是有一定的影響,那就是try塊會阻止java的優化(例如重排序)。當然重排序是需要一定的條件觸發。一般而言,只要try塊範圍越小,對java的優化機制的影響是就越小。所以保證try塊範圍儘量只覆蓋拋出異常的地方,就可以使得異常對java優化的機制的影響最小化。

還是那句話,先保證代碼正確執行,然後在出現明顯的性能問題時,再去考慮優化。

參考鏈接

http://stackoverflow.com/questions/16451777/is-it-expensive-to-use-try-catch-blocks-even-if-an-exception-is-never-thrown
http://stackoverflow.com/questions/504103/how-do-i-write-a-correct-micro-benchmark-in-java

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章