在部分的商用虛擬機器中,Java 程式最初是通過直譯器(Interpreter )進行解釋執行的,當虛擬機器發現某個方法或程式碼塊的執行特別頻繁的時候,就會把這些程式碼認定為“熱點程式碼”。為了提高熱點程式碼的執行效率,在執行時,即時編譯器(Just In Time Compiler )會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化。
一.HotSpot 內的即時編譯器
1.直譯器和編譯器各有各的優點:
直譯器優點:當程式需要迅速啟動的時候,直譯器可以首先發揮作用,省去了編譯的時間,立即執行。解釋執行佔用更小的記憶體空間。同時,當編譯器進行的激進優化失敗的時候,還可以進行逆優化來恢復到解釋執行的狀態。
編譯器優點:在程式執行時,隨著時間的推移,編譯器逐漸發揮作用根據熱點探測功能,,將有價值的位元組碼編譯為本地機器指令,以換取更高的程式執行效率。
因此,整個虛擬機器執行架構中,直譯器與編譯器經常配合工作,如下圖所示。
HotSpot中內建了兩個即時編譯器,分別稱為 Client Compiler和 Server Compiler ,或者簡稱為 C1編譯器和 C2編譯器。目前的 HotSpot編譯器預設的是直譯器和其中一個即時編譯器配合的方式工作,具體是哪一個編譯器,取決於虛擬機器執行的模式,HotSpot虛擬機器會根據自身版本與計算機的硬體效能自動選擇執行模式,使用者也可以使用 -client和 -server引數強制指定虛擬機器執行在 Client模式或者 Server模式。這種配合使用的方式稱為“混合模式”(Mixed Mode),使用者可以使用引數 -Xint 強制虛擬機器執行於 “解釋模式”(Interpreted Mode),這時候編譯器完全不介入工作。另外,使用 -Xcomp 強制虛擬機器執行於 “編譯模式”(Compiled Mode),這時候將優先採用編譯方式執行,但是直譯器仍然要在編譯無法進行的情況下接入執行過程。通過虛擬機器 -version 命令可以檢視當前預設的執行模式。
二.被編譯物件和觸發條件
1.在執行過程中會被即時編譯的“熱點程式碼”有兩類,即:
- 被多次呼叫的方法
- 被多次執行的迴圈體
對於第一種,編譯器會將整個方法作為編譯物件,這也是標準的JIT 編譯方式。對於第二種是由迴圈體出發的,但是編譯器依然會以整個方法作為編譯物件,因為發生在方法執行過程中,稱為棧上替換。
判斷一段程式碼是否是熱點程式碼,是不是需要出發即時編譯,這樣的行為稱為熱點探測(Hot Spot Detection),探測演算法有兩種,分別為。
基於取樣的熱點探測(Sample Based Hot Spot Detection):虛擬機器會週期的對各個執行緒棧頂進行檢查,如果某些方法經常出現在棧頂,這個方法就是“熱點方法”。好處是實現簡單、高效,很容易獲取方法呼叫關係。缺點是很難確認方法的reduce,容易受到執行緒阻塞或其他外因擾亂。
基於計數器的熱點探測(Counter Based Hot Spot Detection):為每個方法(甚至是程式碼塊)建立計數器,執行次數超過閾值就認為是“熱點方法”。優點是統計結果精確嚴謹。缺點是實現麻煩,不能直接獲取方法的呼叫關係。
HotSpot 使用的是第二種-基於技術其的熱點探測,並且有兩類計數器:方法呼叫計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。
這兩個計數器都有一個確定的閾值,超過後便會觸發 JIT 編譯。
第二個回邊計數器,作用是統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為“回邊”(Back Edge )。顯然,建立回邊計數器統計的目的就是為了觸發OSR 編譯。關於這個計數器的閾值,HotSpot 提供了-XX:BackEdgeThreshold供使用者設定,但是當前的虛擬機器實際上使用了 -XX:OnStackReplacePercentage來簡介調整閾值,計算公式如下:
在 Client 模式下, 公式為 方法呼叫計數器閾值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/ 100。其中 OSR比率預設為 933,那麼,回邊計數器的閾值為13995。
在 Server 模式下,公式為 方法呼叫計數器閾值(Compile Threashold)X(OSR (OnStackReplacePercentage)-直譯器監控比率 (InterpreterProfilePercent))/100其中 onStackReplacePercentage 預設值為 140,InterpreterProfilePercentage預設值為 33,如果都取預設值,那麼Server 模式虛擬機器回邊計數器閾值為10700 。
執行過程,如下圖。
三.編譯模式
預設情況下,無論是方法呼叫產生的即時編譯請求,還是 OSR 請求,虛擬機器在程式碼編譯器還未完成之前,都仍然將按照解釋方式繼續執行,而編譯動作則在後臺的編譯執行緒中進行,使用者可以通過引數 -XX:-BackgroundCompilation來禁止後臺編譯,這樣,一旦達到 JIT的編譯條件,執行執行緒向虛擬機器提交便已請求之後便會一直等待,直到編譯過程完成後再開始執行編譯器輸出的原生程式碼。
1.對於 Client 模式而言
**client compiler,又稱C1編譯器,較為輕量,只做少量效能開銷比較高的優化,它佔用記憶體較少,適合於桌面互動式應用。**在暫存器分配策略上,JDK6以後採用的為線性掃描暫存器分配演算法,其他方面的優化,主要有方法內聯、去虛擬化、冗餘消除等。
它是一個簡單快速的三段式編譯器,主要關注點在於區域性的優化,放棄了許多耗時較長的全域性優化手段。
1. 第一階段,一個平臺獨立的前端將位元組碼構造成一種高階中間程式碼表示(High-Level Intermediate Representaion, HIR)。在此之前,編譯器會在位元組碼上完成一部分基礎優化,如 方法內聯,常量傳播等優化。
2. 第二階段,一個平臺相關的後端從 HIR 中產生低階中間程式碼表示(Low-Level Intermediate Representation,LIR),而在此之前會在HIR 上完成另外一些優化,如空值檢查消除,範圍檢查消除等,讓HIR更為高效。
3. 第三階段,在平臺相關的後端使用線性掃描演算法(Linear Scan Register Allocation)在LIR 上分配暫存器,做窺孔(Peephole)優化,然後產生機器碼。
Client Compiler 的大致執行過程如下圖所示:
A、方法內聯
多個方法呼叫,執行時要經歷多次引數傳遞,返回值傳遞及跳轉等,C1採用方法內聯,把呼叫到的方法的指令直接植入當前方法中。-XX:+PringInlining來檢視方法內聯資訊,-XX:MaxInlineSize=35控制編譯後檔案大小。
B、去虛擬化
是指在裝載class檔案後,進行類層次的分析,如果發現類中的方法只提供一個實現類,那麼對於呼叫了此方法的程式碼,也可以進行方法內聯,從而提升執行的效能。
C、冗餘消除
在編譯時根據執行時狀況進行程式碼摺疊或消除。
2.對於 Server Compiler 模式而言
Server compiler,稱為C2編譯器,較為重量,採用了大量傳統編譯優化的技巧來進行優化,佔用記憶體相對多一些,適合伺服器端的應用。和C1的不同主要在於暫存器分配策略及優化範圍,暫存器分配策略上C2採用的為傳統的圖著色暫存器分配演算法,由於C2會收集程式執行資訊,因此其優化範圍更多在於全域性優化,不僅僅是一個方塊的優化。收集的資訊主要有:分支的跳轉/不跳轉的頻率、某條指令上出現過的型別、是否出現過空值、是否出現過異常等。
它是專門面向服務端的典型應用,併為服務端的效能配置特別調整過的編譯器,也是一個充分優化過的高階編譯器,幾乎能達到 GNU C++編譯器使用-O2 引數時的優化強度,它會執行所有的經典的優化動作,如無用程式碼消除(Dead Code Elimination)、迴圈展開(Loop Unrolling)、迴圈表示式外提(Loop Expression Hoisting)、消除公共子表示式(Common Subexpression Elimination)、常量傳播(Constant Propagation)、基本塊衝排序(Basic Block Reordering)等,還會實施一些與Java 語言特性密切相關的優化技術,如範圍檢查消除(Range Check Elimination)、空值檢查消除(Null Check Elimination ,不過並非所有的空值檢查消除都是依賴編譯器優化的,有一些是在程式碼執行過程中自動優化 了)等。另外,還可能根據直譯器或Client Compiler提供的效能監控資訊,進行一些不穩定的激進優化,如守護內聯(Guarded Inlining)、分支頻率預測(Branch Frequency Prediction)等。
Server Compiler 編譯器可以充分利用某些處理器架構,如(RISC)上的大暫存器集合。從即時編譯的角度來看,Server Compiler 無疑是比較緩慢的,但它的便以速度仍遠遠超過傳統的靜態優化編譯器,而且它相對於Client Compiler編譯輸出的程式碼質量有所提高,可以減少原生程式碼的執行時間,從而抵消了額外的編譯時間開銷,所以也有很多非服務端的應用選擇使用Server 模式的虛擬機器執行。
逃逸分析是C2進行很多優化的基礎,它根據執行狀態來判斷方法中的變數是否會被外部讀取,如不會則認為此變數是不會逃逸的,那麼在編譯時會做標量替換、棧上分配和同步消除等優化。
方法逃逸+執行緒逃逸
1)方法逃逸:分析物件的動態作用域,當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞給其他方法,稱為方法逃逸。
1)執行緒逃逸:甚至還有可能被外部執行緒訪問到,比如賦值給類變數或可以在其他執行緒中訪問到的例項變數,稱為執行緒逃逸。
1. 標量替換
如果把一個java物件拆散,根據程式訪問的情況,將其使用到的成員變數恢復原始型別來訪問就叫做標量替換;那程式真正執行的時候,將可能不建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來代替。
這樣做的好處是如果建立的物件並未用到其中的全部變數,則可以節省一定的記憶體。對於程式碼執行而言,無需去找物件的引用,也會更快一些。
2. 棧上分配
如果point沒有逃逸,那麼C2會選擇在棧上直接建立Point物件的例項,而不是在JVM堆上。在棧上分配的好處一方面是加快速度,另一方面是回收時隨著方法的結束,物件被回收了。
3. 同步消除
如果發現同步的物件未逃逸,因為不存在其它執行緒參與該物件的競爭,那也就沒有必要進行同步了,C2編譯時會直接去掉同步。執行緒同步本身就是一個相對耗時的過程,如果逃逸分析能夠確定一個變數不會逃逸出執行緒,無法被其他執行緒訪問,那這個變數的讀寫肯定就不會有競爭,對這個變數實施的同步措施就可以消除掉。
C2還會基於擁有的執行資訊來做其他優化,比如編譯分支頻率執行高的程式碼等。
4.對於分層編譯而言
Java7預設開啟分層編譯(tiered compilation)策略,由C1編譯器和C2編譯器相互協作共同來執行編譯任務。C1編譯器會對位元組碼進行簡單和可靠的優化,以達到更快的編譯速度;C2編譯器會啟動一些編譯耗時更長的優化,以獲取更好的編譯質量。
(1)直譯器不再收集執行狀態資訊,只用於啟動並觸發C1編譯
(2)C1編譯後生成帶收集執行資訊的程式碼
(3)C2編譯,基於C1編譯後程式碼收集的執行資訊進行激進優化,當激進優化的假設不成立時,再退回使用C1編譯的程式碼
直譯器與編譯器並存
如果選用完全解釋策略,那麼編譯器將停止所有的工作,位元組碼將完全依靠直譯器逐行解釋執行。
如果選用完全編譯策略,那麼直譯器仍然會在編譯器無法進行的特殊情況下介入執行,這主要是確保程式能夠最終順序執行。
SunJDK之所以未選擇在啟動時即編譯成機器碼的原因如下:
(1)靜態編譯並不能根據程式的執行狀態來優化執行的程式碼,C2這種方式是根據執行狀態來進行動態編譯的,例如分支判斷、逃逸分析等,這些措施會對提升程式執行的效能起到很大的幫助,在靜態編譯的情況下是無法實現的,給C2收集執行資料越長的時間,編譯出來的程式碼會越優。
(2)解釋執行比編譯執行更節省記憶體
(3)啟動時解釋執行的啟動速度比編譯再啟動更快。
四.編譯過程
1.編譯期優化(早期優化)
為了保證JRuby,Groovy等語言編譯的位元組碼也能得到效能優化,JVM將效能優化放在了後期的執行時優化,即JIT執行時編譯優化中。
具體優化:
1. 編譯期優化主要為語法糖,用來實現Java的各種新的語法特性,比如泛型,變長引數,自動裝箱/拆箱。
2 .Java語法糖:與位元組碼無關,編譯後會去掉它們。作用僅僅為方便碼農寫程式碼,以及將執行時異常在編譯期及早發現(如泛型的使用)。
* 泛型與型別擦除
Java泛型只在編譯期存在,編譯完成後的位元組碼中會替換為原生型別。故稱Java泛型為偽泛型。C#的泛型在執行期仍然存在。
* 條件編譯
if語句中使用常量。比如if(false) {},這個語句塊不會被編譯到位元組碼中.這個過程在編譯時的控制流分析中完成。
2.執行時優化(晚期優化)
- 不同JVM的執行時優化策略
Hotspot採用直譯器與編譯器並存的構架。
- 第0層,解釋執行,不開啟效能監控器,可觸發第一層編譯
- 第1層,將位元組碼編譯為機器碼,進行簡單可靠的優化,可以開啟效能監控
- 第2層,將位元組碼編譯為機器碼,會開啟一些編譯耗時的優化和一些不可靠的激進
- 具體優化:
- 公共字表示式消除
如果一個表示式E已經被計算過了,並且從先前的計算到現在E中所有變數的值都沒有發生變化,那麼E的這次出現就稱為了公共子表示式。對於這種表示式,沒有必要花時間再對它進行計算,只需要直接用前面計算過的表示式結果代替E就可以了。 - 陣列邊界檢查消除
陣列邊界檢查消除(Array Bounds Checking Elimination)是即時編譯器中的一項語言相關的經典優化技術。Java訪問陣列的時候系統將會自動進行上下界的範圍檢查,但對於虛擬機器的執行子 系統來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這無疑也是一種效能負擔。
陣列邊界檢查時必須做的,但陣列邊界檢查在某些情況下可以簡化。例如陣列下標示一個常量,如foo3,只要在編譯器根據資料流分析來確定foo.length的值,並判斷下標“3”沒有越界,執行的時候就無須判斷了。再例如陣列訪問發生在迴圈之中,並且使用迴圈變數來進行陣列訪問,如果編譯器只要通過資料流分析就可以判定迴圈變數的取值範圍永遠在區間[0, foo.length)之內,那在整個迴圈中就可以把陣列的上下界檢查消除掉,這可以節省很多次的條件判斷操作。
與語言相關的其他消除操作還有自動裝箱消除(Autobox Elimination)、安全點消除(Safepoint Elimination)、消除反射(Dereflection)等。 - 方法內聯
方法內聯是編譯器最重要的優化手段之一,除了消除方法呼叫的成本之外,更重要的是可以為其他優化手段建立良好的基礎。 - 逃逸分析
逃逸分析(Escape Analysis)並不是直接優化程式碼的手段,而是為其他優化手段提供依據的分析技術。
- 公共字表示式消除
五. Java即時編譯與C/C++編譯對比
1.劣勢
- Java即時編譯是在執行時,故會佔用使用者程式的執行時間。而C/C++是靜態編譯為機器碼的,完全不佔用執行時間。
- Java執行時不能進行一些比較耗時的優化,故能做的優化也沒有C那麼多
- 多型選擇頻率遠高於C,需要建立虛方法表。也正是多型的存在,使得編譯優化難度遠高於C。因為多型較難預測程式碼跳轉分支。
- Java執行時可以載入新的類,如網路中的二進位制流。這使得編譯器無法看清程式全貌,全域性優化很難進行。
- Java物件都是在堆上分配(除了class物件在方法區),而C/C++既可以在堆上,又可以在棧上。棧上分配可以減輕垃圾回收壓力,且速度遠快於堆。
2. 優勢
- 可以進行效能監控,熱點探測,分支頻率預測,呼叫頻率預測,從而有選擇性的優化程式碼
Refer: