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

1、導讀

常見的編譯型語言如C++,一般會把代碼直接編譯成CPU所能理解的機器碼來運行。而Java爲了實現「一次編譯,到處運行」的特性,把編譯的過程分紅兩部分,首先它會先由javac編譯成通用的中間形式——字節碼,而後再由解釋器逐條將字節碼解釋爲機器碼來執行。因此在性能上,Java一般不如C++這類編譯型語言。前端

爲了優化Java的性能 ,JVM在解釋器以外引入了即時(Just In Time)編譯器:當程序運行時,解釋器首先發揮做用,代碼能夠直接執行。隨着時間推移,即時編譯器逐漸發揮做用,把愈來愈多的代碼編譯優化成本地代碼,來獲取更高的執行效率。解釋器這時能夠做爲編譯運行的降級手段,在一些不可靠的編譯優化出現問題時,再切換回解釋執行,保證程序能夠正常運行。java

即時編譯器極大地提升了Java程序的運行速度,並且跟靜態編譯相比,即時編譯器能夠選擇性地編譯熱點代碼,省去了不少編譯時間,也節省不少的空間。目前,即時編譯器已經很是成熟了,在性能層面甚至能夠和編譯型語言相比。不過在這個領域,你們依然在不斷探索如何結合不一樣的編譯方式,使用更加智能的手段來提高程序的運行速度。算法

2、Java的執行過程

Java的執行過程總體能夠分爲兩個部分,第一步由javac將源碼編譯成字節碼,在這個過程當中會進行詞法分析、語法分析、語義分析,編譯原理中這部分的編譯稱爲前端編譯。接下來無需編譯直接逐條將字節碼解釋執行,在解釋執行的過程當中,虛擬機同時對程序運行的信息進行收集,在這些信息的基礎上,編譯器會逐漸發揮做用,它會進行後端編譯——把字節碼編譯成機器碼,但不是全部的代碼都會被編譯,只有被JVM認定爲的熱點代碼,纔可能被編譯。數據庫

怎麼樣纔會被認爲是熱點代碼呢?JVM中會設置一個閾值,當方法或者代碼塊的在必定時間內的調用次數超過這個閾值時就會被編譯,存入codeCache中。當下次執行時,再遇到這段代碼,就會從codeCache中讀取機器碼,直接執行,以此來提高程序運行的性能。總體的執行過程大體以下圖所示:express

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。

3、編譯優化

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

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_一、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中。

4、實踐

即時編譯器狀況複雜,同時網絡上也不多有實戰經驗,如下是咱們團隊的一些調整經驗。

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會使用本身的垃圾回收,這是一種很原始的基於複製算法的垃圾回收,相比G一、ZGC這些優秀的新型垃圾回收器,它的性能並很差。同時GraalVM對Java的一些特性支持也不夠,好比基於配置的支持,好比反射就須要把全部須要反射的類配置一個JSON文件,在大量使用反射的服務,這樣的配置會是很大的工做量。咱們也在作這方面的調研。

5、總結

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

6、參考文獻

  • 《深刻理解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算法工程師,座標北京/上海,感興趣的同窗可投遞簡歷至:tech@meituan.com(郵件標題請註明:搜索與NLP部)。

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

相關文章
相關標籤/搜索