Java虛擬機-即時編譯器(上)

本文會先介紹Java的執行過程,進而引出對即時編譯器的探討,下篇會介紹分層編譯的機制,最後介紹即時編譯器對應用啟動性能的影響。

本文內容基於HotSpot虛擬機,設計Java版本的地方會在文中說明。

0 Java程序的執行過程

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
}

/<init>

JVM在執行時,首先會逐條讀取IR的指令來執行,這個過程就是解釋執行的過程。當某一方法調用次數達到即時編譯定義的閾值時,就會觸發即時編譯,這時即時編譯器會將IR進行優化,並生成這個方法的機器碼,後面再調用這個方法,就會直接調用機器碼執行,這個就是編譯執行的過程。

所以,從.java文件到最終的執行,其過程大致如下:

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);
}
}

(1)首先是純解釋執行模式

添加虛擬機參數:-Xint -XX:+PrintCompilation(打印編譯信息)

執行結果:

Java虛擬機-即時編譯器(上)

編譯信息沒有打印出來,側面證明了即時編譯器沒有參與工作。

(2)然後是純編譯執行模式

添加虛擬機參數:-Xcomp -XX:+PrintCompilation

執行結果:

Java虛擬機-即時編譯器(上)

會產生大量的編譯信息

(3)最後是混合模式

添加虛擬機參數:-XX:+PrintCompilation

執行結果:

Java虛擬機-即時編譯器(上)

結論:純解釋模式>純編譯模式>混合模式

但這裡只是一個很簡短的程序,如果是長時間運行的程序,不知純編譯模式的執行效率會否高於混合模式,而且這個測試方式並不嚴格,最好的方式應該是在嚴格的基準測試下測試。

2 何時觸發即時編譯

即時編譯器觸發的根據有兩個方面:

  • 方法的調用次數
  • 循環回邊的執行次數

JVM在調用一個方法時,會在計數器上+1,如果方法裡面有循環體,每次循環,計數器也會+1。

在不啟用分層編譯時(下篇會介紹),當某一方法的計數器達到由參數-XX:CompileThreshold指定的值時(C1為1500,C2為10000),就會觸發即時編譯。

下面做個關閉分層編譯時,即時編譯觸發的實驗:

(1)首先是根據方法調用觸發(不涉及循環)

// 參數:-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);
}
}

執行結果如下:

Java虛擬機-即時編譯器(上)

由於解釋執行時的計數工作並沒有嚴格與編譯器同步,所以並不會是嚴格的10000,其實只要調用次數足夠大,就可以視為熱點代碼,沒必要做到嚴格同步。

(2)根據循環回邊

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);
}
}

執行結果:

Java虛擬機-即時編譯器(上)

(3)根據方法調用和循環回邊

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);
}
}

執行結果:

Java虛擬機-即時編譯器(上)

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在內存中所處的位置,例如

Java虛擬機-即時編譯器(上)

從圖中曲線圖可以看出CodeCache已經使用了4M多。

3.2 CodeCache滿了會怎樣

平時我們為一個應用分配內存時往往會忽略CodeCache,CodeCache雖然佔用的內存空間不大,而且他也有GC,往往不會被填滿。但如果CodeCache一旦被填滿,那對於一個QPS高的、對性能有高要求的應用來說,可以說是災難性的。

通過上文的介紹,我們知道JVM內部會先嚐試解釋執行Java字節碼,當方法調用或循環回邊達到一定次數時,會觸發即時編譯,將Java字節碼編譯成本地機器碼以提高執行效率。這個編譯的本地機器碼是緩存在CodeCache中的,如果有大量的代碼觸發了即時編譯,而且沒有及時GC的話,CodeCache就會被填滿。

一旦CodeCache被填滿,已經被編譯的代碼還會以本地代碼方式執行,但後面沒有編譯的代碼只能以解釋執行的方式運行。

通過第2小節的比較,可以清晰看出解釋執行和編譯執行的性能差異。所以對於大多數應用來說,這種情況的出現是災難性的。

CodeCache被填滿時,JVM會打印一條日誌:

Java虛擬機-即時編譯器(上)

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;
}
}

從解釋執行的角度來看,他的執行過程如下:

Java虛擬機-即時編譯器(上)

但經過即時編譯器編譯後的代碼不一定是這樣,即時編譯器在編譯前會收集大量的執行信息,例如,如果這段代碼之前輸入的flag值都為true,那麼即時編譯器可能會將他變異成下面這樣:

public int method(boolean flag) {
return 1;
}

即下圖這樣

Java虛擬機-即時編譯器(上)

但可能後面不總是flag=true,一旦flag傳了false,這個錯了,此時編譯器就會將他“去優化”,變成編譯執行方式,在日誌中的表現是made not entrant:

Java虛擬機-即時編譯器(上)

此時該方法不能再進入,當JVM檢測到所有線程都退出該編譯後的made not entrant,會將該方法標記為:made zombie,此時 這塊代碼佔用的內存就是可回收的了。可以通過編譯日誌看出:

Java虛擬機-即時編譯器(上)

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使用情況,日誌如下:

Java虛擬機-即時編譯器(上)

最多隻使用了6721K(max_used),浪費了大量的內存,此時就可以嘗試將-XX:ReservedCodeCacheSize=256M調小,將多餘的內存分配給別的地方。

4 參考文檔

[1] https://blog.csdn.net/yandaonan/article/details/50844806

[2] 深入理解Java虛擬機 周志明 第11章

[3] 極客時間《深入拆解Java虛擬機》 鄭雨迪


分享到:


相關文章: