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

一、導讀

常見的編譯型語言如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。

三、編譯優化

即時編譯器會對正在運行的服務進行一系列的優化,包括字節碼解析過程中的分析,根據編譯過程中代碼的一些中間形式來做局部優化,還會根據程序依賴圖進行全局優化,最後纔會生成機器碼。

1. 中間表達形式(Intermediate Representation)

在編譯原理中,通常把編譯器分爲前端和後端,前端編譯經過詞法分析、語法分析、語義分析生成中間表達形式(Intermediate Representation,以下稱爲IR),後端會對IR進行優化,生成目標代碼。

Java字節碼就是一種IR,但是字節碼的結構複雜,字節碼這樣代碼形式的IR也不適合做全局的分析優化。現代編譯器一般採用圖結構的IR,靜態單賦值(Static Single Assignment,SSA)IR是目前比較常用的一種。這種IR的特點是每個變量只能被賦值一次,而且只有當變量被賦值之後才能使用。舉個例子:

SSA IR

Plain Text
{
  a = 1;
  a = 2;
  b = a;
}

上述代碼中我們可以輕易地發現a = 1的賦值是冗餘的,但是編譯器不能。傳統的編譯器需要藉助數據流分析,從後至前依次確認哪些變量的值被覆蓋掉。不過,如果藉助了SSA IR,編譯器則可以很容易識別冗餘賦值。

上面代碼的SSA IR形式的僞代碼可以表示爲:

SSA IR

Plain Text
{
  a_1 = 1;
  a_2 = 2;
  b_1 = a_2;
}

由於SSA IR中每個變量只能賦值一次,所以代碼中的a在SSA IR中會分成a_1、a_2兩個變量來賦值,這樣編譯器就可以很容易通過掃描這些變量來發現a_1的賦值後並沒有使用,賦值是冗餘的。

除此之外,SSA IR對其他優化方式也有很大的幫助,例如下面這個死代碼刪除(Dead Code Elimination)的例子:

DeadCodeElimination

public void DeadCodeElimination{
  int a = 2;
  int b = 0
  if(2 > 1){
    a = 1;
  } else{
    b = 2;
  }
  add(a,b)
}

可以得到SSA IR僞代碼:

DeadCodeElimination

a_1 = 2;
b_1 = 0
if true:
  a_2 = 1;
else
  b_2 = 2;
add(a,b)

編譯器通過執行字節碼可以發現 b_2 賦值後不會被使用,else分支不會被執行。經過死代碼刪除後就可以得到代碼:

DeadCodeElimination

public void DeadCodeElimination{
  int a = 1;
  int b = 0;
  add(a,b)
}

我們可以將編譯器的每一種優化看成一個圖優化算法,它接收一個IR圖,並輸出經過轉換後的IR圖。編譯器優化的過程就是一個個圖節點的優化串聯起來的。

C1中的中間表達形式

前文提及C1編譯器內部使用高級中間表達形式HIR,低級中間表達形式LIR來進行各種優化,這兩種IR都是SSA形式的。

HIR是由很多基本塊(Basic Block)組成的控制流圖結構,每個塊包含很多SSA形式的指令。基本塊的結構如下圖所示:

其中,predecessors表示前驅基本塊(由於前驅可能是多個,所以是BlockList結構,是多個BlockBegin組成的可擴容數組)。同樣,successors表示多個後繼基本塊BlockEnd。除了這兩部分就是主體塊,裏面包含程序執行的指令和一個next指針,指向下一個執行的主體塊。

從字節碼到HIR的構造最終調用的是GraphBuilder,GraphBuilder會遍歷字節碼構造所有代碼基本塊儲存爲一個鏈表結構,但是這個時候的基本塊只有BlockBegin,不包括具體的指令。第二步GraphBuilder會用一個ValueStack作爲操作數棧和局部變量表,模擬執行字節碼,構造出對應的HIR,填充之前空的基本塊,這裏給出簡單字節碼塊構造HIR的過程示例,如下所示:

字節碼構造HIR

        字節碼                     Local Value             operand stack              HIR
      5: iload_1                  [i1,i2]                 [i1]
      6: iload_2                  [i1,i2]                 [i1,i2]   
                                  ................................................   i3: i1 * i2
      7: imul                                   
      8: istore_3                 [i1,i2,i3]              [i3]

