基本功 | Java即時編譯器原理解析及實踐

只愛宅zmy發表於2020-10-27

一、導讀

常見的編譯型語言如C++,通常會把程式碼直接編譯成CPU所能理解的機器碼來執行。而Java為了實現“一次編譯,處處執行”的特性,把編譯的過程分成兩部分,首先它會先由javac編譯成通用的中間形式——位元組碼,然後再由直譯器逐條將位元組碼解釋為機器碼來執行。所以在效能上,Java通常不如C++這類編譯型語言。
為了最佳化Java的效能 ,JVM在直譯器之外引入了即時(Just In Time)編譯器:當程式執行時,直譯器首先發揮作用,程式碼可以直接執行。隨著時間推移,即時編譯器逐漸發揮作用,把越來越多的程式碼編譯最佳化成原生程式碼,來獲取更高的執行效率。直譯器這時可以作為編譯執行的降級手段,在一些不可靠的編譯最佳化出現問題時,再切換回解釋執行,保證程式可以正常執行。
即時編譯器極大地提高了Java程式的執行速度,而且跟靜態編譯相比,即時編譯器可以選擇性地編譯熱點程式碼,省去了很多編譯時間,也節省很多的空間。目前,即時編譯器已經非常成熟了,在效能層面甚至可以和編譯型語言相比。不過在這個領域,大家依然在不斷探索如何結合不同的編譯方式,使用更加智慧的手段來提升程式的執行速度。

二、Java的執行過程

Java的執行過程整體可以分為兩個部分,第一步由javac將原始碼編譯成位元組碼,在這個過程中會進行詞法分析、語法分析、語義分析,編譯原理中這部分的編譯稱為前端編譯。接下來無需編譯直接逐條將位元組碼解釋執行,在解釋執行的過程中,虛擬機器同時對程式執行的資訊進行收集,在這些資訊的基礎上,編譯器會逐漸發揮作用,它會進行後端編譯——把位元組碼編譯成機器碼,但不是所有的程式碼都會被編譯,只有被JVM認定為的熱點程式碼,才可能被編譯。
怎麼樣才會被認為是熱點程式碼呢?JVM中會設定一個閾值,當方法或者程式碼塊的在一定時間內的呼叫次數超過這個閾值時就會被編譯,存入codeCache中。當下次執行時,再遇到這段程式碼,就會從codeCache中讀取機器碼,直接執行,以此來提升程式執行的效能。整體的執行過程大致如下圖所示:

1. JVM中的編譯器

JVM中整合了兩種編譯器,Client Compiler和Server Compiler,它們的作用也不同。Client Compiler注重啟動速度和區域性的最佳化,Server Compiler則更加關注全域性的最佳化,效能會更好,但由於會進行更多的全域性分析,所以啟動速度會變慢。兩種編譯器有著不同的應用場景,在虛擬機器中同時發揮作用。
Client Compiler
HotSpot VM帶有一個Client Compiler  C1編譯器。這種編譯器啟動速度快,但是效能比較Server Compiler來說會差一些。C1會做三件事:
  • 區域性簡單可靠的最佳化,比如位元組碼上進行的一些基礎最佳化,方法內聯、常量傳播等,放棄許多耗時較長的全域性最佳化。
  • 將位元組碼構造成高階中間表示(High-level Intermediate Representation,以下稱為HIR),HIR與平臺無關,通常採用圖結構,更適合JVM對程式進行最佳化。
  • 最後將HIR轉換成低階中間表示(Low-level Intermediate Representation,以下稱為LIR),在LIR的基礎上會進行暫存器分配、窺孔最佳化(區域性的最佳化方式,編譯器在一個基本塊或者多個基本塊中,針對已經生成的程式碼,結合CPU自己指令的特點,透過一些認為可能帶來效能提升的轉換規則或者透過整體的分析,進行指令轉換,來提升程式碼效能)等操作,最終生成機器碼。
