溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

Java中即時(shí)編譯器的原理是什么

發(fā)布時(shí)間:2021-07-01 15:37:49 來源:億速云 閱讀:144 作者:Leah 欄目:編程語言

這期內(nèi)容當(dāng)中小編將會(huì)給大家?guī)碛嘘P(guān)Java中即時(shí)編譯器的原理是什么,文章內(nèi)容豐富且以專業(yè)的角度為大家分析和敘述,閱讀完這篇文章希望大家可以有所收獲。

一、導(dǎo)讀

常見的編譯型語言如C++,通常會(huì)把代碼直接編譯成CPU所能理解的機(jī)器碼來運(yùn)行。而Java為了實(shí)現(xiàn)“一次編譯,處處運(yùn)行”的特性,把編譯的過程分成兩部分,首先它會(huì)先由javac編譯成通用的中間形式——字節(jié)碼,然后再由解釋器逐條將字節(jié)碼解釋為機(jī)器碼來執(zhí)行。所以在性能上,Java通常不如C++這類編譯型語言。

為了優(yōu)化Java的性能 ,JVM在解釋器之外引入了即時(shí)(Just In Time)編譯器:當(dāng)程序運(yùn)行時(shí),解釋器首先發(fā)揮作用,代碼可以直接執(zhí)行。隨著時(shí)間推移,即時(shí)編譯器逐漸發(fā)揮作用,把越來越多的代碼編譯優(yōu)化成本地代碼,來獲取更高的執(zhí)行效率。解釋器這時(shí)可以作為編譯運(yùn)行的降級手段,在一些不可靠的編譯優(yōu)化出現(xiàn)問題時(shí),再切換回解釋執(zhí)行,保證程序可以正常運(yùn)行。

即時(shí)編譯器極大地提高了Java程序的運(yùn)行速度,而且跟靜態(tài)編譯相比,即時(shí)編譯器可以選擇性地編譯熱點(diǎn)代碼,省去了很多編譯時(shí)間,也節(jié)省很多的空間。目前,即時(shí)編譯器已經(jīng)非常成熟了,在性能層面甚至可以和編譯型語言相比。不過在這個(gè)領(lǐng)域,大家依然在不斷探索如何結(jié)合不同的編譯方式,使用更加智能的手段來提升程序的運(yùn)行速度。

二、Java的執(zhí)行過程

Java的執(zhí)行過程整體可以分為兩個(gè)部分,第一步由javac將源碼編譯成字節(jié)碼,在這個(gè)過程中會(huì)進(jìn)行詞法分析、語法分析、語義分析,編譯原理中這部分的編譯稱為前端編譯。接下來無需編譯直接逐條將字節(jié)碼解釋執(zhí)行,在解釋執(zhí)行的過程中,虛擬機(jī)同時(shí)對程序運(yùn)行的信息進(jìn)行收集,在這些信息的基礎(chǔ)上,編譯器會(huì)逐漸發(fā)揮作用,它會(huì)進(jìn)行后端編譯——把字節(jié)碼編譯成機(jī)器碼,但不是所有的代碼都會(huì)被編譯,只有被JVM認(rèn)定為的熱點(diǎn)代碼,才可能被編譯。

怎么樣才會(huì)被認(rèn)為是熱點(diǎn)代碼呢?JVM中會(huì)設(shè)置一個(gè)閾值,當(dāng)方法或者代碼塊的在一定時(shí)間內(nèi)的調(diào)用次數(shù)超過這個(gè)閾值時(shí)就會(huì)被編譯,存入codeCache中。當(dāng)下次執(zhí)行時(shí),再遇到這段代碼,就會(huì)從codeCache中讀取機(jī)器碼,直接執(zhí)行,以此來提升程序運(yùn)行的性能。整體的執(zhí)行過程大致如下圖所示:

Java中即時(shí)編譯器的原理是什么

1. JVM中的編譯器

JVM中集成了兩種編譯器,Client Compiler和Server Compiler,它們的作用也不同。Client Compiler注重啟動(dòng)速度和局部的優(yōu)化,Server Compiler則更加關(guān)注全局的優(yōu)化,性能會(huì)更好,但由于會(huì)進(jìn)行更多的全局分析,所以啟動(dòng)速度會(huì)變慢。兩種編譯器有著不同的應(yīng)用場景,在虛擬機(jī)中同時(shí)發(fā)揮作用。

Client Compiler

HotSpot VM帶有一個(gè)Client Compiler C1編譯器。這種編譯器啟動(dòng)速度快,但是性能比較Server Compiler來說會(huì)差一些。C1會(huì)做三件事:

  • 局部簡單可靠的優(yōu)化,比如字節(jié)碼上進(jìn)行的一些基礎(chǔ)優(yōu)化,方法內(nèi)聯(lián)、常量傳播等,放棄許多耗時(shí)較長的全局優(yōu)化。

  • 將字節(jié)碼構(gòu)造成高級中間表示(High-level Intermediate Representation,以下稱為HIR),HIR與平臺無關(guān),通常采用圖結(jié)構(gòu),更適合JVM對程序進(jìn)行優(yōu)化。

  • 最后將HIR轉(zhuǎn)換成低級中間表示(Low-level Intermediate Representation,以下稱為LIR),在LIR的基礎(chǔ)上會(huì)進(jìn)行寄存器分配、窺孔優(yōu)化(局部的優(yōu)化方式,編譯器在一個(gè)基本塊或者多個(gè)基本塊中,針對已經(jīng)生成的代碼,結(jié)合CPU自己指令的特點(diǎn),通過一些認(rèn)為可能帶來性能提升的轉(zhuǎn)換規(guī)則或者通過整體的分析,進(jìn)行指令轉(zhuǎn)換,來提升代碼性能)等操作,最終生成機(jī)器碼。

Server Compiler

Server Compiler主要關(guān)注一些編譯耗時(shí)較長的全局優(yōu)化,甚至?xí)€會(huì)根據(jù)程序運(yùn)行的信息進(jìn)行一些不可靠的激進(jìn)優(yōu)化。這種編譯器的啟動(dòng)時(shí)間長,適用于長時(shí)間運(yùn)行的后臺程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虛擬機(jī)中使用的Server Compiler有兩種:C2和Graal。

C2 Compiler

在Hotspot VM中,默認(rèn)的Server Compiler是C2編譯器。

C2編譯器在進(jìn)行編譯優(yōu)化時(shí),會(huì)使用一種控制流與數(shù)據(jù)流結(jié)合的圖數(shù)據(jù)結(jié)構(gòu),稱為Ideal Graph。 Ideal Graph表示當(dāng)前程序的數(shù)據(jù)流向和指令間的依賴關(guān)系,依靠這種圖結(jié)構(gòu),某些優(yōu)化步驟(尤其是涉及浮動(dòng)代碼塊的那些優(yōu)化步驟)變得不那么復(fù)雜。

Ideal Graph的構(gòu)建是在解析字節(jié)碼的時(shí)候,根據(jù)字節(jié)碼中的指令向一個(gè)空的Graph中添加節(jié)點(diǎn),Graph中的節(jié)點(diǎn)通常對應(yīng)一個(gè)指令塊,每個(gè)指令塊包含多條相關(guān)聯(lián)的指令,JVM會(huì)利用一些優(yōu)化技術(shù)對這些指令進(jìn)行優(yōu)化,比如Global Value Numbering、常量折疊等,解析結(jié)束后,還會(huì)進(jìn)行一些死代碼剔除的操作。生成Ideal Graph后,會(huì)在這個(gè)基礎(chǔ)上結(jié)合收集的程序運(yùn)行信息來進(jìn)行一些全局的優(yōu)化,這個(gè)階段如果JVM判斷此時(shí)沒有全局優(yōu)化的必要,就會(huì)跳過這部分優(yōu)化。

無論是否進(jìn)行全局優(yōu)化,Ideal Graph都會(huì)被轉(zhuǎn)化為一種更接近機(jī)器層面的MachNode Graph,最后編譯的機(jī)器碼就是從MachNode Graph中得的,生成機(jī)器碼前還會(huì)有一些包括寄存器分配、窺孔優(yōu)化等操作。關(guān)于Ideal Graph和各種全局的優(yōu)化手段會(huì)在后面的章節(jié)詳細(xì)介紹。Server Compiler編譯優(yōu)化的過程如下圖所示:

Java中即時(shí)編譯器的原理是什么

Graal Compiler

從JDK 9開始,Hotspot VM中集成了一種新的Server Compiler,Graal編譯器。相比C2編譯器,Graal有這樣幾種關(guān)鍵特性:

  • 前文有提到,JVM會(huì)在解釋執(zhí)行的時(shí)候收集程序運(yùn)行的各種信息,然后編譯器會(huì)根據(jù)這些信息進(jìn)行一些基于預(yù)測的激進(jìn)優(yōu)化,比如分支預(yù)測,根據(jù)程序不同分支的運(yùn)行概率,選擇性地編譯一些概率較大的分支。Graal比C2更加青睞這種優(yōu)化,所以Graal的峰值性能通常要比C2更好。

  • 使用Java編寫,對于Java語言,尤其是新特性,比如Lambda、Stream等更加友好。

  • 更深層次的優(yōu)化,比如虛函數(shù)的內(nèi)聯(lián)、部分逃逸分析等。

Graal編譯器可以通過Java虛擬機(jī)參數(shù)-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler啟用。當(dāng)啟用時(shí),它將替換掉HotSpot中的C2編譯器,并響應(yīng)原本由C2負(fù)責(zé)的編譯請求。

2. 分層編譯

在Java 7以前,需要研發(fā)人員根據(jù)服務(wù)的性質(zhì)去選擇編譯器。對于需要快速啟動(dòng)的,或者一些不會(huì)長期運(yùn)行的服務(wù),可以采用編譯效率較高的C1,對應(yīng)參數(shù)-client。長期運(yùn)行的服務(wù),或者對峰值性能有要求的后臺服務(wù),可以采用峰值性能更好的C2,對應(yīng)參數(shù)-server。Java 7開始引入了分層編譯的概念,它結(jié)合了C1和C2的優(yōu)勢,追求啟動(dòng)速度和峰值性能的一個(gè)平衡。分層編譯將JVM的執(zhí)行狀態(tài)分為了五個(gè)層次。五個(gè)層級分別是:

  1. 解釋執(zhí)行。

  2. 執(zhí)行不帶profiling的C1代碼。

  3. 執(zhí)行僅帶方法調(diào)用次數(shù)以及循環(huán)回邊執(zhí)行次數(shù)profiling的C1代碼。

  4. 執(zhí)行帶所有profiling的C1代碼。

  5. 執(zhí)行C2代碼。

profiling就是收集能夠反映程序執(zhí)行狀態(tài)的數(shù)據(jù)。其中最基本的統(tǒng)計(jì)數(shù)據(jù)就是方法的調(diào)用次數(shù),以及循環(huán)回邊的執(zhí)行次數(shù)。

通常情況下,C2代碼的執(zhí)行效率要比C1代碼的高出30%以上。C1層執(zhí)行的代碼,按執(zhí)行效率排序從高至低則是1層>2層>3層。這5個(gè)層次中,1層和4層都是終止?fàn)顟B(tài),當(dāng)一個(gè)方法到達(dá)終止?fàn)顟B(tài)后,只要編譯后的代碼并沒有失效,那么JVM就不會(huì)再次發(fā)出該方法的編譯請求的。服務(wù)實(shí)際運(yùn)行時(shí),JVM會(huì)根據(jù)服務(wù)運(yùn)行情況,從解釋執(zhí)行開始,選擇不同的編譯路徑,直到到達(dá)終止?fàn)顟B(tài)。下圖中就列舉了幾種常見的編譯路徑:

Java中即時(shí)編譯器的原理是什么

  • 圖中第①條路徑,代表編譯的一般情況,熱點(diǎn)方法從解釋執(zhí)行到被3層的C1編譯,最后被4層的C2編譯。

  • 如果方法比較小(比如Java服務(wù)中常見的getter/setter方法),3層的profiling沒有收集到有價(jià)值的數(shù)據(jù),JVM就會(huì)斷定該方法對于C1代碼和C2代碼的執(zhí)行效率相同,就會(huì)執(zhí)行圖中第②條路徑。在這種情況下,JVM會(huì)在3層編譯之后,放棄進(jìn)入C2編譯,直接選擇用1層的C1編譯運(yùn)行。

  • 在C1忙碌的情況下,執(zhí)行圖中第③條路徑,在解釋執(zhí)行過程中對程序進(jìn)行profiling ,根據(jù)信息直接由第4層的C2編譯。

  • 前文提到C1中的執(zhí)行效率是1層>2層>3層,第3層一般要比第2層慢35%以上,所以在C2忙碌的情況下,執(zhí)行圖中第④條路徑。這時(shí)方法會(huì)被2層的C1編譯,然后再被3層的C1編譯,以減少方法在3層的執(zhí)行時(shí)間。

  • 如果編譯器做了一些比較激進(jìn)的優(yōu)化,比如分支預(yù)測,在實(shí)際運(yùn)行時(shí)發(fā)現(xiàn)預(yù)測出錯(cuò),這時(shí)就會(huì)進(jìn)行反優(yōu)化,重新進(jìn)入解釋執(zhí)行,圖中第⑤條執(zhí)行路徑代表的就是反優(yōu)化。

總的來說,C1的編譯速度更快,C2的編譯質(zhì)量更高,分層編譯的不同編譯路徑,也就是JVM根據(jù)當(dāng)前服務(wù)的運(yùn)行情況來尋找當(dāng)前服務(wù)的最佳平衡點(diǎn)的一個(gè)過程。從JDK 8開始,JVM默認(rèn)開啟分層編譯。

3. 即時(shí)編譯的觸發(fā)

Java虛擬機(jī)根據(jù)方法的調(diào)用次數(shù)以及循環(huán)回邊的執(zhí)行次數(shù)來觸發(fā)即時(shí)編譯。循環(huán)回邊是一個(gè)控制流圖中的概念,程序中可以簡單理解為往回跳轉(zhuǎn)的指令,比如下面這段代碼:

循環(huán)回邊

public void nlp(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}

上面這段代碼經(jīng)過編譯生成下面的字節(jié)碼。其中,偏移量為18的字節(jié)碼將往回跳至偏移量為4的字節(jié)碼中。在解釋執(zhí)行時(shí),每當(dāng)運(yùn)行一次該指令,Java虛擬機(jī)便會(huì)將該方法的循環(huán)回邊計(jì)數(shù)器加1。

字節(jié)碼

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

在即時(shí)編譯過程中,編譯器會(huì)識別循環(huán)的頭部和尾部。上面這段字節(jié)碼中,循環(huán)體的頭部和尾部分別為偏移量為11的字節(jié)碼和偏移量為15的字節(jié)碼。編譯器將在循環(huán)體結(jié)尾增加循環(huán)回邊計(jì)數(shù)器的代碼,來對循環(huán)進(jìn)行計(jì)數(shù)。

當(dāng)方法的調(diào)用次數(shù)和循環(huán)回邊的次數(shù)的和,超過由參數(shù)-XX:CompileThreshold指定的閾值時(shí)(使用C1時(shí),默認(rèn)值為1500;使用C2時(shí),默認(rèn)值為10000),就會(huì)觸發(fā)即時(shí)編譯。

開啟分層編譯的情況下,-XX:CompileThreshold參數(shù)設(shè)置的閾值將會(huì)失效,觸發(fā)編譯會(huì)由以下的條件來判斷:

  • 方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXInvocationThreshold指定的閾值乘以系數(shù)。

  • 方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXMINInvocationThreshold指定的閾值乘以系數(shù),并且方法調(diào)用次數(shù)和循環(huán)回邊次數(shù)之和大于由參數(shù)-XX:TierXCompileThreshold指定的閾值乘以系數(shù)時(shí)。

分層編譯觸發(fā)條件公式

i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s) 
i為調(diào)用次數(shù),b是循環(huán)回邊次數(shù)

上述滿足其中一個(gè)條件就會(huì)觸發(fā)即時(shí)編譯,并且JVM會(huì)根據(jù)當(dāng)前的編譯方法數(shù)以及編譯線程數(shù)動(dòng)態(tài)調(diào)整系數(shù)s。

三、編譯優(yōu)化

即時(shí)編譯器會(huì)對正在運(yùn)行的服務(wù)進(jìn)行一系列的優(yōu)化,包括字節(jié)碼解析過程中的分析,根據(jù)編譯過程中代碼的一些中間形式來做局部優(yōu)化,還會(huì)根據(jù)程序依賴圖進(jìn)行全局優(yōu)化,最后才會(huì)生成機(jī)器碼。

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

在編譯原理中,通常把編譯器分為前端和后端,前端編譯經(jīng)過詞法分析、語法分析、語義分析生成中間表達(dá)形式(Intermediate Representation,以下稱為IR),后端會(huì)對IR進(jìn)行優(yōu)化,生成目標(biāo)代碼。

Java字節(jié)碼就是一種IR,但是字節(jié)碼的結(jié)構(gòu)復(fù)雜,字節(jié)碼這樣代碼形式的IR也不適合做全局的分析優(yōu)化?,F(xiàn)代編譯器一般采用圖結(jié)構(gòu)的IR,靜態(tài)單賦值(Static Single Assignment,SSA)IR是目前比較常用的一種。這種IR的特點(diǎn)是每個(gè)變量只能被賦值一次,而且只有當(dāng)變量被賦值之后才能使用。舉個(gè)例子:

SSA IR

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

上述代碼中我們可以輕易地發(fā)現(xiàn)a = 1的賦值是冗余的,但是編譯器不能。傳統(tǒng)的編譯器需要借助數(shù)據(jù)流分析,從后至前依次確認(rèn)哪些變量的值被覆蓋掉。不過,如果借助了SSA IR,編譯器則可以很容易識別冗余賦值。

上面代碼的SSA IR形式的偽代碼可以表示為:

SSA IR

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

由于SSA IR中每個(gè)變量只能賦值一次,所以代碼中的a在SSA IR中會(huì)分成a_1、a_2兩個(gè)變量來賦值,這樣編譯器就可以很容易通過掃描這些變量來發(fā)現(xiàn)a_1的賦值后并沒有使用,賦值是冗余的。

除此之外,SSA IR對其他優(yōu)化方式也有很大的幫助,例如下面這個(gè)死代碼刪除(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)

編譯器通過執(zhí)行字節(jié)碼可以發(fā)現(xiàn) b_2 賦值后不會(huì)被使用,else分支不會(huì)被執(zhí)行。經(jīng)過死代碼刪除后就可以得到代碼:

DeadCodeElimination

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

我們可以將編譯器的每一種優(yōu)化看成一個(gè)圖優(yōu)化算法,它接收一個(gè)IR圖,并輸出經(jīng)過轉(zhuǎn)換后的IR圖。編譯器優(yōu)化的過程就是一個(gè)個(gè)圖節(jié)點(diǎn)的優(yōu)化串聯(lián)起來的。

C1中的中間表達(dá)形式

前文提及C1編譯器內(nèi)部使用高級中間表達(dá)形式HIR,低級中間表達(dá)形式LIR來進(jìn)行各種優(yōu)化,這兩種IR都是SSA形式的。

HIR是由很多基本塊(Basic Block)組成的控制流圖結(jié)構(gòu),每個(gè)塊包含很多SSA形式的指令?;緣K的結(jié)構(gòu)如下圖所示:

Java中即時(shí)編譯器的原理是什么

其中,predecessors表示前驅(qū)基本塊(由于前驅(qū)可能是多個(gè),所以是BlockList結(jié)構(gòu),是多個(gè)BlockBegin組成的可擴(kuò)容數(shù)組)。同樣,successors表示多個(gè)后繼基本塊BlockEnd。除了這兩部分就是主體塊,里面包含程序執(zhí)行的指令和一個(gè)next指針,指向下一個(gè)執(zhí)行的主體塊。

從字節(jié)碼到HIR的構(gòu)造最終調(diào)用的是GraphBuilder,GraphBuilder會(huì)遍歷字節(jié)碼構(gòu)造所有代碼基本塊儲存為一個(gè)鏈表結(jié)構(gòu),但是這個(gè)時(shí)候的基本塊只有BlockBegin,不包括具體的指令。第二步GraphBuilder會(huì)用一個(gè)ValueStack作為操作數(shù)棧和局部變量表,模擬執(zhí)行字節(jié)碼,構(gòu)造出對應(yīng)的HIR,填充之前空的基本塊,這里給出簡單字節(jié)碼塊構(gòu)造HIR的過程示例,如下所示:

字節(jié)碼構(gòu)造HIR

        字節(jié)碼                     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]

可以看出,當(dāng)執(zhí)行iload_1時(shí),操作數(shù)棧壓入變量i1,執(zhí)行iload_2時(shí),操作數(shù)棧壓入變量i2,執(zhí)行相乘指令imul時(shí)彈出棧頂兩個(gè)值,構(gòu)造出HIR i3 : i1 * i2,生成的i3入棧。

C1編譯器優(yōu)化大部分都是在HIR之上完成的。當(dāng)優(yōu)化完成之后它會(huì)將HIR轉(zhuǎn)化為LIR,LIR和HIR類似,也是一種編譯器內(nèi)部用到的IR,HIR通過優(yōu)化消除一些中間節(jié)點(diǎn)就可以生成LIR,形式上更加簡化。

Sea-of-Nodes IR

C2編譯器中的Ideal Graph采用的是一種名為Sea-of-Nodes中間表達(dá)形式,同樣也是SSA形式的。它最大特點(diǎn)是去除了變量的概念,直接采用值來進(jìn)行運(yùn)算。為了方便理解,可以利用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;
}

對應(yīng)的IR圖如下所示:

Java中即時(shí)編譯器的原理是什么

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

這種圖具有輕量級的邊結(jié)構(gòu)。 圖中的邊僅由指向另一個(gè)節(jié)點(diǎn)的指針表示。節(jié)點(diǎn)是Node子類的實(shí)例,帶有指定輸入邊的指針數(shù)組。這種表示的優(yōu)點(diǎn)是改變節(jié)點(diǎn)的輸入邊很快,如果想要改變輸入邊,只要將指針指向Node,然后存入Node的指針數(shù)組就可以了。

依賴于這種圖結(jié)構(gòu),通過收集程序運(yùn)行的信息,JVM可以通過Schedule那些浮動(dòng)節(jié)點(diǎn),從而獲得最好的編譯效果。

Phi And Region Nodes

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

example

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