可以看出,當執行iload_1時,操作數棧壓入變量i1,執行iload_2時,操作數棧壓入變量i2,執行相乘指令imul時彈出棧頂兩個值,構造出HIR i3 : i1 * i2,生成的i3入棧。

C1編譯器優化大部分都是在HIR之上完成的。當優化完成之後它會將HIR轉化爲LIR,LIR和HIR類似,也是一種編譯器內部用到的IR,HIR通過優化消除一些中間節點就可以生成LIR,形式上更加簡化。

Sea-of-Nodes IR

C2編譯器中的Ideal Graph採用的是一種名爲Sea-of-Nodes中間表達形式,同樣也是SSA形式的。它最大特點是去除了變量的概念,直接採用值來進行運算。爲了方便理解,可以利用IR可視化工具Ideal Graph Visualizer(IGV),來展示具體的IR圖。比如下面這段代碼:

example

public static int foo(int count) {
  int sum = 0;
  for (int i = 0; i < count; i++) {
    sum += i;
  }
  return sum;
}

對應的IR圖如下所示:

圖中若干個順序執行的節點將被包含在同一個基本塊之中,如圖中的B0、B1等。B0基本塊中0號Start節點是方法入口,B3中21號Return節點是方法出口。紅色加粗線條爲控制流,藍色線條爲數據流,而其他顏色的線條則是特殊的控制流或數據流。被控制流邊所連接的是固定節點,其他的則是浮動節點(浮動節點指只要能滿足數據依賴關係,可以放在不同位置的節點,浮動節點變動的這個過程稱爲Schedule)。

這種圖具有輕量級的邊結構。 圖中的邊僅由指向另一個節點的指針表示。節點是Node子類的實例,帶有指定輸入邊的指針數組。這種表示的優點是改變節點的輸入邊很快,如果想要改變輸入邊,只要將指針指向Node,然後存入Node的指針數組就可以了。

依賴於這種圖結構,通過收集程序運行的信息,JVM可以通過Schedule那些浮動節點,從而獲得最好的編譯效果。

Phi And Region Nodes

Ideal Graph是SSA IR。 由於沒有變量的概念,這會帶來一個問題,就是不同執行路徑可能會對同一變量設置不同的值。例如下面這段代碼if語句的兩個分支中,分別返回5和6。此時,根據不同的執行路徑,所讀取到的值很有可能不同。

example

int test(int x) {
int a = 0;
  if(x == 1) {
    a = 5;
  } else {
    a = 6;
  }
  return a;
}

爲了解決這個問題,就引入一個Phi Nodes的概念,能夠根據不同的執行路徑選擇不同的值。於是,上面這段代碼可以表示爲下面這張圖:

Phi Nodes中保存不同路徑上包含的所有值,Region Nodes根據不同路徑的判斷條件,從Phi Nodes取得當前執行路徑中變量應該賦予的值,帶有Phi節點的SSA形式的僞代碼如下:

Phi Nodes

int test(int x) {
  a_1 = 0;
  if(x == 1){
    a_2 = 5;
  }else {
    a_3 = 6;
  }
  a_4 = Phi(a_2,a_3);
  return a_4;
}

Global Value Numbering

Global Value Numbering(GVN) 是一種因爲Sea-of-Nodes變得非常容易的優化技術 。

GVN是指爲每一個計算得到的值分配一個獨一無二的編號,然後遍歷指令尋找優化的機會,它可以發現並消除等價計算的優化技術。如果一段程序中出現了多次操作數相同的乘法,那麼即時編譯器可以將這些乘法合併爲一個,從而降低輸出機器碼的大小。如果這些乘法出現在同一執行路徑上,那麼GVN還將省下冗餘的乘法操作。在Sea-of-Nodes中,由於只存在值的概念,因此GVN算法將非常簡單:即時編譯器只需判斷該浮動節點是否與已存在的浮動節點的編號相同,所輸入的IR節點是否一致,便可以將這兩個浮動節點歸併成一個。比如下面這段代碼:

GVN

a = 1;
b = 2;
c = a + b;
d = a + b;
e = d;

GVN會利用Hash算法編號,計算a = 1時,得到編號1,計算b = 2時得到編號2,計算c = a + b時得到編號3,這些編號都會放入Hash表中保存,在計算d = a + b時,會發現a + b已經存在Hash表中,就不會再進行計算,直接從Hash表中取出計算過的值。最後的e = d也可以由Hash表中查到而進行復用。

可以將GVN理解爲在IR圖上的公共子表達式消除(Common Subexpression Elimination,CSE)。兩者區別在於,GVN直接比較值的相同與否,而CSE是藉助詞法分析器來判斷兩個表達式相同與否。

2.方法內聯

方法內聯,是指在編譯過程中遇到方法調用時,將目標方法的方法體納入編譯範圍之中,並取代原方法調用的優化手段。JIT大部分的優化都是在內聯的基礎上進行的,方法內聯是即時編譯器中非常重要的一環。

Java服務中存在大量getter/setter方法,如果沒有方法內聯,在調用getter/setter時,程序執行時需要保存當前方法的執行位置,創建並壓入用於getter/setter的棧幀、訪問字段、彈出棧幀,最後再恢復當前方法的執行。內聯了對 getter/setter的方法調用後,上述操作僅剩字段訪問。在C2編譯器 中,方法內聯在解析字節碼的過程中完成。當遇到方法調用字節碼時,編譯器將根據一些閾值參數決定是否需要內聯當前方法的調用。如果需要內聯,則開始解析目標方法的字節碼。比如下面這個示例(來源於網絡):

方法內聯的過程

public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
​
public static int foo(int value) {
    int result = bar(flag);
    if (result != 0) {
        return result;
    } else {
        return value;
    }
}
​
public static int bar(boolean flag) {
    return flag ? value0 : value1;
}

bar方法的IR圖:

內聯後的IR圖:

內聯不僅將被調用方法的IR圖節點複製到調用者方法的IR圖中,還要完成其他操作。

被調用方法的參數替換爲調用者方法進行方法調用時所傳入參數。上面例子中,將bar方法中的1號P(0)節點替換爲foo方法3號LoadField節點。

調用者方法的IR圖中,方法調用節點的數據依賴會變成被調用方法的返回。如果存在多個返回節點,會生成一個Phi節點,將這些返回值聚合起來,並作爲原方法調用節點的替換對象。圖中就是將8號==節點,以及12號Return節點連接到原5號Invoke節點的邊,然後指向新生成的24號Phi節點中。

如果被調用方法將拋出某種類型的異常,而調用者方法恰好有該異常類型的處理器,並且該異常處理器覆蓋這一方法調用,那麼即時編譯器需要將被調用方法拋出異常的路徑,與調用者方法的異常處理器相連接。

方法內聯的條件

編譯器的大部分優化都是在方法內聯的基礎上。所以一般來說,內聯的方法越多,生成代碼的執行效率越高。但是對於即時編譯器來說,內聯的方法越多,編譯時間也就越長,程序達到峯值性能的時刻也就比較晚。

可以通過虛擬機參數-XX:MaxInlineLevel調整內聯的層數,以及1層的直接遞歸調用(可以通過虛擬機參數-XX:MaxRecursiveInlineLevel調整)。一些常見的內聯相關的參數如下表所示:

虛函數內聯

內聯是JIT提升性能的主要手段,但是虛函數使得內聯是很難的,因爲在內聯階段並不知道他們會調用哪個方法。例如,我們有一個數據處理的接口,這個接口中的一個方法有三種實現add、sub和multi,JVM是通過保存虛函數表Virtual Method Table(以下稱爲VMT)存儲class對象中所有的虛函數,class的實例對象保存着一個VMT的指針,程序運行時首先加載實例對象,然後通過實例對象找到VMT,通過VMT找到對應方法的地址,所以虛函數的調用比直接指向方法地址的classic call性能上會差一些。很不幸的是,Java中所有非私有的成員函數的調用都是虛調用。

C2編譯器已經足夠智能,能夠檢測這種情況並會對虛調用進行優化。比如下面這段代碼例子:

virtual call

public class SimpleInliningTest
{
    public static void main(String[] args) throws InterruptedException {
        VirtualInvokeTest obj = new VirtualInvokeTest();
        VirtualInvoke1 obj1 = new VirtualInvoke1();
        for (int i = 0; i < 100000; i++) {
            invokeMethod(obj);
            invokeMethod(obj1);
        }
        Thread.sleep(1000);
    }
​
    public static void invokeMethod(VirtualInvokeTest obj) {
        obj.methodCall();
    }
​
    private static class VirtualInvokeTest {
        public void methodCall() {
            System.out.println("virtual call");
        }
    }
​
    private static class VirtualInvoke1 extends VirtualInvokeTest {
        @Override
        public void methodCall() {
            super.methodCall();
        }
    }
}

經過JIT編譯器優化後,進行反彙編得到下面這段彙編代碼:

 0x0000000113369d37: callq  0x00000001132950a0  ; OopMap{off=476}
                                                ;*invokevirtual methodCall  //代表虛調用
                                                ; - SimpleInliningTest::invokeMethod@1 (line 18)
                                                ;   {optimized virtual_call}  //虛調用已經被優化

可以看到JIT對methodCall方法進行了虛調用優化optimized virtual_call。經過優化後的方法可以被內聯。但是C2編譯器的能力有限,對於多個實現方法的虛調用就“無能爲力”了。

比如下面這段代碼,我們增加一個實現:

多實現的虛調用

public class SimpleInliningTest
{
    public static void main(String[] args) throws InterruptedException {
        VirtualInvokeTest obj = new VirtualInvokeTest();
        VirtualInvoke1 obj1 = new VirtualInvoke1();
        VirtualInvoke2 obj2 = new VirtualInvoke2();
        for (int i = 0; i < 100000; i++) {
            invokeMethod(obj);
            invokeMethod(obj1);
        invokeMethod(obj2);
        }
        Thread.sleep(1000);
    }
​
    public static void invokeMethod(VirtualInvokeTest obj) {
        obj.methodCall();
    }
​
    private static class VirtualInvokeTest {
        public void methodCall() {
            System.out.println("virtual call");
        }
    }
​
    private static class VirtualInvoke1 extends VirtualInvokeTest {
        @Override
        public void methodCall() {
            super.methodCall();
        }
    }
    private static class VirtualInvoke2 extends VirtualInvokeTest {
        @Override
        public void methodCall() {
            super.methodCall();
        }
    }
}

經過反編譯得到下面的彙編代碼:

代碼塊

 0x000000011f5f0a37: callq  0x000000011f4fd2e0  ; OopMap{off=28}
                                                ;*invokevirtual methodCall  //代表虛調用
                                                ; - SimpleInliningTest::invokeMethod@1 (line 20)
                                                ;   {virtual_call}  //虛調用未被優化

可以看到多個實現的虛調用未被優化,依然是virtual_call。

Graal編譯器針對這種情況,會去收集這部分執行的信息,比如在一段時間,發現前面的接口方法的調用add和sub是各佔50%的機率,那麼JVM就會在每次運行時,遇到add就把add內聯進來,遇到sub的情況再把sub函數內聯進來,這樣這兩個路徑的執行效率就會提升。在後續如果遇到其他不常見的情況,JVM就會進行去優化的操作,在那個位置做標記,再遇到這種情況時切換回解釋執行。

3. 逃逸分析

逃逸分析是“一種確定指針動態範圍的靜態分析,它可以分析在程序的哪些地方可以訪問到指針”。Java虛擬機的即時編譯器會對新建的對象進行逃逸分析,判斷對象是否逃逸出線程或者方法。即時編譯器判斷對象是否逃逸的依據有兩種:

  1. 對象是否被存入堆中(靜態字段或者堆中對象的實例字段),一旦對象被存入堆中,其他線程便能獲得該對象的引用,即時編譯器就無法追蹤所有使用該對象的代碼位置。
  2. 對象是否被傳入未知代碼中,即時編譯器會將未被內聯的代碼當成未知代碼,因爲它無法確認該方法調用會不會將調用者或所傳入的參數存儲至堆中,這種情況,可以直接認爲方法調用的調用者以及參數是逃逸的。

逃逸分析通常是在方法內聯的基礎上進行的,即時編譯器可以根據逃逸分析的結果進行諸如鎖消除、棧上分配以及標量替換的優化。下面這段代碼的就是對象未逃逸的例子:

pulbic class Example{
    public static void main(String[] args) {
      example();
    }
    public static void example() {
      Foo foo = new Foo();
      Bar bar = new Bar();
      bar.setFoo(foo);
    }
  }
​
  class Foo {}
​
  class Bar {
    private Foo foo;
    public void setFoo(Foo foo) {
      this.foo = foo;
    }
  }
}

在這個例子中,創建了兩個對象foo和bar,其中一個作爲另一個方法的參數提供。該方法setFoo()存儲對收到的Foo對象的引用。如果Bar對象在堆上,則對Foo的引用將逃逸。但是在這種情況下,編譯器可以通過逃逸分析確定Bar對象本身不會對逃逸出example()的調用。這意味着對Foo的引用也不能逃逸。因此,編譯器可以安全地在棧上分配兩個對象。

