深入剖析Java即時編譯器(上)

Mr羽墨青衫發表於2019-03-13

本文會先介紹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        
}
複製程式碼

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

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

Java程式執行過程pro

(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(列印編譯資訊)

執行結果:

執行結果1

編譯資訊沒有列印出來,側面證明了即時編譯器沒有參與工作。

  • 然後是純編譯執行模式

新增虛擬機器引數:-Xcomp -XX:+PrintCompilation

執行結果:

執行結果2

會產生大量的編譯資訊

  • 最後是混合模式

新增虛擬機器引數:-XX:+PrintCompilation

執行結果:

執行結果3

結論:耗時由大到小排序為:純解釋模式 > 純編譯模式 > 混合模式

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

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);
    }
}
複製程式碼

執行結果如下:

執行結果4

由於解釋執行時的計數工作並沒有嚴格與編譯器同步,所以並不會是嚴格的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);
    }
}
複製程式碼

執行結果:

執行結果5

  • 根據方法呼叫和迴圈回邊

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);
    }
}
複製程式碼

執行結果:

執行結果6

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記憶體

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

3.2 CodeCache滿了會怎樣

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

通過上文的介紹,我們知道JVM內部會先嚐試解釋執行Java位元組碼,當方法呼叫或迴圈回邊達到一定次數時,會觸發即時編譯,將Java位元組碼編譯成本地機器碼以提高執行效率。這個編譯的本地機器碼是快取在CodeCache中的,如果有大量的程式碼觸發了即時編譯,而且沒有及時GC的話,CodeCache就會被填滿。

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

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

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

CodeCache日誌

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;
    }
}
複製程式碼

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

CodeCache執行

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

public int method(boolean flag) {
    return 1;
}
複製程式碼

即下圖這樣

CodeCache執行

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

made not entrant

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

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

CodeCache out

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

4 參考文件

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

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

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


歡迎關注我的微信公眾號

公眾號

相關文章