為了解決這個(gè)問題,就引入一個(gè)Phi Nodes的概念,能夠根據(jù)不同的執(zhí)行路徑選擇不同的值。于是,上面這段代碼可以表示為下面這張圖:

Java中即時(shí)編譯器的原理是什么

Phi Nodes中保存不同路徑上包含的所有值,Region Nodes根據(jù)不同路徑的判斷條件,從Phi Nodes取得當(dāng)前執(zhí)行路徑中變量應(yīng)該賦予的值,帶有Phi節(jié)點(diǎn)的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) 是一種因?yàn)镾ea-of-Nodes變得非常容易的優(yōu)化技術(shù) 。

GVN是指為每一個(gè)計(jì)算得到的值分配一個(gè)獨(dú)一無二的編號,然后遍歷指令尋找優(yōu)化的機(jī)會(huì),它可以發(fā)現(xiàn)并消除等價(jià)計(jì)算的優(yōu)化技術(shù)。如果一段程序中出現(xiàn)了多次操作數(shù)相同的乘法,那么即時(shí)編譯器可以將這些乘法合并為一個(gè),從而降低輸出機(jī)器碼的大小。如果這些乘法出現(xiàn)在同一執(zhí)行路徑上,那么GVN還將省下冗余的乘法操作。在Sea-of-Nodes中,由于只存在值的概念,因此GVN算法將非常簡單:即時(shí)編譯器只需判斷該浮動(dòng)節(jié)點(diǎn)是否與已存在的浮動(dòng)節(jié)點(diǎn)的編號相同,所輸入的IR節(jié)點(diǎn)是否一致,便可以將這兩個(gè)浮動(dòng)節(jié)點(diǎn)歸并成一個(gè)。比如下面這段代碼:

GVN

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

GVN會(huì)利用Hash算法編號,計(jì)算a = 1時(shí),得到編號1,計(jì)算b = 2時(shí)得到編號2,計(jì)算c = a + b時(shí)得到編號3,這些編號都會(huì)放入Hash表中保存,在計(jì)算d = a + b時(shí),會(huì)發(fā)現(xiàn)a + b已經(jīng)存在Hash表中,就不會(huì)再進(jìn)行計(jì)算,直接從Hash表中取出計(jì)算過的值。最后的e = d也可以由Hash表中查到而進(jìn)行復(fù)用。

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

2.方法內(nèi)聯(lián)

方法內(nèi)聯(lián),是指在編譯過程中遇到方法調(diào)用時(shí),將目標(biāo)方法的方法體納入編譯范圍之中,并取代原方法調(diào)用的優(yōu)化手段。JIT大部分的優(yōu)化都是在內(nèi)聯(lián)的基礎(chǔ)上進(jìn)行的,方法內(nèi)聯(lián)是即時(shí)編譯器中非常重要的一環(huán)。

Java服務(wù)中存在大量getter/setter方法,如果沒有方法內(nèi)聯(lián),在調(diào)用getter/setter時(shí),程序執(zhí)行時(shí)需要保存當(dāng)前方法的執(zhí)行位置,創(chuàng)建并壓入用于getter/setter的棧幀、訪問字段、彈出棧幀,最后再恢復(fù)當(dāng)前方法的執(zhí)行。內(nèi)聯(lián)了對 getter/setter的方法調(diào)用后,上述操作僅剩字段訪問。在C2編譯器 中,方法內(nèi)聯(lián)在解析字節(jié)碼的過程中完成。當(dāng)遇到方法調(diào)用字節(jié)碼時(shí),編譯器將根據(jù)一些閾值參數(shù)決定是否需要內(nèi)聯(lián)當(dāng)前方法的調(diào)用。如果需要內(nèi)聯(lián),則開始解析目標(biāo)方法的字節(jié)碼。比如下面這個(gè)示例(來源于網(wǎng)絡(luò)):

方法內(nèi)聯(lián)的過程

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圖:

Java中即時(shí)編譯器的原理是什么

內(nèi)聯(lián)后的IR圖:

Java中即時(shí)編譯器的原理是什么

內(nèi)聯(lián)不僅將被調(diào)用方法的IR圖節(jié)點(diǎn)復(fù)制到調(diào)用者方法的IR圖中,還要完成其他操作。

被調(diào)用方法的參數(shù)替換為調(diào)用者方法進(jìn)行方法調(diào)用時(shí)所傳入?yún)?shù)。上面例子中,將bar方法中的1號P(0)節(jié)點(diǎn)替換為foo方法3號LoadField節(jié)點(diǎn)。

調(diào)用者方法的IR圖中,方法調(diào)用節(jié)點(diǎn)的數(shù)據(jù)依賴會(huì)變成被調(diào)用方法的返回。如果存在多個(gè)返回節(jié)點(diǎn),會(huì)生成一個(gè)Phi節(jié)點(diǎn),將這些返回值聚合起來,并作為原方法調(diào)用節(jié)點(diǎn)的替換對象。圖中就是將8號==節(jié)點(diǎn),以及12號Return節(jié)點(diǎn)連接到原5號Invoke節(jié)點(diǎn)的邊,然后指向新生成的24號Phi節(jié)點(diǎn)中。

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

方法內(nèi)聯(lián)的條件

編譯器的大部分優(yōu)化都是在方法內(nèi)聯(lián)的基礎(chǔ)上。所以一般來說,內(nèi)聯(lián)的方法越多,生成代碼的執(zhí)行效率越高。但是對于即時(shí)編譯器來說,內(nèi)聯(lián)的方法越多,編譯時(shí)間也就越長,程序達(dá)到峰值性能的時(shí)刻也就比較晚。

可以通過虛擬機(jī)參數(shù)-XX:MaxInlineLevel調(diào)整內(nèi)聯(lián)的層數(shù),以及1層的直接遞歸調(diào)用(可以通過虛擬機(jī)參數(shù)-XX:MaxRecursiveInlineLevel調(diào)整)。一些常見的內(nèi)聯(lián)相關(guān)的參數(shù)如下表所示:

Java中即時(shí)編譯器的原理是什么

虛函數(shù)內(nèi)聯(lián)

內(nèi)聯(lián)是JIT提升性能的主要手段,但是虛函數(shù)使得內(nèi)聯(lián)是很難的,因?yàn)樵趦?nèi)聯(lián)階段并不知道他們會(huì)調(diào)用哪個(gè)方法。例如,我們有一個(gè)數(shù)據(jù)處理的接口,這個(gè)接口中的一個(gè)方法有三種實(shí)現(xiàn)add、sub和multi,JVM是通過保存虛函數(shù)表Virtual Method Table(以下稱為VMT)存儲class對象中所有的虛函數(shù),class的實(shí)例對象保存著一個(gè)VMT的指針,程序運(yùn)行時(shí)首先加載實(shí)例對象,然后通過實(shí)例對象找到VMT,通過VMT找到對應(yīng)方法的地址,所以虛函數(shù)的調(diào)用比直接指向方法地址的classic call性能上會(huì)差一些。很不幸的是,Java中所有非私有的成員函數(shù)的調(diào)用都是虛調(diào)用。

C2編譯器已經(jīng)足夠智能,能夠檢測這種情況并會(huì)對虛調(diào)用進(jìn)行優(yōu)化。比如下面這段代碼例子:

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();
        }
    }
}

經(jīng)過JIT編譯器優(yōu)化后,進(jìn)行反匯編得到下面這段匯編代碼:

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

可以看到JIT對methodCall方法進(jìn)行了虛調(diào)用優(yōu)化optimized virtual_call。經(jīng)過優(yōu)化后的方法可以被內(nèi)聯(lián)。但是C2編譯器的能力有限,對于多個(gè)實(shí)現(xiàn)方法的虛調(diào)用就“無能為力”了。

比如下面這段代碼,我們增加一個(gè)實(shí)現(xiàn):

多實(shí)現(xiàn)的虛調(diào)用

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();
        }
    }
}

經(jīng)過反編譯得到下面的匯編代碼:

代碼塊

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

可以看到多個(gè)實(shí)現(xiàn)的虛調(diào)用未被優(yōu)化,依然是virtual_call。

Graal編譯器針對這種情況,會(huì)去收集這部分執(zhí)行的信息,比如在一段時(shí)間,發(fā)現(xiàn)前面的接口方法的調(diào)用add和sub是各占50%的幾率,那么JVM就會(huì)在每次運(yùn)行時(shí),遇到add就把a(bǔ)dd內(nèi)聯(lián)進(jìn)來,遇到sub的情況再把sub函數(shù)內(nèi)聯(lián)進(jìn)來,這樣這兩個(gè)路徑的執(zhí)行效率就會(huì)提升。在后續(xù)如果遇到其他不常見的情況,JVM就會(huì)進(jìn)行去優(yōu)化的操作,在那個(gè)位置做標(biāo)記,再遇到這種情況時(shí)切換回解釋執(zhí)行。

3. 逃逸分析

逃逸分析是“一種確定指針動(dòng)態(tài)范圍的靜態(tài)分析,它可以分析在程序的哪些地方可以訪問到指針”。Java虛擬機(jī)的即時(shí)編譯器會(huì)對新建的對象進(jìn)行逃逸分析,判斷對象是否逃逸出線程或者方法。即時(shí)編譯器判斷對象是否逃逸的依據(jù)有兩種:

  1. 對象是否被存入堆中(靜態(tài)字段或者堆中對象的實(shí)例字段),一旦對象被存入堆中,其他線程便能獲得該對象的引用,即時(shí)編譯器就無法追蹤所有使用該對象的代碼位置。

  2. 對象是否被傳入未知代碼中,即時(shí)編譯器會(huì)將未被內(nèi)聯(lián)的代碼當(dāng)成未知代碼,因?yàn)樗鼰o法確認(rèn)該方法調(diào)用會(huì)不會(huì)將調(diào)用者或所傳入的參數(shù)存儲至堆中,這種情況,可以直接認(rèn)為方法調(diào)用的調(diào)用者以及參數(shù)是逃逸的。

逃逸分析通常是在方法內(nèi)聯(lián)的基礎(chǔ)上進(jìn)行的,即時(shí)編譯器可以根據(jù)逃逸分析的結(jié)果進(jìn)行諸如鎖消除、棧上分配以及標(biāo)量替換的優(yōu)化。下面這段代碼的就是對象未逃逸的例子:

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;
    }
  }
}

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

鎖消除

在學(xué)習(xí)Java并發(fā)編程時(shí)會(huì)了解鎖消除,而鎖消除就是在逃逸分析的基礎(chǔ)上進(jìn)行的。

如果即時(shí)編譯器能夠證明鎖對象不逃逸,那么對該鎖對象的加鎖、解鎖操作沒就有意義。因?yàn)榫€程并不能獲得該鎖對象。在這種情況下,即時(shí)編譯器會(huì)消除對該不逃逸鎖對象的加鎖、解鎖操作。實(shí)際上,編譯器僅需證明鎖對象不逃逸出線程,便可以進(jìn)行鎖消除。由于Java虛擬機(jī)即時(shí)編譯的限制,上述條件被強(qiáng)化為證明鎖對象不逃逸出當(dāng)前編譯的方法。不過,基于逃逸分析的鎖消除實(shí)際上并不多見。

棧上分配

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

標(biāo)量替換

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);
  }
}

經(jīng)過逃逸分析,cat對象未逃逸出example()的調(diào)用,因此可以對聚合量cat進(jìn)行分解,得到兩個(gè)標(biāo)量age和weight,進(jìn)行標(biāo)量替換后的偽代碼:

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