鎖消除

在學習Java併發編程時會了解鎖消除,而鎖消除就是在逃逸分析的基礎上進行的。

如果即時編譯器能夠證明鎖對象不逃逸,那麼對該鎖對象的加鎖、解鎖操作沒就有意義。因爲線程並不能獲得該鎖對象。在這種情況下,即時編譯器會消除對該不逃逸鎖對象的加鎖、解鎖操作。實際上,編譯器僅需證明鎖對象不逃逸出線程,便可以進行鎖消除。由於Java虛擬機即時編譯的限制,上述條件被強化爲證明鎖對象不逃逸出當前編譯的方法。不過,基於逃逸分析的鎖消除實際上並不多見。

棧上分配

我們都知道Java的對象是在堆上分配的,而堆是對所有對象可見的。同時,JVM需要對所分配的堆內存進行管理,並且在對象不再被引用時回收其所佔據的內存。如果逃逸分析能夠證明某些新建的對象不逃逸,那麼JVM完全可以將其分配至棧上,並且在new語句所在的方法退出時,通過彈出當前方法的棧楨來自動回收所分配的內存空間。這樣一來,我們便無須藉助垃圾回收器來處理不再被引用的對象。不過Hotspot虛擬機,並沒有進行實際的棧上分配,而是使用了標量替換這一技術。所謂的標量,就是僅能存儲一個值的變量,比如Java代碼中的基本類型。與之相反,聚合量則可能同時存儲多個值,其中一個典型的例子便是Java的對象。編譯器會在方法內將未逃逸的聚合量分解成多個標量,以此來減少堆上分配。下面是一個標量替換的例子:

標量替換

public class Example{
  @AllArgsConstructor
  class Cat{
    int age;
    int weight;
  }
  public static void example(){
    Cat cat = new Cat(1,10);
    addAgeAndWeight(cat.age,Cat.weight);
  }
}

經過逃逸分析,cat對象未逃逸出example()的調用,因此可以對聚合量cat進行分解,得到兩個標量age和weight,進行標量替換後的僞代碼:

public class Example{
  @AllArgsConstructor
  class Cat{
    int age;
    int weight;
  }
  public static void example(){
    int age = 1;
    int weight = 10;
    addAgeAndWeight(age,weight);
  }
}

部分逃逸分析

部分逃逸分析也是Graal對於概率預測的應用。通常來說,如果發現一個對象逃逸出了方法或者線程,JVM就不會去進行優化,但是Graal編譯器依然會去分析當前程序的執行路徑,它會在逃逸分析基礎上收集、判斷哪些路徑上對象會逃逸,哪些不會。然後根據這些信息,在不會逃逸的路徑上進行鎖消除、棧上分配這些優化手段。

4. Loop Transformations

在文章中介紹C2編譯器的部分有提及到,C2編譯器在構建Ideal Graph後會進行很多的全局優化,其中就包括對循環的轉換,最重要的兩種轉換就是循環展開和循環分離。

循環展開

循環展開是一種循環轉換技術,它試圖以犧牲程序二進制碼大小爲代價來優化程序的執行速度,是一種用空間換時間的優化手段。

循環展開通過減少或消除控制程序循環的指令,來減少計算開銷,這種開銷包括增加指向數組中下一個索引或者指令的指針算數等。如果編譯器可以提前計算這些索引,並且構建到機器代碼指令中,那麼程序運行時就可以不必進行這種計算。也就是說有些循環可以寫成一些重複獨立的代碼。比如下面這個循環:

循環展開

public void loopRolling(){
  for(int i = 0;i<200;i++){
    delete(i);  
  }
}

上面的代碼需要循環刪除200次,通過循環展開可以得到下面這段代碼:

循環展開

public void loopRolling(){
  for(int i = 0;i<200;i+=5){
    delete(i);
    delete(i+1);
    delete(i+2);
    delete(i+3);
    delete(i+4);
  }
}

這樣展開就可以減少循環的次數,每次循環內的計算也可以利用CPU的流水線提升效率。當然這只是一個示例,實際進行展開時,JVM會去評估展開帶來的收益,再決定是否進行展開。

循環分離

循環分離也是循環轉換的一種手段。它把循環中一次或多次的特殊迭代分離出來,在循環外執行。舉個例子,下面這段代碼:

