java编译_解析 Java 即时编译器原理。
一、導讀
常見的編譯型語言如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)非常成熟了,在性能層面甚至可以和編譯型語言相比。不過在這個領域,大家依然在不斷探索如何結合不同的編譯方式,使用更加智能的手段來提升程序的運行速度。
二、Java的執(zhí)行過程
Java的執(zhí)行過程整體可以分為兩個部分,第一步由javac將源碼編譯成字節(jié)碼,在這個過程中會進行詞法分析、語法分析、語義分析,編譯原理中這部分的編譯稱為前端編譯。接下來無需編譯直接逐條將字節(jié)碼解釋執(zhí)行,在解釋執(zhí)行的過程中,虛擬機同時對程序運行的信息進行收集,在這些信息的基礎上,編譯器會逐漸發(fā)揮作用,它會進行后端編譯——把字節(jié)碼編譯成機器碼,但不是所有的代碼都會被編譯,只有被JVM認定為的熱點代碼,才可能被編譯。
怎么樣才會被認為是熱點代碼呢?JVM中會設置一個閾值,當方法或者代碼塊的在一定時間內(nèi)的調(diào)用次數(shù)超過這個閾值時就會被編譯,存入codeCache中。當下次執(zhí)行時,再遇到這段代碼,就會從codeCache中讀取機器碼,直接執(zhí)行,以此來提升程序運行的性能。整體的執(zhí)行過程大致如下圖所示:
1. JVM中的編譯器
JVM中集成了兩種編譯器,Client Compiler和Server Compiler,它們的作用也不同。Client Compiler注重啟動速度和局部的優(yōu)化,Server Compiler則更加關注全局的優(yōu)化,性能會更好,但由于會進行更多的全局分析,所以啟動速度會變慢。兩種編譯器有著不同的應用場景,在虛擬機中同時發(fā)揮作用。
Client Compiler
HotSpot VM帶有一個Client Compiler ?C1編譯器。這種編譯器啟動速度快,但是性能比較Server Compiler來說會差一些。C1會做三件事:
局部簡單可靠的優(yōu)化,比如字節(jié)碼上進行的一些基礎優(yōu)化,方法內(nèi)聯(lián)、常量傳播等,放棄許多耗時較長的全局優(yōu)化。
將字節(jié)碼構造成高級中間表示(High-level Intermediate Representation,以下稱為HIR),HIR與平臺無關,通常采用圖結構,更適合JVM對程序進行優(yōu)化。
最后將HIR轉換成低級中間表示(Low-level Intermediate Representation,以下稱為LIR),在LIR的基礎上會進行寄存器分配、窺孔優(yōu)化(局部的優(yōu)化方式,編譯器在一個基本塊或者多個基本塊中,針對已經(jīng)生成的代碼,結合CPU自己指令的特點,通過一些認為可能帶來性能提升的轉換規(guī)則或者通過整體的分析,進行指令轉換,來提升代碼性能)等操作,最終生成機器碼。
Server Compiler
Server Compiler主要關注一些編譯耗時較長的全局優(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ù)流結合的圖數(shù)據(jù)結構,稱為Ideal Graph。Ideal Graph表示當前程序的數(shù)據(jù)流向和指令間的依賴關系,依靠這種圖結構,某些優(yōu)化步驟(尤其是涉及浮動代碼塊的那些優(yōu)化步驟)變得不那么復雜。
Ideal Graph的構建是在解析字節(jié)碼的時候,根據(jù)字節(jié)碼中的指令向一個空的Graph中添加節(jié)點,Graph中的節(jié)點通常對應一個指令塊,每個指令塊包含多條相關聯(lián)的指令,JVM會利用一些優(yōu)化技術對這些指令進行優(yōu)化,比如Global Value Numbering、常量折疊等,解析結束后,還會進行一些死代碼剔除的操作。生成Ideal Graph后,會在這個基礎上結合收集的程序運行信息來進行一些全局的優(yōu)化,這個階段如果JVM判斷此時沒有全局優(yōu)化的必要,就會跳過這部分優(yōu)化。
無論是否進行全局優(yōu)化,Ideal Graph都會被轉化為一種更接近機器層面的MachNode Graph,最后編譯的機器碼就是從MachNode Graph中得的,生成機器碼前還會有一些包括寄存器分配、窺孔優(yōu)化等操作。關于Ideal Graph和各種全局的優(yōu)化手段會在后面的章節(jié)詳細介紹。Server Compiler編譯優(yōu)化的過程如下圖所示:
Graal Compiler
從JDK 9開始,Hotspot VM中集成了一種新的Server Compiler,Graal編譯器。相比C2編譯器,Graal有這樣幾種關鍵特性:
前文有提到,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負責的編譯請求。
2. 分層編譯
在Java 7以前,需要研發(fā)人員根據(jù)服務的性質(zhì)去選擇編譯器。對于需要快速啟動的,或者一些不會長期運行的服務,可以采用編譯效率較高的C1,對應參數(shù)-client。長期運行的服務,或者對峰值性能有要求的后臺服務,可以采用峰值性能更好的C2,對應參數(shù)-server。Java 7開始引入了分層編譯的概念,它結合了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編譯。
如果方法比較小(比如Java服務中常見的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默認開啟分層編譯。
3. 即時編譯的觸發(fā)
Java虛擬機根據(jù)方法的調(diào)用次數(shù)以及循環(huán)回邊的執(zhí)行次數(shù)來觸發(fā)即時編譯。循環(huá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)體結尾增加循環(huán)回邊計數(shù)器的代碼,來對循環(huán)進行計數(shù)。
當方法的調(diào)用次數(shù)和循環(huán)回邊的次數(shù)的和,超過由參數(shù)-XX:CompileThreshold指定的閾值時(使用C1時,默認值為1500;使用C2時,默認值為10000),就會觸發(fā)即時編譯。
開啟分層編譯的情況下,-XX:CompileThreshold參數(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。
--
知識分享,時代前行!
~~ 時代Java
還有更多好文章……
請查看歷史文章和官網(wǎng),
↓有分享,有收獲~
總結
以上是生活随笔為你收集整理的java编译_解析 Java 即时编译器原理。的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 64位java_树莓派3B+安装64位u
- 下一篇: bsc是指什么_为什么KPI令人厌恶?中