本文會先介紹Java的執行過程,進而引出對即時編譯器的探討,下篇會介紹分層編譯的機制,最後介紹即時編譯器對應用啟動效能的影響。
本文內容基於HotSpot虛擬機器,設計Java版本的地方會在文中說明。
0 Java程式的執行過程
Java面試中,有一道面試題是這樣問的:Java程式是解釋執行還是編譯執行?
在我們剛學習Java時,大概會認為Java是編譯執行的。其實,Java既有解釋執行,也有編譯執行。
Java程式通常的執行過程如下:
原始碼.java檔案通過javac命令編譯成.class的位元組碼,再通過java命令執行。
需要說明的是,在編譯原理中,通常將編譯分為前端和後端。其中前端會對程式進行詞法分析、語法分析、語義分析,然後生成一箇中間表達形式(稱為IR:Intermediate Representation)。後端再講這個中間表達形式進行優化,最終生成目標機器碼。
在Java中,javac之後生成的就是中間表達形式(.class),舉個栗子
public class JITDemo2 {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
複製程式碼
上述程式碼通過javap反編譯後如下:
// javap -c JITDemo2.class
Compiled from "JITDemo2.java"
public class com.example.demo.jitdemo.JITDemo2 {
public com.example.demo.jitdemo.JITDemo2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
複製程式碼
JVM在執行時,首先會逐條讀取IR的指令來執行,這個過程就是解釋執行的過程。當某一方法呼叫次數達到即時編譯定義的閾值時,就會觸發即時編譯,這時即時編譯器會將IR進行優化,並生成這個方法的機器碼,後面再呼叫這個方法,就會直接呼叫機器碼執行,這個就是編譯執行的過程。
所以,從.java檔案到最終的執行,其過程大致如下:
(CodeCache會在下文中介紹)
那麼,何時出發即時編譯?即時編譯的過程又是怎樣的?我們繼續往下研究。
1 Java即時編譯器初探
HotSpot虛擬機器有兩個編譯器,稱為C1和C2編譯器(Java10以後新增了一個編譯器Graal)。
C1編譯器對應引數-client,對於執行時間較短,對啟動效能有要求的程式,可以選擇C1。
C2編譯器對應引數-server,對峰值效能有要求的程式,可以選擇C2。
但無論是-client還是-server,C1和C2都是有參與編譯工作的。這種方式成為混合模式(mixed),也是預設的方式,可以通過java -version看出:
C:\Users\Lord_X_>java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
複製程式碼
最後一行的mixed mode說明了這一點。
我們也可以通過-Xint引數強行指定只使用解釋模式,此時即時編譯器完全不參與工作,java -version的最後一行會顯示interpreted mode。
可以通過引數-Xcomp強行指定只使用編譯模式,此時程式啟動後就會直接對所有程式碼進行編譯,這種方式會拖慢啟動時間,但啟動後由於省去了解釋執行和C1、C2的編譯時間,程式碼執行效率會提升很多。此時java -version的最後一行會顯示compiled mode。
下面通過一段程式碼來對比一下三種模式的執行效率(一個簡陋的效能 ):
public class JITDemo2 {
private static Random random = new Random();
public static void main(String[] args) {
long start = System.currentTimeMillis();
int count = 0;
int i = 0;
while (i++ < 99999999){
count += plus();
}
System.out.println("time cost : " + (System.currentTimeMillis() - start));
}
private static int plus() {
return random.nextInt(10);
}
}
複製程式碼
- 首先是純解釋執行模式
新增虛擬機器引數:-Xint -XX:+PrintCompilation(列印編譯資訊)
執行結果:
編譯資訊沒有列印出來,側面證明了即時編譯器沒有參與工作。
- 然後是純編譯執行模式
新增虛擬機器引數:-Xcomp -XX:+PrintCompilation
執行結果:
會產生大量的編譯資訊
- 最後是混合模式
新增虛擬機器引數:-XX:+PrintCompilation
執行結果:
結論:耗時由大到小排序為:純解釋模式 > 純編譯模式 > 混合模式
但這裡只是一個很簡短的程式,如果是長時間執行的程式,不知純編譯模式的執行效率會否高於混合模式,而且這個測試方式並不嚴格,最好的方式應該是在嚴格的基準測試下測試。
2 何時觸發即時編譯
即時編譯器觸發的根據有兩個方面:
- 方法的呼叫次數
- 迴圈回邊的執行次數
JVM在呼叫一個方法時,會在計數器上+1,如果方法裡面有迴圈體,每次迴圈,計數器也會+1。
在不啟用分層編譯時(下篇會介紹),當某一方法的計數器達到由引數-XX:CompileThreshold指定的值時(C1為1500,C2為10000),就會觸發即時編譯。
下面做個關閉分層編譯時,即時編譯觸發的實驗:
- 首先是根據方法呼叫觸發(不涉及迴圈)
// 引數:-XX:+PrintCompilation -XX:-TieredCompilation(關閉分層編譯)
public class JITDemo2 {
private static Random random = new Random();
public static void main(String[] args) {
long start = System.currentTimeMillis();
int count = 0;
int i = 0;
while (i++ < 15000){
System.out.println(i);
count += plus();
}
System.out.println("time cost : " + (System.currentTimeMillis() - start));
}
// 呼叫時,編譯器計數器+1
private static int plus() {
return random.nextInt(10);
}
}
複製程式碼
執行結果如下:
由於解釋執行時的計數工作並沒有嚴格與編譯器同步,所以並不會是嚴格的10000,其實只要呼叫次數足夠大,就可以視為熱點程式碼,沒必要做到嚴格同步。
- 根據迴圈回邊
public class JITDemo2 {
private static Random random = new Random();
public static void main(String[] args) {
long start = System.currentTimeMillis();
plus();
System.out.println("time cost : " + (System.currentTimeMillis() - start));
}
// 呼叫時,編譯器計數器+1
private static int plus() {
int count = 0;
// 每次迴圈,編譯器計數器+1
for (int i = 0; i < 15000; i++) {
System.out.println(i);
count += random.nextInt(10);
}
return random.nextInt(10);
}
}
複製程式碼
執行結果:
- 根據方法呼叫和迴圈回邊
PS:每次方法呼叫中有10次迴圈,所以每次方法呼叫計數器應該+11,所以應該會在差不多大於10000/11=909次呼叫時觸發即時編譯。
public class JITDemo2 {
private static Random random = new Random();
public static void main(String[] args) {
long start = System.currentTimeMillis();
int count = 0;
int i = 0;
while (i++ < 15000) {
System.out.println(i);
count += plus();
}
System.out.println("time cost : " + (System.currentTimeMillis() - start));
}
// 呼叫時,編譯器計數器+1
private static int plus() {
int count = 0;
// 每次迴圈,編譯器計數器+1
for (int i = 0; i < 10; i++) {
count += random.nextInt(10);
}
return random.nextInt(10);
}
}
複製程式碼
執行結果:
3 CodeCache
CodeCache是熱點程式碼的暫存區,經過即時編譯器編譯的程式碼會放在這裡,它存在於堆外記憶體。
-XX:InitialCodeCacheSize和-XX:ReservedCodeCacheSize引數指定了CodeCache的記憶體大小。
- -XX:InitialCodeCacheSize:CodeCache初始記憶體大小,預設2496K
- -XX:ReservedCodeCacheSize:CodeCache預留記憶體大小,預設48M
PS:可以通過-XX:+PrintFlagsFinal列印出所有引數的預設值。
3.1 通過jconsole監控CodeCache
可以通過JDK自帶的jconsole工具看到CodeCache在記憶體中所處的位置,例如
從圖中曲線圖可以看出CodeCache已經使用了4M多。
3.2 CodeCache滿了會怎樣
平時我們為一個應用分配記憶體時往往會忽略CodeCache,CodeCache雖然佔用的記憶體空間不大,而且他也有GC,往往不會被填滿。但如果CodeCache一旦被填滿,那對於一個QPS高的、對效能有高要求的應用來說,可以說是災難性的。
通過上文的介紹,我們知道JVM內部會先嚐試解釋執行Java位元組碼,當方法呼叫或迴圈回邊達到一定次數時,會觸發即時編譯,將Java位元組碼編譯成本地機器碼以提高執行效率。這個編譯的本地機器碼是快取在CodeCache中的,如果有大量的程式碼觸發了即時編譯,而且沒有及時GC的話,CodeCache就會被填滿。
一旦CodeCache被填滿,已經被編譯的程式碼還會以原生程式碼方式執行,但後面沒有編譯的程式碼只能以解釋執行的方式執行。
通過第2小節的比較,可以清晰看出解釋執行和編譯執行的效能差異。所以對於大多數應用來說,這種情況的出現是災難性的。
CodeCache被填滿時,JVM會列印一條日誌:
JVM針對CodeCache提供了GC方式: -XX:+UseCodeCacheFlushing。在JDK1.7.0_4之後這個引數預設開啟,當CodeCache即將填滿時會嘗試回收。JDK7在這方面的回收做的不是很少,GC收益較低,在JDK8有了很大的改善,所以可以通過升級到JDK8來直接提升這方面的效能。
3.3 CodeCache的回收
那麼什麼時候CodeCache中被編譯的程式碼是可以回收的呢?
這要從編譯器的編譯方式說起。舉個例子,下面這段程式碼:
public int method(boolean flag) {
if (flag) {
return 1;
} else {
return 0;
}
}
複製程式碼
從解釋執行的角度來看,他的執行過程如下:
但經過即時編譯器編譯後的程式碼不一定是這樣,即時編譯器在編譯前會收集大量的執行資訊,例如,如果這段程式碼之前輸入的flag值都為true,那麼即時編譯器可能會將他變異成下面這樣:
public int method(boolean flag) {
return 1;
}
複製程式碼
即下圖這樣
但可能後面不總是flag=true,一旦flag傳了false,這個錯了,此時編譯器就會將他“去優化”,變成編譯執行方式,在日誌中的表現是made not entrant:
此時該方法不能再進入,當JVM檢測到所有執行緒都退出該編譯後的made not entrant,會將該方法標記為:made zombie,此時 這塊程式碼佔用的記憶體就是可回收的了。可以通過編譯日誌看出:
3.4 CodeCache的調優
在Java8中提供了一個JVM啟動引數:-XX:+PrintCodeCache,他可以在JVM停止時列印CodeCache的使用情況,可以在每次停止應用時觀察一下這個值,慢慢調整為一個最合適的大小。
以一個SpringBoot的Demo說明一下:
// 啟動引數:-XX:ReservedCodeCacheSize=256M -XX:+PrintCodeCache
@RestController
@SpringBootApplication
public class DemoApplication {
// ... other code ...
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
System.out.println("start....");
System.exit(1);
}
}
複製程式碼
這裡我將CodeCache定義為256M,並在JVM退出時列印了CodeCache使用情況,日誌如下:
最多隻使用了6721K(max_used),浪費了大量的記憶體,此時就可以嘗試將-XX:ReservedCodeCacheSize=256M調小,將多餘的記憶體分配給別的地方。
4 參考文件
[1] https://blog.csdn.net/yandaonan/article/details/50844806
[2] 深入理解Java虛擬機器 周志明 第11章
[3] 極客時間《深入拆解Java虛擬機器》 鄭雨迪