部分逃逸分析

部分逃逸分析也是Graal對于概率預(yù)測的應(yīng)用。通常來說,如果發(fā)現(xiàn)一個(gè)對象逃逸出了方法或者線程,JVM就不會(huì)去進(jìn)行優(yōu)化,但是Graal編譯器依然會(huì)去分析當(dāng)前程序的執(zhí)行路徑,它會(huì)在逃逸分析基礎(chǔ)上收集、判斷哪些路徑上對象會(huì)逃逸,哪些不會(huì)。然后根據(jù)這些信息,在不會(huì)逃逸的路徑上進(jìn)行鎖消除、棧上分配這些優(yōu)化手段。

4. Loop Transformations

在文章中介紹C2編譯器的部分有提及到,C2編譯器在構(gòu)建Ideal Graph后會(huì)進(jìn)行很多的全局優(yōu)化,其中就包括對循環(huán)的轉(zhuǎn)換,最重要的兩種轉(zhuǎn)換就是循環(huán)展開和循環(huán)分離。

循環(huán)展開

循環(huán)展開是一種循環(huán)轉(zhuǎn)換技術(shù),它試圖以犧牲程序二進(jìn)制碼大小為代價(jià)來優(yōu)化程序的執(zhí)行速度,是一種用空間換時(shí)間的優(yōu)化手段。

循環(huán)展開通過減少或消除控制程序循環(huán)的指令,來減少計(jì)算開銷,這種開銷包括增加指向數(shù)組中下一個(gè)索引或者指令的指針?biāo)銛?shù)等。如果編譯器可以提前計(jì)算這些索引,并且構(gòu)建到機(jī)器代碼指令中,那么程序運(yùn)行時(shí)就可以不必進(jìn)行這種計(jì)算。也就是說有些循環(huán)可以寫成一些重復(fù)獨(dú)立的代碼。比如下面這個(gè)循環(huán):

循環(huán)展開

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

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

循環(huán)展開

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);
  }
}

這樣展開就可以減少循環(huán)的次數(shù),每次循環(huán)內(nèi)的計(jì)算也可以利用CPU的流水線提升效率。當(dāng)然這只是一個(gè)示例,實(shí)際進(jìn)行展開時(shí),JVM會(huì)去評估展開帶來的收益,再?zèng)Q定是否進(jìn)行展開。

循環(huán)分離

循環(huán)分離也是循環(huán)轉(zhuǎn)換的一種手段。它把循環(huán)中一次或多次的特殊迭代分離出來,在循環(huán)外執(zhí)行。舉個(gè)例子,下面這段代碼:

循環(huán)分離

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

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

循環(huán)分離

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

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

5. 窺孔優(yōu)化與寄存器分配

前文提到的窺孔優(yōu)化是優(yōu)化的最后一步,這之后就會(huì)程序就會(huì)轉(zhuǎn)換成機(jī)器碼,窺孔優(yōu)化就是將編譯器所生成的中間代碼(或目標(biāo)代碼)中相鄰指令,將其中的某些組合替換為效率更高的指令組,常見的比如強(qiáng)度削減、常數(shù)合并等,看下面這個(gè)例子就是一個(gè)強(qiáng)度削減的例子:

強(qiáng)度削減

y1=x1*3  經(jīng)過強(qiáng)度削減后得到  y1=(x1<<1)+x1

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

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

寄存器分配和窺孔優(yōu)化是程序優(yōu)化的最后一步。經(jīng)過寄存器分配和窺孔優(yōu)化之后,程序就會(huì)被轉(zhuǎn)換成機(jī)器碼保存在codeCache中。

四、實(shí)踐

即時(shí)編譯器情況復(fù)雜,同時(shí)網(wǎng)絡(luò)上也很少有實(shí)戰(zhàn)經(jīng)驗(yàn),以下是我們團(tuán)隊(duì)的一些調(diào)整經(jīng)驗(yàn)。

1. 編譯相關(guān)的重* 要參數(shù)

  • -XX:+TieredCompilation:開啟分層編譯,JDK8之后默認(rèn)開啟

  • -XX:+CICompilerCount=N:編譯線程數(shù),設(shè)置數(shù)量后,JVM會(huì)自動(dòng)分配線程數(shù),C1:C2 = 1:2

  • -XX:TierXBackEdgeThreshold:OSR編譯的閾值

  • -XX:TierXMinInvocationThreshold:開啟分層編譯后各層調(diào)用的閾值

  • -XX:TierXCompileThreshold:開啟分層編譯后的編譯閾值

  • -XX:ReservedCodeCacheSize:codeCache最大大小

  • -XX:InitialCodeCacheSize:codeCache初始大小

-XX:TierXMinInvocationThreshold是開啟分層編譯的情況下,觸發(fā)編譯的閾值參數(shù),當(dāng)方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXInvocationThreshold指定的閾值乘以系數(shù),或者當(dāng)方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXMINInvocationThreshold指定的閾值乘以系數(shù),并且方法調(diào)用次數(shù)和循環(huán)回邊次數(shù)之和大于由參數(shù)-XX:TierXCompileThreshold指定的閾值乘以系數(shù)時(shí),便會(huì)觸發(fā)X層即時(shí)編譯。分層編譯開啟下會(huì)乘以一個(gè)系數(shù),系數(shù)根據(jù)當(dāng)前編譯的方法和編譯線程數(shù)確定,降低閾值可以提升編譯方法數(shù),一些常用但是不能編譯的方法可以編譯優(yōu)化提升性能。

