Java HotSpot VM中的JIT編譯

Java HotSpot虛擬機是Oracle收購Sun時得到的,JVM和開源的OpenJDK都是以此虛擬機爲基礎發展的。如同其它虛擬機,HotSpot虛擬機爲字節碼提供了一個運行時環境。實際上,它主要會作這三件事情:java

  • 執行方法所請求的指令和運算。
  • 定位、加載和驗證新的類型(即類加載)。
  • 管理應用內存。

最後兩點都是各自領域的大話題,因此這篇文章中只關注代碼執行。git

java hotspot

JIT編譯

Java HotSpot是一個混合模式的虛擬機,也就是說它既能夠解釋字節碼,又能夠將代碼編譯爲本地機器碼以更快的執行。經過配置-XX:+PrintCompilation參數,你能夠在log文件中看到方法被JIT編譯時的信息。JIT編譯發生在運行時 —— 方法通過屢次運行以後。到方法須要使用到的時候,HotSpot VM會決定如何優化這些代碼。segmentfault

若是你好奇JIT編譯帶來的性能提高,可使用-Djava.compiler=none將其關掉而後運行基準測試程序來看看它們的差異。網絡

Java HotSpot虛擬機能夠運行在兩種模式下:client或者server。你能夠在JVM啓動時經過配置-client或者-server選項來選擇其中一種。兩種模式都有各自的適用場景,本文中,咱們只會涉及到server模式。oracle

兩種模式最主要的區別是server模式下會進行更激進的優化 —— 這些優化是創建在一些並不永遠爲真的假設之上。一個簡單的保護條件(guard condition)會驗證這些假設是否成立,以確保優化老是正確的。若是假設不成立,Java HotSpot虛擬機將會撤銷所作的優化並退回到解釋模式。也就是說Java HotSpot虛擬機老是會先檢查優化是否仍然有效,不會由於假設再也不成立而表現出錯誤的行爲。ide

在server模式下,Java
HotSpot虛擬機會默認在解釋模式下運行方法10000次纔會觸發JIT編譯。能夠經過虛擬機參數-XX:CompileThreshold來調整這個值。好比-XX:CompileThreshold=5000會讓觸發JIT編譯的方法運行次數減小一半。(譯者注:有關JIT觸發條件可參考《深刻理解Java虛擬機》第十一章以及《Java Performance》第三章HotSpot VM JIT Compilers小節)oop

這可能會誘使新手將編譯閾值調整到一個很是低的值。但要抵擋住這個誘惑,由於這樣可能會下降虛擬機性能,優化後減小的方法執行時間還不足以抵消花在JIT編譯上的時間。性能

當Java HotSpot虛擬機能爲JIT編譯收集到足夠多的統計信息時,性能會最好。當你下降編譯閾值時,Java HotSpot虛擬機可能會在非熱點代碼的編譯中花費較多時間。有些優化只有在收集到足夠多的統計信息時纔會進行,因此下降編譯閾值可能致使優化效果不佳。測試

另一方面,不少開發者想讓一些重要方法在編譯模式下儘快得到更好的性能。優化

解決此問題通常是在進程啓動後,對代碼進行預熱以使它們被強制編譯。對於像訂單系統或者交易系統來講,重要的是要確保預熱不會產生真實的訂單。

Java HotSpot虛擬機提供了不少參數來輸出JIT的編譯信息。最經常使用的就是前文提到的PrintCompilation,也還有一些其它參數。

接下來咱們將使用PrintCompilation來觀察Java HotSpot虛擬機在運行時編譯方法的成效。但先有必要說一下用於計時的System.nanoTime()方法。

計時方法

Java爲咱們提供了兩個主要的獲取時間值的方法:currentTimeMillis()和nanoTime().前者對應於咱們在實體世界中看到的時間(所謂的鐘表時間),它的精度能知足大多數狀況,但不適用於低延遲的應用。

納秒計時器擁有更高的精度。這種計時器度量時間的間隔極短。1納秒是光在光纖中移動20CM所需的時間,相比之下,光經過光纖從倫敦傳送到紐約大約須要27.5毫秒。

由於納秒級的時間戳精度過高,使用不當就會產生較大偏差,所以使用時須要注意。

如,currentTimeMillis()能很好的在機器間同步,能夠用於測量網絡延遲,但nanoTime()不能跨機器使用。

接下來將上面的理論付諸實踐,來看一個很簡單(但極其強大)的JIT編譯技術。

方法內聯

方法內聯是編譯器優化的關鍵手段之一。方法內聯就是把方法的代碼「複製」到發起調用的方法裏,以消除方法調用。這個功能至關重要,由於調用一個小方法可能比執行該小方法的方法體耗時還多。

JIT編譯器能夠進行漸進內聯,開始時內聯簡單的方法,若是能夠進行其它優化時,就接着優化內聯後的較大的代碼塊。

Listing1,Listing1A以及Listing1B是個簡單的測試,將直接操做字段和經過getter/setter方法作了對比。若是簡單的getters和setters方法沒有使用內聯的話,那調用它們的代價是至關大的,由於方法調用比直接操做字段代價更高。

Listing1:

public class Main {
    private static double timeTestRun(String desc, int runs, 
        Callable<Double> callable) throws Exception {
        long start = System.nanoTime();
        callable.call();
        long time = System.nanoTime() - start;
        return (double) time / runs;
    }

    // Housekeeping method to provide nice uptime values for us
    private static long uptime() {
        return ManagementFactory.getRuntimeMXBean().getUptime() + 15; 
    // fudge factor
    }

