01 即時編譯優化
Java程序在運行初期是通過解釋器來執行,當發現某塊代碼運行特別頻繁,就會將之判定爲熱點代碼(Hot Spot Code), 虛擬機會將這部分代碼編譯成本地機器碼,並對這些代碼進行優化。這件事就是即時編譯(Just In Time, JIT)優化, 做這件事的就是即時編譯器。
1. 解釋器與編譯器
目前主流虛擬機都採用解釋器、編譯器並存的架構。
解釋器:程序執行初期,解釋器執行的方式可以省去編譯過程,節省時間
編譯器:在渡過初期後,編譯器把更多的代碼編譯成本地代碼,提升執行效率,以空間換時間
因爲編譯器存在過度優化,基於假設優化等可能失敗的優化結果,通過逆優化(Deoptimization)的方式,將程序的執行主動權從編譯器交給解釋器執行。可以把解釋器看成是一個保守派,編譯器是一個激進派,在JVM執行體系裏,兩者相輔相成,互相配合。
1.1 編譯器種類
一般虛擬機都內置了兩個或三個即時編譯器,歷史比較久遠的C1, C2, 以及在JDK10纔出現的Graal
C1:客戶端編譯器(Client Complier),執行時間較短,啓動程序的時間較快。在一些物聯網小型設備上可指定這種編譯器,通過-client參數強制指定
C2:服務端編譯器(Server Complier),執行時間較長,啓動時間較長但可編譯高度優化的代碼,峯值性能更高。可通過-server參數強制指定
Graal:是一個實驗性質的即時編譯器,其最大的特點是該編譯器用Java語言編寫,更加模塊化,也更容易開發與維護。充分預熱後Java代碼編譯成二進制碼後其執行性能並不亞於由C++編寫的C2。可以通過參數 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 啓用,並替換 C2
1.2 分層編譯優化
雖然可以通過-Xint參數強制虛擬機處於"解釋模式"此時編譯器不工作,可以通過-Xcomp參數強制虛擬機處於"編譯模式"此時解釋器不工作,可以通過-client參數使C2不工作,也可以通過-server參數使C1不工作,但是並不推薦這樣做,因爲有分層編譯優化這一特性。
編譯器在編譯代碼的時候會佔用程序運行時間,優化程度越高的代碼編譯時間會越長,甚至會需要解釋器負責收集程序運行監控信息提供給編譯器來編譯優化程度更高的代碼。所以爲了在更短的時間內編譯優化程度更高的代碼,需要編譯器之間的配合,也就是所謂的分層編譯優化。一共有五層,分別是:
純解釋執行,解釋器不開啓收集程序運行監控信息
使用C1編譯器進行簡單可靠的優化,解釋器不開啓收集程序運行監控信息
仍然使用C1編譯器優化,但是會針對方法調用次數和回邊次數(循環代碼調用次數)相關的統計
仍然使用C1編譯器優化,統計信息才上一層的基礎上會加上分支跳轉、虛方法調用等全部統計信息,解釋器火力全開
使用C2編譯器優化,相比C1,C2會開啓更多耗時更長的優化,還會根據解釋器提供的程序運行信息進行一些更爲激進的優化
在開啓編譯優化後,熱點代碼可能會被重複編譯,C1編譯器編譯得更快,C2編譯器編譯質量更高,第0層模式解釋器執行的時候也不用收集監控信息,第4層模式C2在進行耗時較長的編譯較爲忙碌時候,C1也能爲C2承擔一部分編譯工作,交互關係如下圖
common是針對大部分代碼的編譯情況,trival method針對執行次數較少的代碼
trival method很少被執行所以沒有被C2編譯的必要,通過第4層模式的優化就足夠了
在C1忙碌的時候,會直接由C2編譯;C2忙碌的時候,在C1編譯的路徑也會更長
2. 編譯觸發條件
上面提到即使編譯是針對熱點代碼進行編譯優化,那麼什麼是熱點代碼?
被多次調用的方法
被多次執行的循環代碼體
這裏的多次如何知道具體有多少次?有兩種方法可以知道
基於採樣的熱點探測(Sample Based Hot Spot Code Detection): 虛擬機週期性地檢查各個線程的調用棧頂,如果發現某個方法經常出現在棧頂,那麼這個方法就是熱點方法,這種方法簡單高效但是精確度不高
基於計數器的熱點探測(Counter Based Hot Spot Code Dection): 虛擬機爲每個方法建立計數器,計數器超過一定閾值就是熱點方法
目前HotSpot虛擬機使用的是第二種方法,虛擬機爲每個方法都準備了兩類計數器,方法調用計數器以及回邊計數器(回邊的意思是在循環的末尾邊界往回跳轉,可以理解爲循環代碼的一次執行)
講到這裏給大家舉一個工作中經常見到的一個JIT優化案例:異常堆棧丟失
02 異常堆棧丟失
1. 問題
總所周知在打印Java異常的時候,會將其堆棧信息一併輸出,這些堆棧信息非常重要,有助於我們排查問題,像這樣
20:10:50.491 [main] ERROR com.yangkw.ErrorTestjava.lang.NullPointerException: nullat com.yangkw.ErrorTest.error(ErrorTest.java:33) at com.yangkw.ErrorTest.main(ErrorTest.java:19)
但是在最近在觀察系統的線上運行日誌的時候,發現很多不帶堆棧的異常日誌,讓人摸不着頭腦到底發生了什麼,像這樣
20:10:50.491 [main] ERROR com.yangkw.ErrorTestjava.lang.NullPointerException: null
2. 猜想
通過前面關於JIT編譯觸發條件的介紹,可以設想是拋出異常執行太頻繁所以觸發了JIT優化導致,於是我們可以寫一個Demo來驗證,堆棧完整的時候打印"full trace",堆棧丟失的時候打印"no trace"
public static void main(String[] args) throws InterruptedException {int count = 0;while (true) {try { count++; //統計調用次數 error(); } catch (Exception e) {if (e.getStackTrace().length == 0) { LOG.error("no trace count:{}", count, e); Thread.sleep(1000); //方便觀察日誌 } else { LOG.error("full trace count:{}", count, e); } } } }private static void error() { String nullMsg = null; nullMsg.toString(); }
下面是執行結果,可以看出程序是在執行到8405次(每次執行都會不同)的時候丟失了堆棧
3. 驗證
雖然8405次執行的時候丟失了堆棧,但是並不能說明是因爲JIT優化導致的,於是我們可以加上參數-XX:+PrintCompilation 來打印即時編譯情況。
可以看到,在10388次執行的時候是有堆棧信息的,在10389次執行的時候就丟失了堆棧信息,在這中間就發生了即使編譯優化,針對這一現象官方術語稱之爲"fast throw"可以通過參數-XX:-OmitStackTraceInFastThrow關閉這一優化
在ORACLE官方文檔有這麼一段描述
The compiler in the server VM now provides correct stack backtraces for all "cold" built-in exceptions. For performance purposes, when such an exception is thrown a few times, the method may be recompiled. After recompilation, the compiler may choose a faster tactic using preallocated exceptions that do not provide a stack trace. To disable completely the use of preallocated exceptions, use this new flag: -XX:-OmitStackTraceInFastThrow.
堆棧丟失只是表面現象,JIT還對其做了以下優化:
創建需要拋出異常的實例
清空堆棧信息
將該實例緩存起來
之後再需要拋出的時候,將緩存實例拋出去
03 總結
解釋器、C1編譯器、C2編譯器各有優劣,合理搭配,幹活不累
-XX:-OmitStackTraceInFastThrow 謹慎使用,如果關閉fast throw的優化應預防"日誌風暴"使磁盤空間迅速被打滿
做好歷史日誌的記錄以及備份,筆者通過回查歷史日誌成功追回了異常的堆棧信息
日照充足的西瓜會更甜,擁有即時編譯優化會讓Java程序程序更靈性
有道無術,術可成;有術無道,止於術
歡迎大家關注Java之道公衆號
好文章,我在看❤️