Java HotSpot VM中的JIT編譯
原文地址譯者:郭蕾 校對:丁一
本文是Java HotSpot VM and just-in-time(JIT) compilation系列的第一篇。
Java HotSpot虛擬機器是Oracle收購Sun時獲得的,JVM和開源的OpenJDK都是以此虛擬機器為基礎發展的。如同其它虛擬機器,HotSpot虛擬機器為位元組碼提供了一個執行時環境。實際上,它主要會做這三件事情:
- 執行方法所請求的指令和運算。
- 定位、載入和驗證新的型別(即類載入)。
- 管理應用記憶體。
最後兩點都是各自領域的大話題,所以這篇文章中只關注程式碼執行。
JIT編譯
Java HotSpot是一個混合模式的虛擬機器,也就是說它既可以解釋位元組碼,又可以將程式碼編譯為本地機器碼以更快的執行。通過配置-XX:+PrintCompilation引數,你可以在log檔案中看到方法被JIT編譯時的資訊。JIT編譯發生在執行時 —— 方法經過多次執行之後。到方法需要使用到的時候,HotSpot VM會決定如何優化這些程式碼。
如果你好奇JIT編譯帶來的效能提升,可以使用-Djava.compiler=none將其關掉然後執行基準測試程式來看看它們的差別。
Java HotSpot虛擬機器可以執行在兩種模式下:client或者server。你可以在JVM啟動時通過配置-client或者-server選項來選擇其中一種。兩種模式都有各自的適用場景,本文中,我們只會涉及到server模式。
兩種模式最主要的區別是server模式下會進行更激進的優化 —— 這些優化是建立在一些並不永遠為真的假設之上。一個簡單的保護條件(guard condition)會驗證這些假設是否成立,以確保優化總是正確的。如果假設不成立,Java HotSpot虛擬機器將會撤銷所做的優化並退回到解釋模式。也就是說Java HotSpot虛擬機器總是會先檢查優化是否仍然有效,不會因為假設不再成立而表現出錯誤的行為。
在server模式下,Java HotSpot虛擬機器會預設在解釋模式下執行方法10000次才會觸發JIT編譯。可以通過虛擬機器引數-XX:CompileThreshold來調整這個值。比如-XX:CompileThreshold=5000會讓觸發JIT編譯的方法執行次數減少一半。(譯者注:有關JIT觸發條件可參考《深入理解Java虛擬機器》第十一章以及《Java Performance》第三章HotSpot VM JIT Compilers小節)
這可能會誘使新手將編譯閾值調整到一個非常低的值。但要抵擋住這個誘惑,因為這樣可能會降低虛擬機器效能,優化後減少的方法執行時間還不足以抵消花在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編譯的過程。