    public static void main(String... args) throws Exception {
        int iterations = 0;
        for (int i : new int[]
            { 100, 1000, 5000, 9000, 10000, 11000, 13000, 20000, 100000} ) {
            final int runs = i - iterations;
            iterations += runs;

            // NOTE: We return double (sum of values) from our test cases to
            // prevent aggressive JIT compilation from eliminating the loop in
            // unrealistic ways
            Callable<Double> directCall = new DFACaller(runs);
            Callable<Double> viaGetSet = new GetSetCaller(runs);

            double time1 = timeTestRun("public fields", runs, directCall);
            double time2 = timeTestRun("getter/setter fields", runs, viaGetSet);

            System.out.printf("%7d %,7d\t\tfield access=%.1f ns, getter/setter=%.1f ns%n",
                uptime(), iterations, time1, time2);
            // added to improve readability of the output
            Thread.sleep(100);
        }
    }
}

Listing1A:

public class DFACaller implements Callable<Double>{
    private final int runs;

    public DFACaller(int runs_) {
        runs = runs_;
    }

    @Override
    public Double call() {
        DirectFieldAccess direct = new DirectFieldAccess();
        double sum = 0;
        for (int i = 0; i < runs; i++) {
            direct.one++;
            sum += direct.one;
        }
        return sum;
    }
}

public class DirectFieldAccess {
    int one;
}

Listing1B:

public class GetSetCaller implements Callable<Double> {
    private final int runs;

    public GetSetCaller(int runs_) {
        runs = runs_;
    }

    @Override
    public Double call() {
        ViaGetSet getSet = new ViaGetSet();
        double sum = 0;
        for (int i = 0; i < runs; i++) {
            getSet.setOne(getSet.getOne() + 1);
            sum += getSet.getOne();
        }
        return sum;
    }
}

public class ViaGetSet {
    private int one;

    public int getOne() {
        return one;
    }

    public void setOne(int one) {
        this.one = one;
    }
}

若是使用java -cp. -XX:PrintCompilation Main 運行測試用例,就能看到性能上的差別(見Listing2)。

Listing2

31    1     java.lang.String::hashCode (67 bytes) 
 36   100    field access=1970.0 ns, getter/setter=1790.0 ns 
 39    2     sun.nio.cs.UTF_8$Encoder::encode (361 bytes) 
 42    3     java.lang.String::indexOf (87 bytes) 
141   1,000 field access=16.7 ns, getter/setter=67.8 ns 
245   5,000 field access=16.8 ns, getter/setter=72.8 ns 
245    4     ViaGetSet::getOne (5 bytes) 
348   9,000 field access=16.0 ns, getter/setter=65.3 ns 
450    5     ViaGetSet::setOne (6 bytes) 
450  10,000 field access=16.0 ns, getter/setter=199.0 ns 
553    6     Main$1::call (51 bytes) 
554    7     Main$2::call (51 bytes) 
556    8     java.lang.String::charAt (33 bytes) 
556  11,000 field access=1263.0 ns, getter/setter=1253.0 ns 
658  13,000 field access=5.5 ns, getter/setter=1.5 ns 
760  20,000 field access=0.7 ns, getter/setter=0.7 ns 
862 100,000 field access=0.7 ns, getter/setter=0.7 ns

這些是什麼意思?Listing2中的第一列是程序啓動到語句執行時所通過的毫秒數,第二列是方法ID(編譯後的方法)或遍歷次數。

注意:測試中沒有直接使用String和UTF_8類,但它們仍然出如今編譯的輸出中,這是由於平臺使用了它們。

從Listing2中的第二行能夠發現,直接訪問字段和經過getter/setter都是比較慢的,這是由於第一次運行時包含了類加載的時間,下一行就比較快了,儘管此時尚未任何代碼被編譯。

另外要注意下面幾點:

  • 在遍歷1000和5000次時,直接操做字段比使用getter/setter方法快,由於getter 和setter尚未內聯或優化。即使如此,它們都還至關地快。
  • 在遍歷9000次時,getter方法被優化了(由於每次循環中調用了兩次),使性能有小許提升。
  • 在遍歷10000次時,setter方法也被優化了,由於須要額外花費時間去優化,因此執行速度降下來了。
  • 最終,兩個測試類都被優化了:

    • DFACaller直接操做字段,GetSetCaller使用getter和setter方法。此時它們不只剛被優化,還被內聯了。
    • 從下一次的遍歷中能夠看到,測試用例的執行時間仍不是最快的。
  • 在13000次遍歷以後,兩種字段訪問方式的性能都和最後更長時間測試的結果同樣好,咱們已經達到了性能的穩定狀態。

須要特別注意的是,直接訪問字段和經過getter/setter訪問在穩定狀態下的性能是基本一致的,由於方法已經被內聯到GetSetCaller中,也就是說在viaGetSet中所作的事情和directCall中徹底同樣。

JIT編譯是在後臺進行的。每次可用的優化手段可能隨機器的不一樣而不一樣,甚至,同個程序的屢次運行期間也可能不同。

總結

這篇文章中,我所描述的只是JIT編譯的冰山一角,尤爲是沒有提到如何寫出好的基準測試以及如何使用統計信息以確保不會被平臺的動態性所愚弄。

這裏使用的基準測試很是簡單,不適合作爲真實的基準測試。在第二部分,我計劃向您展現一個真實的基準測試並繼續深刻JIT編譯的過程。


原文 Introduction to JIT Compliation in Java Hotspot VM
譯者 郭蕾
校對丁一
via ifeve

相關文章
相關標籤/搜索