天天看點

深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

1. JIT編譯器的引入

首先我們這篇文章中所說的編譯器都是指JVM的組成部分之一—即時編譯器(JIT),與生成Java位元組碼的javac編譯器要區分開來。首先我們這篇文章中所說的編譯器都是指JVM的組成部分之一—即時編譯器(JIT),與生成Java位元組碼的javac編譯器要區分開來。JIT的出現,是為了補強虛拟機邊運作邊解釋的低性能。它會智能地對熱點代碼進行優化且重複利用,最終将這些代碼編譯為與本地平台相關的機器碼。

2. 解釋器與編譯器并存的架構體系

我們可能會問為什麼虛拟機要使用解釋器與編譯器并存的架構體系?主要是有以下幾個原因:

  1. 當程式需要迅速啟動和執行時,解釋器可以首先發揮作用,省去編譯的時間,立即執行。
  2. 當程式運作後,随着事件的推移,JIT編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼之後,可以擷取更高的執行效率。
  3. 當程式運作環境中的記憶體資源限制較大的時候,可以使用解釋器執行節約記憶體,反之可以使用編譯器執行來提高效率。
  4. 解釋器還可以作為編譯器優化時的一個“逃生門”,讓編譯器根據機率選擇一些大多數時候都能提升運作速度的優化手段。
深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

引入及時編譯器之後整個JVM的工作流程如下:

深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

2.1 Client模式和Server模式

在HotSpot中還内置了兩個即時編譯器,分别是Client Compiler和Server Compiler,也稱為C1編譯器與C2編譯器,在目前的HotSpot JVM中預設采用的是解釋器與其中一個編譯器直接配合的方式工作。我們可以使用“-client”或“-server”參數去指定解釋器與具體的某個編譯器配合工作。這也就是Client模式和Server模式的本質—指定了不同的JIT編譯器進行工作。

Client版本(C1編譯器)啟動快,Server版本(C2編譯器)運作快。至于為什麼會産生這樣的效果卻鮮有人說明,其實就是因為兩種編譯器之間的差異。為編譯器編譯本地代碼也是需要占用程式運作時間的。

  • C1編譯器:C1編譯器主要是進行簡單、可靠的優化,是以Client模式加載速度較快而Server模式運作起來較快。
  • C2編譯器:C2編譯器為了編譯出優化程度更高的代碼主要是進行一些編譯耗時較長的優化,甚至會進行激進優化。
  • 分層編譯:JVM團隊為了在程式啟動響應速度和與運作效率之間達到最佳平衡,設計出了分層編譯。在JDK1.7的Server模式中,分層編譯被作為預設編譯政策開啟,分層編譯根據編譯器編譯、優化的規模與耗時,劃分出不同的編譯層次。

2.2 編譯對象與觸發即時編譯的條件

在了解了為什麼需要引入JIT編譯器之後,現在需要讨論的就是哪些代碼會被JIT編譯器進行編譯。

會被JIT編譯器編譯的“熱點代碼”有兩類:

  • 被多次調用的方法
  • 被多次執行的循環體

OSR(Open Stack Replacement)編譯:由于編譯是發生在方法執行的過程中,是以會産生“棧上替換”(OSR編譯)的行為,也就是方法棧幀還在棧上,方法就被替換了。

熱點探測:那麼判斷一段代碼是不是熱點代碼,是不是需要觸發即時編譯,這樣的行為稱為熱點編譯。目前主流的熱點探測判斷方式主要有兩種:

  1. 基于采樣的熱點探測: JVM周期性的檢查各個線程的棧頂,如發現某個方法經常出現在棧頂,那這個方法就是“熱點方法”。這個方法的劣勢很明顯,如果發生線程阻塞,那将會擾亂熱點探測。
  2. 基于計數器的熱點探測: HotSpot虛拟機采用這種方法。它會為每個方法建立計數器,統計方法的執行次數,如果執行次數超過了一定的閥值,就可以認為它是“熱點方法”。