Server Compiler
Server Compiler主要關注一些編譯耗時較長的全域性最佳化,甚至會還會根據程式執行的資訊進行一些不可靠的激進最佳化。這種編譯器的啟動時間長,適用於長時間執行的後臺程式,它的效能通常比Client Compiler高30%以上。目前,Hotspot虛擬機器中使用的Server Compiler有兩種:C2和Graal。
C2 Compiler
在Hotspot VM中,預設的Server Compiler是C2編譯器。
C2編譯器在進行編譯最佳化時,會使用一種控制流與資料流結合的圖資料結構,稱為Ideal Graph。 Ideal Graph表示當前程式的資料流向和指令間的依賴關係,依靠這種圖結構,某些最佳化步驟(尤其是涉及浮動程式碼塊的那些最佳化步驟)變得不那麼複雜。
Ideal Graph的構建是在解析位元組碼的時候,根據位元組碼中的指令向一個空的Graph中新增節點,Graph中的節點通常對應一個指令塊,每個指令塊包含多條相關聯的指令,JVM會利用一些最佳化技術對這些指令進行最佳化,比如Global Value Numbering、常量摺疊等,解析結束後,還會進行一些死程式碼剔除的操作。生成Ideal Graph後,會在這個基礎上結合收集的程式執行資訊來進行一些全域性的最佳化,這個階段如果JVM判斷此時沒有全域性最佳化的必要,就會跳過這部分最佳化。
無論是否進行全域性最佳化,Ideal Graph都會被轉化為一種更接近機器層面的MachNode Graph,最後編譯的機器碼就是從MachNode Graph中得的,生成機器碼前還會有一些包括暫存器分配、窺孔最佳化等操作。關於Ideal Graph和各種全域性的最佳化手段會在後面的章節詳細介紹。Server Compiler編譯最佳化的過程如下圖所示:

Graal Compiler
從JDK 9開始,Hotspot VM中整合了一種新的Server Compiler,Graal編譯器。相比C2編譯器,Graal有這樣幾種關鍵特性:
  • 前文有提到,JVM會在解釋執行的時候收集程式執行的各種資訊,然後編譯器會根據這些資訊進行一些基於預測的激進最佳化,比如分支預測,根據程式不同分支的執行機率,選擇性地編譯一些機率較大的分支。Graal比C2更加青睞這種最佳化,所以Graal的峰值效能通常要比C2更好。
  • 使用Java編寫,對於Java語言,尤其是新特性,比如Lambda、Stream等更加友好。
  • 更深層次的最佳化,比如虛擬函式的內聯、部分逃逸分析等。
Graal編譯器可以透過Java虛擬機器引數-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler啟用。當啟用時,它將替換掉HotSpot中的C2編譯器,並響應原本由C2負責的編譯請求。

2. 分層編譯

在Java 7以前,需要研發人員根據服務的性質去選擇編譯器。對於需要快速啟動的,或者一些不會長期執行的服務,可以採用編譯效率較高的C1,對應引數-client。長期執行的服務,或者對峰值效能有要求的後臺服務,可以採用峰值效能更好的C2,對應引數-server。Java 7開始引入了分層編譯的概念,它結合了C1和C2的優勢,追求啟動速度和峰值效能的一個平衡。分層編譯將JVM的執行狀態分為了五個層次。五個層級分別是:
  1. 解釋執行。
  2. 執行不帶profiling的C1程式碼。
  3. 執行僅帶方法呼叫次數以及迴圈回邊執行次數profiling的C1程式碼。
  4. 執行帶所有profiling的C1程式碼。
  5. 執行C2程式碼。
