java编译_解析 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的執行狀態分為了五個層次。五個層級分別是:
解釋執行。
執行不帶profiling的C1代碼。
執行僅帶方法調用次數以及循環回邊執行次數profiling的C1代碼。
執行帶所有profiling的C1代碼。
執行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。
--
知識分享,時代前行!
~~ 時代Java
還有更多好文章……
請查看歷史文章和官網,
↓有分享,有收獲~
總結
以上是生活随笔為你收集整理的java编译_解析 Java 即时编译器原理。的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 64位java_树莓派3B+安装64位u
- 下一篇: java字符串为空抛出异常_Java 判