“熱點探測”技術給我們提供了尋找“熱點方法”的途徑,而計數器則是這條途徑的具體實作。HotSpot虛拟機為每個方法提供了兩種計數器,這兩個計數器都有一定的閥值,當計數器超過這個閥值溢出了,就會觸發JIT編譯。

2.2.1 方法調用計數器

這個計數器用于統計方法被調用的次數,對應“熱點代碼”中“被多次調用的方法”。有興趣的同學可以查查它的預設閥值。閥值可以通過虛拟機參數-XX:CompileThreshold進行設定。

我們重點來看一下方法調用計數器觸發即時編譯的整個流程。

當一個方法被調用時,會先檢查方法是否存在被JIT編譯過的版本,如果存在,則優先使用編譯後的本地代碼來執行。如果不存在已經被編譯的版本,則将此方法的調用計數器加1,然後判斷方法調用計數器與回邊計數器(稍後說明)之和是否超過方法調用計數器的閥值,如果已經超過閥值,那麼将會向即時編譯器送出一個該方法的代碼編譯請求。在下次進行方法調用的時候,重複此流程。

具體流程如下圖:

深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

從圖中可以看到,在向即時編譯器送出編譯請求之後,執行引擎并不會進行阻塞,而是繼續進入解釋器按照解釋方式執行位元組碼,直到送出的請求被編譯器編譯完成,這樣做很明顯不會造成程式運作中的阻塞。并且,我們可以判斷,即時編譯由一個背景線程操作進行。

在方法調用計數器中還有兩個特别重要的概念:方法調用計數器的熱度衰減與半衰周期。

如果不做任何設定,方法調用計數器統計的并不是方法調用的絕對次數,而是一個相對的執行頻率。也就是說,如果在一定的時間内,方法調用的次數不足以讓它送出給即時編譯器編譯,那麼這個方法的調用計數器就會被減少一半,這個過程就是方法調用計數器的熱度衰減。而這段時間,就是此方法統計的半衰周期。

進行熱度衰減的動作是在垃圾收集的時候順便進行的。我們可以通過調節虛拟機參數指定是否進行熱度衰減,或者調整它的半衰周期。

2.2.2 回邊計數器

用于統計一個方法中循環體代碼執行的次數。關于回邊計數器的閥值不同的模式有不同的計算方法,不在這裡進行讨論。

回邊計數器觸發JIT編譯的流程與方法調用計數器極其類似。

當解釋器遇到一條回邊指令(編譯原理的相關知識,可以粗略了解為循環)時,會先檢查将要執行的代碼片段是否存在被JIT編譯過的版本,如果存在,則優先使用編譯後的本地代碼來執行。如果不存在已經被編譯的版本,則将此方法的回邊計數器加1,然後判斷方法調用計數器與回邊計數器之和是否超過回邊調用計數器的閥值,如果已經超過閥值,那麼将會向即時編譯器送出一個OSR編譯請求,并且會把回邊計數器的值降低一些,以便繼續在解釋器中執行循環。在下次進行方法調用的時候,重複此流程。

流程圖如下:

深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

回邊計數器還有另外值得注意的地方:雖然編譯動作是由循環體所觸發,但是編譯器仍然會編譯整個方法,是以在回邊計數器溢出的時候,它還會把方法計數器的值也調整到溢出狀态。在下次進入該方法的時候就會執行标準編譯過程。

3. 編譯優化技術

Java 程式員有一個共識,以編譯方式執行本地代碼比解釋器方式執行更快,之是以會有這樣的共識,除去虛拟機介紹執行位元組碼時額外消耗時間的原因外,還有一個很重要的原因就是虛拟機設計團隊幾乎把對所有的優化措施都集中在了即時編譯器之中,是以一般來說,即時編譯器産生的代碼會比Javac産生的位元組碼更加優秀。下面我們将介紹一些HotSpot虛拟機的即時編譯器在生成代碼時采用的代碼優化技術。

3.1 優化技術概覽

深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術
深入了解JVM虛拟機(九):運作期優化與JIT編譯器1. JIT編譯器的引入2. 解釋器與編譯器并存的架構體系3. 編譯優化技術

3.2 方法内聯

即時編譯器在将位元組碼翻譯成本地機器碼之前,還會對位元組碼進行一系列的優化,是以JIT編譯器産生的本地代碼會比javac産生的位元組碼更加優秀。

JVM設計團隊采用的優化手段多不勝數,《深入了解Java虛拟機》一書中列舉了方法内聯、備援通路消除、複寫傳播、無用代碼消除、公共子表達式消除、數組邊界檢查消除、逃逸分析等優化手段。

方法内聯的重要性要高于其他優化措施,它的目的有二

  1. 去除方法調用的成本(建立棧幀)
  2. 為其他優化建立良好的基礎。

優化前的代碼:

static class B {
    int value;

    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    // ...do stuff...
    z = b.get();

    sum = y + z;
}
           

内聯後的代碼:

public void foo() {
    y = b.value;
    // ...do stuff...
    z = b.value;

    sum = y + z;
}
           

方法内聯看起來很簡單,但按照經典編譯原理的優化理論,大多數的Java方法都無法進行内聯。

還記得我們在前面講述的方法解析與分派嗎?在Java中,大多數的方法都是虛方法(虛方法的定義可以參見之前部落格),這就導緻了不到運作期JVM根本不知道實際調用的是哪一個方法版本。那麼在JIT編譯期(晚期優化還是發生在運作期之前)做内聯的時候也就無法确定應該使用的方法版本。

例如如果有ParentB與SubB兩個具有繼承關系的類,并且子類重寫了父類的get方法,那麼要執行父類的get方法還是執行子類的get方法,需要到運作期才能确定,JIT編譯期是無法得出結論的。

3.3 守護内聯與内聯緩存

為了解決虛方法的内聯問題,JVM設計團隊引入了一種“類型繼承關系分析(CHA)”的技術。它用于确定在目前已加載的類中,某個接口是否有多于一種的實作,某個類是否存在子類,子類是否為抽象類等資訊。

編譯器在進行内聯時,如果是非虛方法,那麼直接進行内聯就可以了,這時候的内聯是有穩定前提保障的。

如果遇到虛方法,則會向CHA查詢此方法在目前程式下是否有多個目标版本可供選擇,如果查詢結果隻有一個版本,那也可以進行内聯,不過這種内聯就屬于激進優化,需要預留一個“逃生門”(解釋器或C1編譯器),稱為守護内聯(Guarded Inlining)。如果程式的後續執行過程中,虛拟機一直沒有加載到會令這個方法的接收者的繼承關系發生變化的類,那這個内聯優化的代碼就可以一直使用下去。如果加載了導緻繼承關系發生變化的新類,那就需要抛棄已經編譯的代碼,退回到解釋狀态執行,或者重新進行編譯。(類檔案可動态加載即類關系可能在運作時被修改)

如果向CHA查詢出來的結果是有多個版本的目标方法可供選擇,則編譯器還将會進行最後一次努力,使用内聯緩存(Inline Cache)來完成方法内聯,這是一個建立在目标方法正常入口之前的緩存,它的工作原理大緻是:在未發生方法調用之前,内聯緩存狀态為空,當第一次調用發生後,緩存記錄下方法接收者的版本資訊,并且每次進行方法調用時都比較接收者版本,如果以後進來的每次調用的方法接收者版本都是一樣的,那這個内聯還可以一直用下去。如果發生了方法接收者不一緻的情況,就說明程式真正使用了虛方法的多态特性,這時才會取消内聯,查找虛方法表進行方法分派(動态分派 方法重寫 實際類型)。

是以說,在許多情況下虛拟機進行的内聯都是一種激進優化,激進優化的手段在高性能的商用虛拟機中很常見,除了内聯之外,對于出現機率很小(通過經驗資料或解釋器收集到的性能監控資訊确定機率大小)的隐式異常、使用機率很小的分支等都可以被激進優化“移除”,如果真的出現了小機率事件,這時才會從“逃生門”回到解釋狀态重新執行。

繼續閱讀