循環分離

int a = 10;
for(int i = 0;i<10;i++){
  b[i] = x[i] + x[a];
  a = i;
}

可以看出這段代碼除了第一次循環a = 10以外,其他的情況a都等於i-1。所以可以把特殊情況分離出去,變成下面這段代碼:

循環分離

b[0] = x[0] + 10;
for(int i = 1;i<10;i++){
  b[i] = x[i] + x[i-1];
}

這種等效的轉換消除了在循環中對a變量的需求,從而減少了開銷。

5. 窺孔優化與寄存器分配

前文提到的窺孔優化是優化的最後一步,這之後就會程序就會轉換成機器碼,窺孔優化就是將編譯器所生成的中間代碼(或目標代碼)中相鄰指令,將其中的某些組合替換爲效率更高的指令組,常見的比如強度削減、常數合併等,看下面這個例子就是一個強度削減的例子:

強度削減

y1=x1*3  經過強度削減後得到  y1=(x1<<1)+x1

編譯器使用移位和加法削減乘法的強度,使用更高效率的指令組。

寄存器分配也是一種編譯的優化手段,在C2編譯器中普遍的使用。它是通過把頻繁使用的變量保存在寄存器中,CPU訪問寄存器的速度比內存快得多,可以提升程序的運行速度。

寄存器分配和窺孔優化是程序優化的最後一步。經過寄存器分配和窺孔優化之後,程序就會被轉換成機器碼保存在codeCache中。

四、實踐

即時編譯器情況複雜,同時網絡上也很少有實戰經驗,以下是我們團隊的一些調整經驗。

1. 編譯相關的重* 要參數

  • -XX:+TieredCompilation:開啓分層編譯,JDK8之後默認開啓
  • -XX:+CICompilerCount=N:編譯線程數,設置數量後,JVM會自動分配線程數,C1:C2 = 1:2
  • -XX:TierXBackEdgeThreshold:OSR編譯的閾值
  • -XX:TierXMinInvocationThreshold:開啓分層編譯後各層調用的閾值
  • -XX:TierXCompileThreshold:開啓分層編譯後的編譯閾值
  • -XX:ReservedCodeCacheSize:codeCache最大大小
  • -XX:InitialCodeCacheSize:codeCache初始大小

-XX:TierXMinInvocationThreshold是開啓分層編譯的情況下,觸發編譯的閾值參數,當方法調用次數大於由參數-XX:TierXInvocationThreshold指定的閾值乘以係數,或者當方法調用次數大於由參數-XX:TierXMINInvocationThreshold指定的閾值乘以係數,並且方法調用次數和循環回邊次數之和大於由參數-XX:TierXCompileThreshold指定的閾值乘以係數時,便會觸發X層即時編譯。分層編譯開啓下會乘以一個係數,係數根據當前編譯的方法和編譯線程數確定,降低閾值可以提升編譯方法數,一些常用但是不能編譯的方法可以編譯優化提升性能。

由於編譯情況複雜,JVM也會動態調整相關的閾值來保證JVM的性能,所以不建議手動調整編譯相關的參數。除非一些特定的Case,比如codeCache滿了停止了編譯,可以適當增加codeCache大小,或者一些非常常用的方法,未被內聯到,拖累了性能,可以調整內斂層數或者內聯方法的大小來解決。

2. 通過JITwatch分析編譯日誌

通過增加-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining -XX:+PrintCodeCache -XX:+PrintCodeCacheOnCompilation -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=LogPath參數可以輸出編譯、內聯、codeCache信息到文件。但是打印的編譯日誌多且複雜很難直接從其中得到信息,可以使用JITwatch的工具來分析編譯日誌。JITwatch首頁的Open Log選中日誌文件,點擊Start就可以開始分析日誌。

如上圖所示,區域1中是整個項目Java Class包括引入的第三方依賴;區域2是功能區Timeline以圖形的形式展示JIT編譯的時間軸,Histo是直方圖展示一些信息,TopList裏面是編譯中產生的一些對象和數據的排序,Cache是空閒codeCache空間,NMethod是Native方法,Threads是JIT編譯的線程;區域3是JITwatch對日誌分析結果的展示,其中Suggestions中會給出一些代碼優化的建議,舉個例子,如下圖中:

