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

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

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

0 Java程序的執行過程

Java面試中,有一道面試題是這樣問的:Java程序是解釋執行仍是編譯執行?面試

在咱們剛學習Java時,大概會認爲Java是編譯執行的。其實,Java既有解釋執行,也有編譯執行。後端

Java程序一般的執行過程以下:緩存

Java程序執行過程

源碼.java文件經過javac命令編譯成.class的字節碼,再經過java命令執行。微信

須要說明的是,在編譯原理中,一般將編譯分爲前端和後端。其中前端會對程序進行詞法分析、語法分析、語義分析,而後生成一箇中間表達形式(稱爲IR:Intermediate Representation)。後端再講這個中間表達形式進行優化,最終生成目標機器碼。dom

在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強行指定只使用編譯模式,此時程序啓動後就會直接對全部代碼進行編譯,這種方式會拖慢啓動時間,但啓動後因爲省去了解釋執行和C一、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虛擬機》 鄭雨迪


歡迎關注個人微信公衆號

公衆號
相關文章
相關標籤/搜索