這期內(nèi)容當中小編將會給大家?guī)碛嘘P(guān)Java中即時編譯器的原理是什么,文章內(nèi)容豐富且以專業(yè)的角度為大家分析和敘述,閱讀完這篇文章希望大家可以有所收獲。
站在用戶的角度思考問題,與客戶深入溝通,找到東源網(wǎng)站設(shè)計與東源網(wǎng)站推廣的解決方案,憑借多年的經(jīng)驗,讓設(shè)計與互聯(lián)網(wǎng)技術(shù)結(jié)合,創(chuàng)造個性化、用戶體驗好的作品,建站類型包括:做網(wǎng)站、成都網(wǎng)站建設(shè)、企業(yè)官網(wǎng)、英文網(wǎng)站、手機端網(wǎng)站、網(wǎng)站推廣、域名注冊、網(wǎng)站空間、企業(yè)郵箱。業(yè)務覆蓋東源地區(qū)。
常見的編譯型語言如C++,通常會把代碼直接編譯成CPU所能理解的機器碼來運行。而Java為了實現(xiàn)“一次編譯,處處運行”的特性,把編譯的過程分成兩部分,首先它會先由javac編譯成通用的中間形式——字節(jié)碼,然后再由解釋器逐條將字節(jié)碼解釋為機器碼來執(zhí)行。所以在性能上,Java通常不如C++這類編譯型語言。
為了優(yōu)化Java的性能 ,JVM在解釋器之外引入了即時(Just In Time)編譯器:當程序運行時,解釋器首先發(fā)揮作用,代碼可以直接執(zhí)行。隨著時間推移,即時編譯器逐漸發(fā)揮作用,把越來越多的代碼編譯優(yōu)化成本地代碼,來獲取更高的執(zhí)行效率。解釋器這時可以作為編譯運行的降級手段,在一些不可靠的編譯優(yōu)化出現(xiàn)問題時,再切換回解釋執(zhí)行,保證程序可以正常運行。
即時編譯器極大地提高了Java程序的運行速度,而且跟靜態(tài)編譯相比,即時編譯器可以選擇性地編譯熱點代碼,省去了很多編譯時間,也節(jié)省很多的空間。目前,即時編譯器已經(jīng)非常成熟了,在性能層面甚至可以和編譯型語言相比。不過在這個領(lǐng)域,大家依然在不斷探索如何結(jié)合不同的編譯方式,使用更加智能的手段來提升程序的運行速度。
Java的執(zhí)行過程整體可以分為兩個部分,第一步由javac將源碼編譯成字節(jié)碼,在這個過程中會進行詞法分析、語法分析、語義分析,編譯原理中這部分的編譯稱為前端編譯。接下來無需編譯直接逐條將字節(jié)碼解釋執(zhí)行,在解釋執(zhí)行的過程中,虛擬機同時對程序運行的信息進行收集,在這些信息的基礎(chǔ)上,編譯器會逐漸發(fā)揮作用,它會進行后端編譯——把字節(jié)碼編譯成機器碼,但不是所有的代碼都會被編譯,只有被JVM認定為的熱點代碼,才可能被編譯。
怎么樣才會被認為是熱點代碼呢?JVM中會設(shè)置一個閾值,當方法或者代碼塊的在一定時間內(nèi)的調(diào)用次數(shù)超過這個閾值時就會被編譯,存入codeCache中。當下次執(zhí)行時,再遇到這段代碼,就會從codeCache中讀取機器碼,直接執(zhí)行,以此來提升程序運行的性能。整體的執(zhí)行過程大致如下圖所示:
JVM中集成了兩種編譯器,Client Compiler和Server Compiler,它們的作用也不同。Client Compiler注重啟動速度和局部的優(yōu)化,Server Compiler則更加關(guān)注全局的優(yōu)化,性能會更好,但由于會進行更多的全局分析,所以啟動速度會變慢。兩種編譯器有著不同的應用場景,在虛擬機中同時發(fā)揮作用。
Client Compiler
HotSpot VM帶有一個Client Compiler C1編譯器。這種編譯器啟動速度快,但是性能比較Server Compiler來說會差一些。C1會做三件事:
局部簡單可靠的優(yōu)化,比如字節(jié)碼上進行的一些基礎(chǔ)優(yōu)化,方法內(nèi)聯(lián)、常量傳播等,放棄許多耗時較長的全局優(yōu)化。
將字節(jié)碼構(gòu)造成高級中間表示(High-level Intermediate Representation,以下稱為HIR),HIR與平臺無關(guān),通常采用圖結(jié)構(gòu),更適合JVM對程序進行優(yōu)化。
最后將HIR轉(zhuǎn)換成低級中間表示(Low-level Intermediate Representation,以下稱為LIR),在LIR的基礎(chǔ)上會進行寄存器分配、窺孔優(yōu)化(局部的優(yōu)化方式,編譯器在一個基本塊或者多個基本塊中,針對已經(jīng)生成的代碼,結(jié)合CPU自己指令的特點,通過一些認為可能帶來性能提升的轉(zhuǎn)換規(guī)則或者通過整體的分析,進行指令轉(zhuǎn)換,來提升代碼性能)等操作,最終生成機器碼。
Server Compiler
Server Compiler主要關(guān)注一些編譯耗時較長的全局優(yōu)化,甚至會還會根據(jù)程序運行的信息進行一些不可靠的激進優(yōu)化。這種編譯器的啟動時間長,適用于長時間運行的后臺程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虛擬機中使用的Server Compiler有兩種:C2和Graal。
C2 Compiler
在Hotspot VM中,默認的Server Compiler是C2編譯器。
C2編譯器在進行編譯優(yōu)化時,會使用一種控制流與數(shù)據(jù)流結(jié)合的圖數(shù)據(jù)結(jié)構(gòu),稱為Ideal Graph。 Ideal Graph表示當前程序的數(shù)據(jù)流向和指令間的依賴關(guān)系,依靠這種圖結(jié)構(gòu),某些優(yōu)化步驟(尤其是涉及浮動代碼塊的那些優(yōu)化步驟)變得不那么復雜。
Ideal Graph的構(gòu)建是在解析字節(jié)碼的時候,根據(jù)字節(jié)碼中的指令向一個空的Graph中添加節(jié)點,Graph中的節(jié)點通常對應一個指令塊,每個指令塊包含多條相關(guān)聯(lián)的指令,JVM會利用一些優(yōu)化技術(shù)對這些指令進行優(yōu)化,比如Global Value Numbering、常量折疊等,解析結(jié)束后,還會進行一些死代碼剔除的操作。生成Ideal Graph后,會在這個基礎(chǔ)上結(jié)合收集的程序運行信息來進行一些全局的優(yōu)化,這個階段如果JVM判斷此時沒有全局優(yōu)化的必要,就會跳過這部分優(yōu)化。
無論是否進行全局優(yōu)化,Ideal Graph都會被轉(zhuǎn)化為一種更接近機器層面的MachNode Graph,最后編譯的機器碼就是從MachNode Graph中得的,生成機器碼前還會有一些包括寄存器分配、窺孔優(yōu)化等操作。關(guān)于Ideal Graph和各種全局的優(yōu)化手段會在后面的章節(jié)詳細介紹。Server Compiler編譯優(yōu)化的過程如下圖所示:
Graal Compiler
從JDK 9開始,Hotspot VM中集成了一種新的Server Compiler,Graal編譯器。相比C2編譯器,Graal有這樣幾種關(guān)鍵特性:
前文有提到,JVM會在解釋執(zhí)行的時候收集程序運行的各種信息,然后編譯器會根據(jù)這些信息進行一些基于預測的激進優(yōu)化,比如分支預測,根據(jù)程序不同分支的運行概率,選擇性地編譯一些概率較大的分支。Graal比C2更加青睞這種優(yōu)化,所以Graal的峰值性能通常要比C2更好。
使用Java編寫,對于Java語言,尤其是新特性,比如Lambda、Stream等更加友好。
更深層次的優(yōu)化,比如虛函數(shù)的內(nèi)聯(lián)、部分逃逸分析等。
Graal編譯器可以通過Java虛擬機參數(shù)-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler啟用。當啟用時,它將替換掉HotSpot中的C2編譯器,并響應原本由C2負責的編譯請求。
在Java 7以前,需要研發(fā)人員根據(jù)服務的性質(zhì)去選擇編譯器。對于需要快速啟動的,或者一些不會長期運行的服務,可以采用編譯效率較高的C1,對應參數(shù)-client。長期運行的服務,或者對峰值性能有要求的后臺服務,可以采用峰值性能更好的C2,對應參數(shù)-server。Java 7開始引入了分層編譯的概念,它結(jié)合了C1和C2的優(yōu)勢,追求啟動速度和峰值性能的一個平衡。分層編譯將JVM的執(zhí)行狀態(tài)分為了五個層次。五個層級分別是:
解釋執(zhí)行。
執(zhí)行不帶profiling的C1代碼。
執(zhí)行僅帶方法調(diào)用次數(shù)以及循環(huán)回邊執(zhí)行次數(shù)profiling的C1代碼。
執(zhí)行帶所有profiling的C1代碼。
執(zhí)行C2代碼。
profiling就是收集能夠反映程序執(zhí)行狀態(tài)的數(shù)據(jù)。其中最基本的統(tǒng)計數(shù)據(jù)就是方法的調(diào)用次數(shù),以及循環(huán)回邊的執(zhí)行次數(shù)。
通常情況下,C2代碼的執(zhí)行效率要比C1代碼的高出30%以上。C1層執(zhí)行的代碼,按執(zhí)行效率排序從高至低則是1層>2層>3層。這5個層次中,1層和4層都是終止狀態(tài),當一個方法到達終止狀態(tài)后,只要編譯后的代碼并沒有失效,那么JVM就不會再次發(fā)出該方法的編譯請求的。服務實際運行時,JVM會根據(jù)服務運行情況,從解釋執(zhí)行開始,選擇不同的編譯路徑,直到到達終止狀態(tài)。下圖中就列舉了幾種常見的編譯路徑:
圖中第①條路徑,代表編譯的一般情況,熱點方法從解釋執(zhí)行到被3層的C1編譯,最后被4層的C2編譯。
如果方法比較?。ū热鏙ava服務中常見的getter/setter方法),3層的profiling沒有收集到有價值的數(shù)據(jù),JVM就會斷定該方法對于C1代碼和C2代碼的執(zhí)行效率相同,就會執(zhí)行圖中第②條路徑。在這種情況下,JVM會在3層編譯之后,放棄進入C2編譯,直接選擇用1層的C1編譯運行。
在C1忙碌的情況下,執(zhí)行圖中第③條路徑,在解釋執(zhí)行過程中對程序進行profiling ,根據(jù)信息直接由第4層的C2編譯。
前文提到C1中的執(zhí)行效率是1層>2層>3層,第3層一般要比第2層慢35%以上,所以在C2忙碌的情況下,執(zhí)行圖中第④條路徑。這時方法會被2層的C1編譯,然后再被3層的C1編譯,以減少方法在3層的執(zhí)行時間。
如果編譯器做了一些比較激進的優(yōu)化,比如分支預測,在實際運行時發(fā)現(xiàn)預測出錯,這時就會進行反優(yōu)化,重新進入解釋執(zhí)行,圖中第⑤條執(zhí)行路徑代表的就是反優(yōu)化。
總的來說,C1的編譯速度更快,C2的編譯質(zhì)量更高,分層編譯的不同編譯路徑,也就是JVM根據(jù)當前服務的運行情況來尋找當前服務的最佳平衡點的一個過程。從JDK 8開始,JVM默認開啟分層編譯。
Java虛擬機根據(jù)方法的調(diào)用次數(shù)以及循環(huán)回邊的執(zhí)行次數(shù)來觸發(fā)即時編譯。循環(huán)回邊是一個控制流圖中的概念,程序中可以簡單理解為往回跳轉(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í)行時,每當運行一次該指令,Java虛擬機便會將該方法的循環(huán)回邊計數(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
在即時編譯過程中,編譯器會識別循環(huán)的頭部和尾部。上面這段字節(jié)碼中,循環(huán)體的頭部和尾部分別為偏移量為11的字節(jié)碼和偏移量為15的字節(jié)碼。編譯器將在循環(huán)體結(jié)尾增加循環(huán)回邊計數(shù)器的代碼,來對循環(huán)進行計數(shù)。
當方法的調(diào)用次數(shù)和循環(huán)回邊的次數(shù)的和,超過由參數(shù)-XX:CompileThreshold指定的閾值時(使用C1時,默認值為1500;使用C2時,默認值為10000),就會觸發(fā)即時編譯。
開啟分層編譯的情況下,-XX:CompileThreshold參數(shù)設(shè)置的閾值將會失效,觸發(fā)編譯會由以下的條件來判斷:
方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXInvocationThreshold指定的閾值乘以系數(shù)。
方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXMINInvocationThreshold指定的閾值乘以系數(shù),并且方法調(diào)用次數(shù)和循環(huán)回邊次數(shù)之和大于由參數(shù)-XX:TierXCompileThreshold指定的閾值乘以系數(shù)時。
分層編譯觸發(fā)條件公式
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s) i為調(diào)用次數(shù),b是循環(huán)回邊次數(shù)
上述滿足其中一個條件就會觸發(fā)即時編譯,并且JVM會根據(jù)當前的編譯方法數(shù)以及編譯線程數(shù)動態(tài)調(diào)整系數(shù)s。
即時編譯器會對正在運行的服務進行一系列的優(yōu)化,包括字節(jié)碼解析過程中的分析,根據(jù)編譯過程中代碼的一些中間形式來做局部優(yōu)化,還會根據(jù)程序依賴圖進行全局優(yōu)化,最后才會生成機器碼。
在編譯原理中,通常把編譯器分為前端和后端,前端編譯經(jīng)過詞法分析、語法分析、語義分析生成中間表達形式(Intermediate Representation,以下稱為IR),后端會對IR進行優(yōu)化,生成目標代碼。
Java字節(jié)碼就是一種IR,但是字節(jié)碼的結(jié)構(gòu)復雜,字節(jié)碼這樣代碼形式的IR也不適合做全局的分析優(yōu)化。現(xiàn)代編譯器一般采用圖結(jié)構(gòu)的IR,靜態(tài)單賦值(Static Single Assignment,SSA)IR是目前比較常用的一種。這種IR的特點是每個變量只能被賦值一次,而且只有當變量被賦值之后才能使用。舉個例子:
SSA IR
Plain Text { a = 1; a = 2; b = a; }
上述代碼中我們可以輕易地發(fā)現(xiàn)a = 1的賦值是冗余的,但是編譯器不能。傳統(tǒng)的編譯器需要借助數(shù)據(jù)流分析,從后至前依次確認哪些變量的值被覆蓋掉。不過,如果借助了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兩個變量來賦值,這樣編譯器就可以很容易通過掃描這些變量來發(fā)現(xiàn)a_1的賦值后并沒有使用,賦值是冗余的。
除此之外,SSA IR對其他優(yōu)化方式也有很大的幫助,例如下面這個死代碼刪除(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 賦值后不會被使用,else分支不會被執(zhí)行。經(jīng)過死代碼刪除后就可以得到代碼:
DeadCodeElimination
public void DeadCodeElimination{ int a = 1; int b = 0; add(a,b) }
我們可以將編譯器的每一種優(yōu)化看成一個圖優(yōu)化算法,它接收一個IR圖,并輸出經(jīng)過轉(zhuǎn)換后的IR圖。編譯器優(yōu)化的過程就是一個個圖節(jié)點的優(yōu)化串聯(lián)起來的。
C1中的中間表達形式
前文提及C1編譯器內(nèi)部使用高級中間表達形式HIR,低級中間表達形式LIR來進行各種優(yōu)化,這兩種IR都是SSA形式的。
HIR是由很多基本塊(Basic Block)組成的控制流圖結(jié)構(gòu),每個塊包含很多SSA形式的指令?;緣K的結(jié)構(gòu)如下圖所示:
其中,predecessors表示前驅(qū)基本塊(由于前驅(qū)可能是多個,所以是BlockList結(jié)構(gòu),是多個BlockBegin組成的可擴容數(shù)組)。同樣,successors表示多個后繼基本塊BlockEnd。除了這兩部分就是主體塊,里面包含程序執(zhí)行的指令和一個next指針,指向下一個執(zhí)行的主體塊。
從字節(jié)碼到HIR的構(gòu)造最終調(diào)用的是GraphBuilder,GraphBuilder會遍歷字節(jié)碼構(gòu)造所有代碼基本塊儲存為一個鏈表結(jié)構(gòu),但是這個時候的基本塊只有BlockBegin,不包括具體的指令。第二步GraphBuilder會用一個ValueStack作為操作數(shù)棧和局部變量表,模擬執(zhí)行字節(jié)碼,構(gòu)造出對應的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]
可以看出,當執(zhí)行iload_1時,操作數(shù)棧壓入變量i1,執(zhí)行iload_2時,操作數(shù)棧壓入變量i2,執(zhí)行相乘指令imul時彈出棧頂兩個值,構(gòu)造出HIR i3 : i1 * i2,生成的i3入棧。
C1編譯器優(yōu)化大部分都是在HIR之上完成的。當優(yōu)化完成之后它會將HIR轉(zhuǎn)化為LIR,LIR和HIR類似,也是一種編譯器內(nèi)部用到的IR,HIR通過優(yōu)化消除一些中間節(jié)點就可以生成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圖如下所示:
圖中若干個順序執(zhí)行的節(jié)點將被包含在同一個基本塊之中,如圖中的B0、B1等。B0基本塊中0號Start節(jié)點是方法入口,B3中21號Return節(jié)點是方法出口。紅色加粗線條為控制流,藍色線條為數(shù)據(jù)流,而其他顏色的線條則是特殊的控制流或數(shù)據(jù)流。被控制流邊所連接的是固定節(jié)點,其他的則是浮動節(jié)點(浮動節(jié)點指只要能滿足數(shù)據(jù)依賴關(guān)系,可以放在不同位置的節(jié)點,浮動節(jié)點變動的這個過程稱為Schedule)。
這種圖具有輕量級的邊結(jié)構(gòu)。 圖中的邊僅由指向另一個節(jié)點的指針表示。節(jié)點是Node子類的實例,帶有指定輸入邊的指針數(shù)組。這種表示的優(yōu)點是改變節(jié)點的輸入邊很快,如果想要改變輸入邊,只要將指針指向Node,然后存入Node的指針數(shù)組就可以了。
依賴于這種圖結(jié)構(gòu),通過收集程序運行的信息,JVM可以通過Schedule那些浮動節(jié)點,從而獲得最好的編譯效果。
Phi And Region Nodes
Ideal Graph是SSA IR。 由于沒有變量的概念,這會帶來一個問題,就是不同執(zhí)行路徑可能會對同一變量設(shè)置不同的值。例如下面這段代碼if語句的兩個分支中,分別返回5和6。此時,根據(jù)不同的執(zhí)行路徑,所讀取到的值很有可能不同。
example
int test(int x) { int a = 0; if(x == 1) { a = 5; } else { a = 6; } return a; }
為了解決這個問題,就引入一個Phi Nodes的概念,能夠根據(jù)不同的執(zhí)行路徑選擇不同的值。于是,上面這段代碼可以表示為下面這張圖:
Phi Nodes中保存不同路徑上包含的所有值,Region Nodes根據(jù)不同路徑的判斷條件,從Phi Nodes取得當前執(zhí)行路徑中變量應該賦予的值,帶有Phi節(jié)點的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變得非常容易的優(yōu)化技術(shù) 。
GVN是指為每一個計算得到的值分配一個獨一無二的編號,然后遍歷指令尋找優(yōu)化的機會,它可以發(fā)現(xiàn)并消除等價計算的優(yōu)化技術(shù)。如果一段程序中出現(xiàn)了多次操作數(shù)相同的乘法,那么即時編譯器可以將這些乘法合并為一個,從而降低輸出機器碼的大小。如果這些乘法出現(xiàn)在同一執(zhí)行路徑上,那么GVN還將省下冗余的乘法操作。在Sea-of-Nodes中,由于只存在值的概念,因此GVN算法將非常簡單:即時編譯器只需判斷該浮動節(jié)點是否與已存在的浮動節(jié)點的編號相同,所輸入的IR節(jié)點是否一致,便可以將這兩個浮動節(jié)點歸并成一個。比如下面這段代碼:
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時,會發(fā)現(xiàn)a + b已經(jīng)存在Hash表中,就不會再進行計算,直接從Hash表中取出計算過的值。最后的e = d也可以由Hash表中查到而進行復用。
可以將GVN理解為在IR圖上的公共子表達式消除(Common Subexpression Elimination,CSE)。兩者區(qū)別在于,GVN直接比較值的相同與否,而CSE是借助詞法分析器來判斷兩個表達式相同與否。
方法內(nèi)聯(lián),是指在編譯過程中遇到方法調(diào)用時,將目標方法的方法體納入編譯范圍之中,并取代原方法調(diào)用的優(yōu)化手段。JIT大部分的優(yōu)化都是在內(nèi)聯(lián)的基礎(chǔ)上進行的,方法內(nèi)聯(lián)是即時編譯器中非常重要的一環(huán)。
Java服務中存在大量getter/setter方法,如果沒有方法內(nèi)聯(lián),在調(diào)用getter/setter時,程序執(zhí)行時需要保存當前方法的執(zhí)行位置,創(chuàng)建并壓入用于getter/setter的棧幀、訪問字段、彈出棧幀,最后再恢復當前方法的執(zhí)行。內(nèi)聯(lián)了對 getter/setter的方法調(diào)用后,上述操作僅剩字段訪問。在C2編譯器 中,方法內(nèi)聯(lián)在解析字節(jié)碼的過程中完成。當遇到方法調(diào)用字節(jié)碼時,編譯器將根據(jù)一些閾值參數(shù)決定是否需要內(nèi)聯(lián)當前方法的調(diào)用。如果需要內(nèi)聯(lián),則開始解析目標方法的字節(jié)碼。比如下面這個示例(來源于網(wǎng)絡):
方法內(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圖:
內(nèi)聯(lián)后的IR圖:
內(nèi)聯(lián)不僅將被調(diào)用方法的IR圖節(jié)點復制到調(diào)用者方法的IR圖中,還要完成其他操作。
被調(diào)用方法的參數(shù)替換為調(diào)用者方法進行方法調(diào)用時所傳入?yún)?shù)。上面例子中,將bar方法中的1號P(0)節(jié)點替換為foo方法3號LoadField節(jié)點。
調(diào)用者方法的IR圖中,方法調(diào)用節(jié)點的數(shù)據(jù)依賴會變成被調(diào)用方法的返回。如果存在多個返回節(jié)點,會生成一個Phi節(jié)點,將這些返回值聚合起來,并作為原方法調(diào)用節(jié)點的替換對象。圖中就是將8號==節(jié)點,以及12號Return節(jié)點連接到原5號Invoke節(jié)點的邊,然后指向新生成的24號Phi節(jié)點中。
如果被調(diào)用方法將拋出某種類型的異常,而調(diào)用者方法恰好有該異常類型的處理器,并且該異常處理器覆蓋這一方法調(diào)用,那么即時編譯器需要將被調(diào)用方法拋出異常的路徑,與調(diào)用者方法的異常處理器相連接。
方法內(nèi)聯(lián)的條件
編譯器的大部分優(yōu)化都是在方法內(nèi)聯(lián)的基礎(chǔ)上。所以一般來說,內(nèi)聯(lián)的方法越多,生成代碼的執(zhí)行效率越高。但是對于即時編譯器來說,內(nèi)聯(lián)的方法越多,編譯時間也就越長,程序達到峰值性能的時刻也就比較晚。
可以通過虛擬機參數(shù)-XX:MaxInlineLevel調(diào)整內(nèi)聯(lián)的層數(shù),以及1層的直接遞歸調(diào)用(可以通過虛擬機參數(shù)-XX:MaxRecursiveInlineLevel調(diào)整)。一些常見的內(nèi)聯(lián)相關(guān)的參數(shù)如下表所示:
虛函數(shù)內(nèi)聯(lián)
內(nèi)聯(lián)是JIT提升性能的主要手段,但是虛函數(shù)使得內(nèi)聯(lián)是很難的,因為在內(nèi)聯(lián)階段并不知道他們會調(diào)用哪個方法。例如,我們有一個數(shù)據(jù)處理的接口,這個接口中的一個方法有三種實現(xiàn)add、sub和multi,JVM是通過保存虛函數(shù)表Virtual Method Table(以下稱為VMT)存儲class對象中所有的虛函數(shù),class的實例對象保存著一個VMT的指針,程序運行時首先加載實例對象,然后通過實例對象找到VMT,通過VMT找到對應方法的地址,所以虛函數(shù)的調(diào)用比直接指向方法地址的classic call性能上會差一些。很不幸的是,Java中所有非私有的成員函數(shù)的調(diào)用都是虛調(diào)用。
C2編譯器已經(jīng)足夠智能,能夠檢測這種情況并會對虛調(diào)用進行優(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)化后,進行反匯編得到下面這段匯編代碼:
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方法進行了虛調(diào)用優(yōu)化optimized virtual_call。經(jīng)過優(yōu)化后的方法可以被內(nèi)聯(lián)。但是C2編譯器的能力有限,對于多個實現(xiàn)方法的虛調(diào)用就“無能為力”了。
比如下面這段代碼,我們增加一個實現(xiàn):
多實現(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)化
可以看到多個實現(xiàn)的虛調(diào)用未被優(yōu)化,依然是virtual_call。
Graal編譯器針對這種情況,會去收集這部分執(zhí)行的信息,比如在一段時間,發(fā)現(xiàn)前面的接口方法的調(diào)用add和sub是各占50%的幾率,那么JVM就會在每次運行時,遇到add就把add內(nèi)聯(lián)進來,遇到sub的情況再把sub函數(shù)內(nèi)聯(lián)進來,這樣這兩個路徑的執(zhí)行效率就會提升。在后續(xù)如果遇到其他不常見的情況,JVM就會進行去優(yōu)化的操作,在那個位置做標記,再遇到這種情況時切換回解釋執(zhí)行。
逃逸分析是“一種確定指針動態(tài)范圍的靜態(tài)分析,它可以分析在程序的哪些地方可以訪問到指針”。Java虛擬機的即時編譯器會對新建的對象進行逃逸分析,判斷對象是否逃逸出線程或者方法。即時編譯器判斷對象是否逃逸的依據(jù)有兩種:
對象是否被存入堆中(靜態(tài)字段或者堆中對象的實例字段),一旦對象被存入堆中,其他線程便能獲得該對象的引用,即時編譯器就無法追蹤所有使用該對象的代碼位置。
對象是否被傳入未知代碼中,即時編譯器會將未被內(nèi)聯(lián)的代碼當成未知代碼,因為它無法確認該方法調(diào)用會不會將調(diào)用者或所傳入的參數(shù)存儲至堆中,這種情況,可以直接認為方法調(diào)用的調(diào)用者以及參數(shù)是逃逸的。
逃逸分析通常是在方法內(nèi)聯(lián)的基礎(chǔ)上進行的,即時編譯器可以根據(jù)逃逸分析的結(jié)果進行諸如鎖消除、棧上分配以及標量替換的優(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; } } }
在這個例子中,創(chuàng)建了兩個對象foo和bar,其中一個作為另一個方法的參數(shù)提供。該方法setFoo()存儲對收到的Foo對象的引用。如果Bar對象在堆上,則對Foo的引用將逃逸。但是在這種情況下,編譯器可以通過逃逸分析確定Bar對象本身不會對逃逸出example()的調(diào)用。這意味著對Foo的引用也不能逃逸。因此,編譯器可以安全地在棧上分配兩個對象。
鎖消除
在學習Java并發(fā)編程時會了解鎖消除,而鎖消除就是在逃逸分析的基礎(chǔ)上進行的。
如果即時編譯器能夠證明鎖對象不逃逸,那么對該鎖對象的加鎖、解鎖操作沒就有意義。因為線程并不能獲得該鎖對象。在這種情況下,即時編譯器會消除對該不逃逸鎖對象的加鎖、解鎖操作。實際上,編譯器僅需證明鎖對象不逃逸出線程,便可以進行鎖消除。由于Java虛擬機即時編譯的限制,上述條件被強化為證明鎖對象不逃逸出當前編譯的方法。不過,基于逃逸分析的鎖消除實際上并不多見。
棧上分配
我們都知道Java的對象是在堆上分配的,而堆是對所有對象可見的。同時,JVM需要對所分配的堆內(nèi)存進行管理,并且在對象不再被引用時回收其所占據(jù)的內(nèi)存。如果逃逸分析能夠證明某些新建的對象不逃逸,那么JVM完全可以將其分配至棧上,并且在new語句所在的方法退出時,通過彈出當前方法的棧楨來自動回收所分配的內(nèi)存空間。這樣一來,我們便無須借助垃圾回收器來處理不再被引用的對象。不過Hotspot虛擬機,并沒有進行實際的棧上分配,而是使用了標量替換這一技術(shù)。所謂的標量,就是僅能存儲一個值的變量,比如Java代碼中的基本類型。與之相反,聚合量則可能同時存儲多個值,其中一個典型的例子便是Java的對象。編譯器會在方法內(nèi)將未逃逸的聚合量分解成多個標量,以此來減少堆上分配。下面是一個標量替換的例子:
標量替換
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進行分解,得到兩個標量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對于概率預測的應用。通常來說,如果發(fā)現(xiàn)一個對象逃逸出了方法或者線程,JVM就不會去進行優(yōu)化,但是Graal編譯器依然會去分析當前程序的執(zhí)行路徑,它會在逃逸分析基礎(chǔ)上收集、判斷哪些路徑上對象會逃逸,哪些不會。然后根據(jù)這些信息,在不會逃逸的路徑上進行鎖消除、棧上分配這些優(yōu)化手段。
在文章中介紹C2編譯器的部分有提及到,C2編譯器在構(gòu)建Ideal Graph后會進行很多的全局優(yōu)化,其中就包括對循環(huán)的轉(zhuǎn)換,最重要的兩種轉(zhuǎn)換就是循環(huán)展開和循環(huán)分離。
循環(huán)展開
循環(huán)展開是一種循環(huán)轉(zhuǎn)換技術(shù),它試圖以犧牲程序二進制碼大小為代價來優(yōu)化程序的執(zhí)行速度,是一種用空間換時間的優(yōu)化手段。
循環(huán)展開通過減少或消除控制程序循環(huán)的指令,來減少計算開銷,這種開銷包括增加指向數(shù)組中下一個索引或者指令的指針算數(shù)等。如果編譯器可以提前計算這些索引,并且構(gòu)建到機器代碼指令中,那么程序運行時就可以不必進行這種計算。也就是說有些循環(huán)可以寫成一些重復獨立的代碼。比如下面這個循環(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)的計算也可以利用CPU的流水線提升效率。當然這只是一個示例,實際進行展開時,JVM會去評估展開帶來的收益,再決定是否進行展開。
循環(huán)分離
循環(huán)分離也是循環(huán)轉(zhuǎn)換的一種手段。它把循環(huán)中一次或多次的特殊迭代分離出來,在循環(huán)外執(zhí)行。舉個例子,下面這段代碼:
循環(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變量的需求,從而減少了開銷。
前文提到的窺孔優(yōu)化是優(yōu)化的最后一步,這之后就會程序就會轉(zhuǎn)換成機器碼,窺孔優(yōu)化就是將編譯器所生成的中間代碼(或目標代碼)中相鄰指令,將其中的某些組合替換為效率更高的指令組,常見的比如強度削減、常數(shù)合并等,看下面這個例子就是一個強度削減的例子:
強度削減
y1=x1*3 經(jīng)過強度削減后得到 y1=(x1<<1)+x1
編譯器使用移位和加法削減乘法的強度,使用更高效率的指令組。
寄存器分配也是一種編譯的優(yōu)化手段,在C2編譯器中普遍的使用。它是通過把頻繁使用的變量保存在寄存器中,CPU訪問寄存器的速度比內(nèi)存快得多,可以提升程序的運行速度。
寄存器分配和窺孔優(yōu)化是程序優(yōu)化的最后一步。經(jīng)過寄存器分配和窺孔優(yōu)化之后,程序就會被轉(zhuǎn)換成機器碼保存在codeCache中。
即時編譯器情況復雜,同時網(wǎng)絡上也很少有實戰(zhàn)經(jīng)驗,以下是我們團隊的一些調(diào)整經(jīng)驗。
-XX:+TieredCompilation:開啟分層編譯,JDK8之后默認開啟
-XX:+CICompilerCount=N:編譯線程數(shù),設(shè)置數(shù)量后,JVM會自動分配線程數(shù),C1:C2 = 1:2
-XX:TierXBackEdgeThreshold:OSR編譯的閾值
-XX:TierXMinInvocationThreshold:開啟分層編譯后各層調(diào)用的閾值
-XX:TierXCompileThreshold:開啟分層編譯后的編譯閾值
-XX:ReservedCodeCacheSize:codeCache最大大小
-XX:InitialCodeCacheSize:codeCache初始大小
-XX:TierXMinInvocationThreshold是開啟分層編譯的情況下,觸發(fā)編譯的閾值參數(shù),當方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXInvocationThreshold指定的閾值乘以系數(shù),或者當方法調(diào)用次數(shù)大于由參數(shù)-XX:TierXMINInvocationThreshold指定的閾值乘以系數(shù),并且方法調(diào)用次數(shù)和循環(huán)回邊次數(shù)之和大于由參數(shù)-XX:TierXCompileThreshold指定的閾值乘以系數(shù)時,便會觸發(fā)X層即時編譯。分層編譯開啟下會乘以一個系數(shù),系數(shù)根據(jù)當前編譯的方法和編譯線程數(shù)確定,降低閾值可以提升編譯方法數(shù),一些常用但是不能編譯的方法可以編譯優(yōu)化提升性能。
由于編譯情況復雜,JVM也會動態(tài)調(diào)整相關(guān)的閾值來保證JVM的性能,所以不建議手動調(diào)整編譯相關(guān)的參數(shù)。除非一些特定的Case,比如codeCache滿了停止了編譯,可以適當增加codeCache大小,或者一些非常常用的方法,未被內(nèi)聯(lián)到,拖累了性能,可以調(diào)整內(nèi)斂層數(shù)或者內(nèi)聯(lián)方法的大小來解決。
通過增加-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining -XX:+PrintCodeCache -XX:+PrintCodeCacheOnCompilation -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=LogPath參數(shù)可以輸出編譯、內(nèi)聯(lián)、codeCache信息到文件。但是打印的編譯日志多且復雜很難直接從其中得到信息,可以使用JITwatch的工具來分析編譯日志。JITwatch首頁的Open Log選中日志文件,點擊Start就可以開始分析日志。
如上圖所示,區(qū)域1中是整個項目Java Class包括引入的第三方依賴;區(qū)域2是功能區(qū)Timeline以圖形的形式展示JIT編譯的時間軸,Histo是直方圖展示一些信息,TopList里面是編譯中產(chǎn)生的一些對象和數(shù)據(jù)的排序,Cache是空閑codeCache空間,NMethod是Native方法,Threads是JIT編譯的線程;區(qū)域3是JITwatch對日志分析結(jié)果的展示,其中Suggestions中會給出一些代碼優(yōu)化的建議,舉個例子,如下圖中:
我們可以看到在調(diào)用ZipInputStream的read方法時,因為該方法沒有被標記為熱點方法,同時又“太大了”,導致無法被內(nèi)聯(lián)到。使用-XX:CompileCommand中inline指令可以強制方法進行內(nèi)聯(lián),不過還是建議謹慎使用,除非確定某個方法內(nèi)聯(lián)會帶來不少的性能提升,否則不建議使用,并且過多使用對編譯線程和codeCache都會帶來不小的壓力。
區(qū)域3中的-Allocs和-Locks逃逸分析后JVM對代碼做的優(yōu)化,包括棧上分配、鎖消除等。
由于JVM會去根據(jù)當前的編譯方法數(shù)和編譯線程數(shù)對編譯閾值進行動態(tài)的調(diào)整,所以實際服務中對這一部分的調(diào)整空間是不大的,JVM做的已經(jīng)足夠多了。
為了提升性能,在服務中嘗試了最新的Graal編譯器。只需要使用-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler就可以啟動Graal編譯器來代替C2編譯器,并且響應C2的編譯請求,不過要注意的是,Graal編譯器與ZGC不兼容,只能與G1搭配使用。
前文有提到過,Graal是一個用Java寫的即時編譯器,它從Java 9開始便被集成自JDK中,作為實驗性質(zhì)的即時編譯器。Graal編譯器就是脫身于GraalVM,GraalVM是一個高性能的、支持多種編程語言的執(zhí)行環(huán)境。它既可以在傳統(tǒng)的 OpenJDK上運行,也可以通過AOT(Ahead-Of-Time)編譯成可執(zhí)行文件單獨運行,甚至可以集成至數(shù)據(jù)庫中運行。
前文提到過數(shù)次,Graal的優(yōu)化都基于某種假設(shè)(Assumption)。當假設(shè)出錯的情況下,Java虛擬機會借助去優(yōu)化(Deoptimization)這項機制,從執(zhí)行即時編譯器生成的機器碼切換回解釋執(zhí)行,在必要情況下,它甚至會廢棄這份機器碼,并在重新收集程序profile之后,再進行編譯。
這些中激進的手段使得Graal的峰值性能要好于C2,而且在Scale、Ruby這種語言Graal表現(xiàn)更加出色,Twitter目前已經(jīng)在服務中大量的使用Graal來提升性能,企業(yè)版的GraalVM使得Twitter服務性能提升了22%。
使用Graal編譯器后性能表現(xiàn)
在我們的線上服務中,啟用Graal編譯后,TP9999從60ms -> 50ms ,下降10ms,下降幅度達16.7%。
運行過程中的峰值性能會更高??梢钥闯鰧τ谠摲?,Graal編譯器帶來了一定的性能提升。
Graal編譯器的問題
Graal編譯器的優(yōu)化方式更加激進,因此在啟動時會進行更多的編譯,Graal編譯器本身也需要被即時編譯,所以服務剛啟動時性能會比較差。
考慮的解決辦法:JDK 9開始提供工具jaotc,同時GraalVM的Native Image都是可以通過靜態(tài)編譯,極大地提升服務的啟動速度的方式,但是GraalVM會使用自己的垃圾回收,這是一種很原始的基于復制算法的垃圾回收,相比G1、ZGC這些優(yōu)秀的新型垃圾回收器,它的性能并不好。同時GraalVM對Java的一些特性支持也不夠,比如基于配置的支持,比如反射就需要把所有需要反射的類配置一個JSON文件,在大量使用反射的服務,這樣的配置會是很大的工作量。我們也在做這方面的調(diào)研。
上述就是小編為大家分享的Java中即時編譯器的原理是什么了,如果剛好有類似的疑惑,不妨參照上述分析進行理解。如果想知道更多相關(guān)知識,歡迎關(guān)注創(chuàng)新互聯(lián)行業(yè)資訊頻道。
文章名稱:Java中即時編譯器的原理是什么
URL地址:http://chinadenli.net/article0/ggidio.html
成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供品牌網(wǎng)站設(shè)計、、自適應網(wǎng)站、手機網(wǎng)站建設(shè)、移動網(wǎng)站建設(shè)、App開發(fā)
聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時間刪除。文章觀點不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時需注明來源: 創(chuàng)新互聯(lián)