profiling就是收集能夠反映程式執行狀態的資料。其中最基本的統計資料就是方法的呼叫次數,以及迴圈回邊的執行次數。
通常情況下,C2程式碼的執行效率要比C1程式碼的高出30%以上。C1層執行的程式碼,按執行效率排序從高至低則是1層>2層>3層。這5個層次中,1層和4層都是終止狀態,當一個方法到達終止狀態後,只要編譯後的程式碼並沒有失效,那麼JVM就不會再次發出該方法的編譯請求的。服務實際執行時,JVM會根據服務執行情況,從解釋執行開始,選擇不同的編譯路徑,直到到達終止狀態。下圖中就列舉了幾種常見的編譯路徑:

  • 圖中第①條路徑,代表編譯的一般情況,熱點方法從解釋執行到被3層的C1編譯,最後被4層的C2編譯。
  • 如果方法比較小(比如Java服務中常見的getter/setter方法),3層的profiling沒有收集到有價值的資料,JVM就會斷定該方法對於C1程式碼和C2程式碼的執行效率相同,就會執行圖中第②條路徑。在這種情況下,JVM會在3層編譯之後,放棄進入C2編譯,直接選擇用1層的C1編譯執行。
  • 在C1忙碌的情況下,執行圖中第③條路徑,在解釋執行過程中對程式進行profiling ,根據資訊直接由第4層的C2編譯。
  • 前文提到C1中的執行效率是1層>2層>3層,第3層一般要比第2層慢35%以上,所以在C2忙碌的情況下,執行圖中第④條路徑。這時方法會被2層的C1編譯,然後再被3層的C1編譯,以減少方法在3層的執行時間。
  • 如果編譯器做了一些比較激進的最佳化,比如分支預測,在實際執行時發現預測出錯,這時就會進行反最佳化,重新進入解釋執行,圖中第⑤條執行路徑代表的就是反最佳化。
總的來說,C1的編譯速度更快,C2的編譯質量更高,分層編譯的不同編譯路徑,也就是JVM根據當前服務的執行情況來尋找當前服務的最佳平衡點的一個過程。從JDK 8開始,JVM預設開啟分層編譯。

3. 即時編譯的觸發

Java虛擬機器根據方法的呼叫次數以及迴圈回邊的執行次數來觸發即時編譯。迴圈回邊是一個控制流圖中的概念,程式中可以簡單理解為往回跳轉的指令,比如下面這段程式碼:
迴圈回邊
public void nlp(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}
上面這段程式碼經過編譯生成下面的位元組碼。其中,偏移量為18的位元組碼將往回跳至偏移量為4的位元組碼中。在解釋執行時,每當執行一次該指令,Java虛擬機器便會將該方法的迴圈回邊計數器加1。
位元組碼
public void nlp(java.lang.Object);
    Code:
       0: iconst_0
       1: istore_1
       2: iconst_0
       3: istore_2
       4: iload_2
       5: sipush        200
       8: if_icmpge     21
      11: iload_1
      12: iload_2
      13: iadd
      14: istore_1
      15: iinc          2, 1
      18: goto          4
      21: return
在即時編譯過程中,編譯器會識別迴圈的頭部和尾部。上面這段位元組碼中,迴圈體的頭部和尾部分別為偏移量為11的位元組碼和偏移量為15的位元組碼。編譯器將在迴圈體結尾增加迴圈回邊計數器的程式碼,來對迴圈進行計數。
當方法的呼叫次數和迴圈回邊的次數的和,超過由引數-XX:CompileThreshold指定的閾值時(使用C1時,預設值為1500;使用C2時,預設值為10000),就會觸發即時編譯。
開啟分層編譯的情況下,-XX:CompileThreshold引數設定的閾值將會失效,觸發編譯會由以下的條件來判斷:
  • 方法呼叫次數大於由引數-XX:TierXInvocationThreshold指定的閾值乘以係數。
  • 方法呼叫次數大於由引數-XX:TierXMINInvocationThreshold指定的閾值乘以係數,並且方法呼叫次數和迴圈回邊次數之和大於由引數-XX:TierXCompileThreshold指定的閾值乘以係數時。
分層編譯觸發條件公式
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s) 
i為呼叫次數,b是迴圈回邊次數
上述滿足其中一個條件就會觸發即時編譯,並且JVM會根據當前的編譯方法數以及編譯執行緒數動態調整係數s。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69983372/viewspace-2730213/,如需轉載,請註明出處,否則將追究法律責任。

相關文章