我們可以看到在調用ZipInputStream的read方法時,因爲該方法沒有被標記爲熱點方法,同時又“太大了”,導致無法被內聯到。使用-XX:CompileCommand中inline指令可以強制方法進行內聯,不過還是建議謹慎使用,除非確定某個方法內聯會帶來不少的性能提升,否則不建議使用,並且過多使用對編譯線程和codeCache都會帶來不小的壓力。

區域3中的-Allocs和-Locks逃逸分析後JVM對代碼做的優化,包括棧上分配、鎖消除等。

3. 使用Graal編譯器

由於JVM會去根據當前的編譯方法數和編譯線程數對編譯閾值進行動態的調整,所以實際服務中對這一部分的調整空間是不大的,JVM做的已經足夠多了。

爲了提升性能,在服務中嘗試了最新的Graal編譯器。只需要使用-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler就可以啓動Graal編譯器來代替C2編譯器,並且響應C2的編譯請求,不過要注意的是,Graal編譯器與ZGC不兼容,只能與G1搭配使用。

前文有提到過,Graal是一個用Java寫的即時編譯器,它從Java 9開始便被集成自JDK中,作爲實驗性質的即時編譯器。Graal編譯器就是脫身於GraalVM,GraalVM是一個高性能的、支持多種編程語言的執行環境。它既可以在傳統的 OpenJDK上運行,也可以通過AOT(Ahead-Of-Time)編譯成可執行文件單獨運行,甚至可以集成至數據庫中運行。

前文提到過數次,Graal的優化都基於某種假設(Assumption)。當假設出錯的情況下,Java虛擬機會藉助去優化(Deoptimization)這項機制,從執行即時編譯器生成的機器碼切換回解釋執行,在必要情況下,它甚至會廢棄這份機器碼,並在重新收集程序profile之後,再進行編譯。

這些中激進的手段使得Graal的峯值性能要好於C2,而且在Scale、Ruby這種語言Graal表現更加出色,Twitter目前已經在服務中大量的使用Graal來提升性能,企業版的GraalVM使得Twitter服務性能提升了22%。

使用Graal編譯器後性能表現

在我們的線上服務中,啓用Graal編譯後,TP9999從60ms -> 50ms ,下降10ms,下降幅度達16.7%。

運行過程中的峯值性能會更高。可以看出對於該服務,Graal編譯器帶來了一定的性能提升。

Graal編譯器的問題

Graal編譯器的優化方式更加激進,因此在啓動時會進行更多的編譯,Graal編譯器本身也需要被即時編譯,所以服務剛啓動時性能會比較差。

考慮的解決辦法:JDK 9開始提供工具jaotc,同時GraalVM的Native Image都是可以通過靜態編譯,極大地提升服務的啓動速度的方式,但是GraalVM會使用自己的垃圾回收,這是一種很原始的基於複製算法的垃圾回收,相比G1、ZGC這些優秀的新型垃圾回收器,它的性能並不好。同時GraalVM對Java的一些特性支持也不夠,比如基於配置的支持,比如反射就需要把所有需要反射的類配置一個JSON文件,在大量使用反射的服務,這樣的配置會是很大的工作量。我們也在做這方面的調研。

五、總結

本文主要介紹了JIT即時編譯的原理以及在美團一些實踐的經驗,還有最前沿的即時編譯器的使用效果。作爲一項解釋型語言中提升性能的技術,JIT已經比較成熟了,在很多語言中都有使用。對於Java服務,JVM本身已經做了足夠多,但是我們還應該不斷深入瞭解JIT的優化原理和最新的編譯技術,從而彌補JIT的劣勢,提升Java服務的性能,不斷追求卓越。

六、參考文獻

  • 《深入理解Java虛擬機》
  • 《Proceedings of the Java™ Virtual Machine Research and Technology Symposium》Monterey, California, USA April 23–24, 2001
  • 《Visualization of Program Dependence Graphs》 Thomas Würthinger
  • 《深入拆解Java虛擬機》 鄭宇迪
  • JIT的Profile神器JITWatch

作者簡介

珩智,昊天,薛超,均來自美團AI平臺/搜索與NLP部。

招聘信息

美團搜索與NLP部,長期招聘搜索、對話、NLP算法工程師,座標北京/上海,感興趣的同學可投遞簡歷至:[email protected](郵件標題請註明:搜索與NLP部)。

想閱讀更多技術文章,請關注美團技術團隊(meituantech)官方微信公衆號。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章