由于編譯情況復(fù)雜,JVM也會(huì)動(dòng)態(tài)調(diào)整相關(guān)的閾值來保證JVM的性能,所以不建議手動(dòng)調(diào)整編譯相關(guān)的參數(shù)。除非一些特定的Case,比如codeCache滿了停止了編譯,可以適當(dāng)增加codeCache大小,或者一些非常常用的方法,未被內(nèi)聯(lián)到,拖累了性能,可以調(diào)整內(nèi)斂層數(shù)或者內(nèi)聯(lián)方法的大小來解決。

2. 通過JITwatch分析編譯日志

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

Java中即時(shí)編譯器的原理是什么 Java中即時(shí)編譯器的原理是什么

如上圖所示,區(qū)域1中是整個(gè)項(xiàng)目Java Class包括引入的第三方依賴;區(qū)域2是功能區(qū)Timeline以圖形的形式展示JIT編譯的時(shí)間軸,Histo是直方圖展示一些信息,TopList里面是編譯中產(chǎn)生的一些對象和數(shù)據(jù)的排序,Cache是空閑codeCache空間,NMethod是Native方法,Threads是JIT編譯的線程;區(qū)域3是JITwatch對日志分析結(jié)果的展示,其中Suggestions中會(huì)給出一些代碼優(yōu)化的建議,舉個(gè)例子,如下圖中:

Java中即時(shí)編譯器的原理是什么

我們可以看到在調(diào)用ZipInputStream的read方法時(shí),因?yàn)樵摲椒]有被標(biāo)記為熱點(diǎn)方法,同時(shí)又“太大了”,導(dǎo)致無法被內(nèi)聯(lián)到。使用-XX:CompileCommand中inline指令可以強(qiáng)制方法進(jìn)行內(nèi)聯(lián),不過還是建議謹(jǐn)慎使用,除非確定某個(gè)方法內(nèi)聯(lián)會(huì)帶來不少的性能提升,否則不建議使用,并且過多使用對編譯線程和codeCache都會(huì)帶來不小的壓力。

區(qū)域3中的-Allocs和-Locks逃逸分析后JVM對代碼做的優(yōu)化,包括棧上分配、鎖消除等。

3. 使用Graal編譯器

由于JVM會(huì)去根據(jù)當(dāng)前的編譯方法數(shù)和編譯線程數(shù)對編譯閾值進(jìn)行動(dòng)態(tài)的調(diào)整,所以實(shí)際服務(wù)中對這一部分的調(diào)整空間是不大的,JVM做的已經(jīng)足夠多了。

為了提升性能,在服務(wù)中嘗試了最新的Graal編譯器。只需要使用-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler就可以啟動(dòng)Graal編譯器來代替C2編譯器,并且響應(yīng)C2的編譯請求,不過要注意的是,Graal編譯器與ZGC不兼容,只能與G1搭配使用。

前文有提到過,Graal是一個(gè)用Java寫的即時(shí)編譯器,它從Java 9開始便被集成自JDK中,作為實(shí)驗(yàn)性質(zhì)的即時(shí)編譯器。Graal編譯器就是脫身于GraalVM,GraalVM是一個(gè)高性能的、支持多種編程語言的執(zhí)行環(huán)境。它既可以在傳統(tǒng)的 OpenJDK上運(yùn)行,也可以通過AOT(Ahead-Of-Time)編譯成可執(zhí)行文件單獨(dú)運(yùn)行,甚至可以集成至數(shù)據(jù)庫中運(yùn)行。

前文提到過數(shù)次,Graal的優(yōu)化都基于某種假設(shè)(Assumption)。當(dāng)假設(shè)出錯(cuò)的情況下,Java虛擬機(jī)會(huì)借助去優(yōu)化(Deoptimization)這項(xiàng)機(jī)制,從執(zhí)行即時(shí)編譯器生成的機(jī)器碼切換回解釋執(zhí)行,在必要情況下,它甚至?xí)U棄這份機(jī)器碼,并在重新收集程序profile之后,再進(jìn)行編譯。

這些中激進(jìn)的手段使得Graal的峰值性能要好于C2,而且在Scale、Ruby這種語言Graal表現(xiàn)更加出色,Twitter目前已經(jīng)在服務(wù)中大量的使用Graal來提升性能,企業(yè)版的GraalVM使得Twitter服務(wù)性能提升了22%。

使用Graal編譯器后性能表現(xiàn)

在我們的線上服務(wù)中,啟用Graal編譯后,TP9999從60ms -> 50ms ,下降10ms,下降幅度達(dá)16.7%。

運(yùn)行過程中的峰值性能會(huì)更高??梢钥闯鰧τ谠摲?wù),Graal編譯器帶來了一定的性能提升。

Graal編譯器的問題

Graal編譯器的優(yōu)化方式更加激進(jìn),因此在啟動(dòng)時(shí)會(huì)進(jìn)行更多的編譯,Graal編譯器本身也需要被即時(shí)編譯,所以服務(wù)剛啟動(dòng)時(shí)性能會(huì)比較差。

考慮的解決辦法:JDK 9開始提供工具jaotc,同時(shí)GraalVM的Native Image都是可以通過靜態(tài)編譯,極大地提升服務(wù)的啟動(dòng)速度的方式,但是GraalVM會(huì)使用自己的垃圾回收,這是一種很原始的基于復(fù)制算法的垃圾回收,相比G1、ZGC這些優(yōu)秀的新型垃圾回收器,它的性能并不好。同時(shí)GraalVM對Java的一些特性支持也不夠,比如基于配置的支持,比如反射就需要把所有需要反射的類配置一個(gè)JSON文件,在大量使用反射的服務(wù),這樣的配置會(huì)是很大的工作量。我們也在做這方面的調(diào)研。

上述就是小編為大家分享的Java中即時(shí)編譯器的原理是什么了,如果剛好有類似的疑惑,不妨參照上述分析進(jìn)行理解。如果想知道更多相關(guān)知識,歡迎關(guān)注億速云行業(yè)資訊頻道。

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI