如果我們把字節(jié)碼看作是程序語言的一種中間表示形式(Intermediate Representation,IR)的話,那編譯器無論在何時、在何種狀態(tài)下把Class文件轉(zhuǎn)換成與本地基礎(chǔ)設(shè)施(硬件指令集、操作系統(tǒng))相關(guān)的二進(jìn)制機(jī)器碼,它都可以視為整個編譯過程的后端
無論是提前編譯器(Ahead Of Time,AOT)抑或即時編譯器(Just In Time,JIT),都不是Java虛擬機(jī)必需的組成部分,《Java虛擬機(jī)規(guī)范》中從來沒有規(guī)定過虛擬機(jī)內(nèi)部必須要包含這些編譯器,更沒有限定或指導(dǎo)這些編譯器應(yīng)該如何去實現(xiàn)。但是,后端編譯器編譯性能的好壞、代碼優(yōu)化質(zhì)量的高低卻是衡量一款商用虛擬機(jī)優(yōu)秀與否的關(guān)鍵指標(biāo)之一,它們也是商業(yè)Java虛擬機(jī)中的核心,是最能體現(xiàn)技術(shù)水平與價值的功能。
即時編譯器
目前主流的兩款商用Java虛擬機(jī)(HotSpot、OpenJ9)里,Java程序最初都是通過解釋器(Interpreter)進(jìn)行解釋執(zhí)行的,當(dāng)虛擬機(jī)發(fā)現(xiàn)某個方法或代碼塊的運行特別頻繁,就會把這些代碼認(rèn)定為“熱點代碼”(Hot Spot Code),為了提高熱點代碼的執(zhí)行效率,在運行時,虛擬機(jī)將會把這些代碼編譯成本地機(jī)器碼,并以各種手段盡可能地進(jìn)行代碼優(yōu)化,運行時完成這個任務(wù)的后端編譯器被稱為即時編譯器,下面我們將會了解HotSpot虛擬機(jī)內(nèi)的即時編譯器的運作過程,此外,我們還將解決以
下幾個問題:
為何HotSpot虛擬機(jī)要使用解釋器與即時編譯器并存的架構(gòu)?
·為何HotSpot虛擬機(jī)要實現(xiàn)兩個(或三個)不同的即時編譯器?
·程序何時使用解釋器執(zhí)行?何時使用編譯器執(zhí)行?
·哪些程序代碼會被編譯為本地代碼?如何編譯本地代碼?
·如何從外部觀察到即時編譯器的編譯過程和編譯結(jié)果?
解釋器與編譯器
盡管并不是所有的Java虛擬機(jī)都采用解釋器與編譯器并存的運行架構(gòu),但目前主流的商用Java虛擬機(jī),譬如HotSpot、OpenJ9等,內(nèi)部都同時包含解釋器與編譯器,解釋器與編譯器兩者各有優(yōu)勢:當(dāng)程序需要迅速啟動和執(zhí)行的時候,解釋器可以首先發(fā)揮作用,省去編譯的時間,立即運行。當(dāng)程序啟動后,隨著時間的推移,編譯器逐漸發(fā)揮作用,把越來越多的代碼編譯成本地代碼,這樣可以減少解釋器的中間損耗,獲得更高的執(zhí)行效率。因此在整個Java虛擬機(jī)執(zhí)行架構(gòu)里,解釋器與編譯器經(jīng)常是相輔相成地配合工作,其交互關(guān)系如圖11-1所示。

HotSpot虛擬機(jī)中內(nèi)置了兩個(或三個)即時編譯器,其中有兩個編譯器存在已久,分別被稱為“客戶端編譯器”(Client Compiler)和“服務(wù)端編譯器”(Server Compiler),或者簡稱為C1編譯器和C2編譯器(部分資料和JDK源碼中C2也叫Opto編譯器),第三個是在JDK 10時才出現(xiàn)的、長期目標(biāo)是代替C2的Graal編譯器。Graal編譯器目前還處于實驗狀態(tài).
在分層編譯(Tiered Compilation)的工作模式出現(xiàn)以前,HotSpot虛擬機(jī)通常是采用解釋器與其中一個編譯器直接搭配的方式工作,程序使用哪個編譯器,只取決于虛擬機(jī)運行的模式,HotSpot虛擬機(jī)會根據(jù)自身版本與宿主機(jī)器的硬件性能自動選擇運行模式,用戶也可以使用“-client”或“-server”參數(shù)去強(qiáng)制指定虛擬機(jī)運行在客戶端模式還是服務(wù)端模式。
無論采用的編譯器是客戶端編譯器還是服務(wù)端編譯器,解釋器與編譯器搭配使用的方式在虛擬機(jī)中被稱為“混合模式”(Mixed Mode),用戶也可以使用參數(shù)“-Xint”強(qiáng)制虛擬機(jī)運行于“解釋模式”(Interpreted Mode),這時候編譯器完全不介入工作,全部代碼都使用解釋方式執(zhí)行。另外,也可以使用參數(shù)“-Xcomp”強(qiáng)制虛擬機(jī)運行于“編譯模式”(Compiled Mode),這時候?qū)?yōu)先采用編譯方式執(zhí)行程序,但是解釋器仍然要在編譯無法進(jìn)行的情況下介入執(zhí)行過程??梢酝ㄟ^虛擬機(jī)的“-
version”命令的輸出結(jié)果顯示出這三種模式,內(nèi)容如代碼清單11-1所示,請讀者注意黑體字部分。
$java -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)
$java -Xint -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)
$java -Xcomp -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)
由于即時編譯器編譯本地代碼需要占用程序運行時間,通常要編譯出優(yōu)化程度越高的代碼,所花費的時間便會越長;而且想要編譯出優(yōu)化程度更高的代碼,解釋器可能還要替編譯器收集性能監(jiān)控信息,這對解釋執(zhí)行階段的速度也有所影響。為了在程序啟動響應(yīng)速度與運行效率之間達(dá)到最佳平衡,HotSpot虛擬機(jī)在編譯子系統(tǒng)中加入了分層編譯的功能,分層編譯的概念其實很早就已經(jīng)提出,但直到JDK 6時期才被初步實現(xiàn),后來一直處于改進(jìn)階段,最終在JDK 7的服務(wù)端模式虛擬機(jī)中作為默認(rèn)編譯策略被開啟。分層編譯根據(jù)編譯器編譯、優(yōu)化的規(guī)模與耗時,劃分出不同的編譯層次,其中包括:
·第0層。程序純解釋執(zhí)行,并且解釋器不開啟性能監(jiān)控功能(Profiling)。
·第1層。使用客戶端編譯器將字節(jié)碼編譯為本地代碼來運行,進(jìn)行簡單可靠的穩(wěn)定優(yōu)化,不開啟性能監(jiān)控功能。
·第2層。仍然使用客戶端編譯器執(zhí)行,僅開啟方法及回邊次數(shù)統(tǒng)計等有限的性能監(jiān)控功能。
·第3層。仍然使用客戶端編譯器執(zhí)行,開啟全部性能監(jiān)控,除了第2層的統(tǒng)計信息外,還會收集如分支跳轉(zhuǎn)、虛方法調(diào)用版本等全部的統(tǒng)計信息。
·第4層。使用服務(wù)端編譯器將字節(jié)碼編譯為本地代碼,相比起客戶端編譯器,服務(wù)端編譯器會啟用更多編譯耗時更長的優(yōu)化,還會根據(jù)性能監(jiān)控信息進(jìn)行一些不可靠的激進(jìn)優(yōu)化。
以上層次并不是固定不變的,根據(jù)不同的運行參數(shù)和版本,虛擬機(jī)可以調(diào)整分層的數(shù)量。各層次編譯之間的交互、轉(zhuǎn)換關(guān)系如圖11-2所示。

實施分層編譯后,解釋器、客戶端編譯器和服務(wù)端編譯器就會同時工作,熱點代碼都可能會被多次編譯,用客戶端編譯器獲取更高的編譯速度,用服務(wù)端編譯器來獲取更好的編譯質(zhì)量,在解釋執(zhí)行的時候也無須額外承擔(dān)收集性能監(jiān)控信息的任務(wù),而在服務(wù)端編譯器采用高復(fù)雜度的優(yōu)化算法時,客戶端編譯器可先采用簡單優(yōu)化來為它爭取更多的編譯時間。
編譯對象與觸發(fā)條件
前面提到了在運行過程中會被即時編譯器編譯的目標(biāo)是“熱點代碼”,這里所指的熱點代碼主要有兩類,包括:
- 被多次調(diào)用的方法。
- 被多次執(zhí)行的循環(huán)體。
前者很好理解,一個方法被調(diào)用得多了,方法體內(nèi)代碼執(zhí)行的次數(shù)自然就多,它成為“熱點代碼”是理所當(dāng)然的。而后者則是為了解決當(dāng)一個方法只被調(diào)用過一次或少量的幾次,但是方法體內(nèi)部存在循環(huán)次數(shù)較多的循環(huán)體,這樣循環(huán)體的代碼也被重復(fù)執(zhí)行多次,因此這些代碼也應(yīng)該認(rèn)為是“熱點代碼”。
對于這兩種情況,編譯的目標(biāo)對象都是整個方法體,而不會是單獨的循環(huán)體。第一種情況,由于是依靠方法調(diào)用觸發(fā)的編譯,那編譯器理所當(dāng)然地會以整個方法作為編譯對象,這種編譯也是虛擬機(jī)中標(biāo)準(zhǔn)的即時編譯方式。而對于后一種情況,盡管編譯動作是由循環(huán)體所觸發(fā)的,熱點只是方法的一部分,但編譯器依然必須以整個方法作為編譯對象,只是執(zhí)行入口(從方法第幾條字節(jié)碼指令開始執(zhí)行)會稍有不同,編譯時會傳入執(zhí)行入口點字節(jié)碼序號(Byte Code Index,BCI)。這種編譯方式因為編譯發(fā)生在方法執(zhí)行的過程中,因此被很形象地稱為“棧上換”(On Stack Replacement,OSR),即方法的棧幀還在棧上,方法就被替換了。
在上面的描述里,無論是“多次執(zhí)行的方法”,還是“多次執(zhí)行的代碼塊”,
所謂“多次”只定性不定量,并不是一個具體嚴(yán)謹(jǐn)?shù)挠谜Z,那到底多少次才算“多次”呢?還有一個問題,就是Java虛擬機(jī)是如何統(tǒng)計某個方法或某段代碼被執(zhí)行過多少次的呢?解決了這兩個問題,也就解答了即時編譯被觸發(fā)的條件。
要知道某段代碼是不是熱點代碼,是不是需要觸發(fā)即時編譯,這個行為稱為“熱點探測”(HotSpot Code Detection),其實進(jìn)行熱點探測并不一定要知道方法具體被調(diào)用了多少次,目前主流的熱點探測判定方式有兩種,分別是:
基于采樣的熱點探測(Sample Based Hot Spot Code Detection)。采用這種方法的虛擬機(jī)會周期性地檢查各個線程的調(diào)用棧頂,如果發(fā)現(xiàn)某個(或某些)方法經(jīng)常出現(xiàn)在棧頂,那這個方法就是“熱點方法”?;诓蓸拥臒狳c探測的好處是實現(xiàn)簡單高效,還可以很容易地獲取方法調(diào)用關(guān)系(將調(diào)用堆棧展開即可),缺點是很難精確地確認(rèn)一個方法的熱度,容易因為受到線程阻塞或別的外界因素的影響而擾亂熱點探測。
基于計數(shù)器的熱點探測(Counter Based Hot Spot Code Detection)。采用這種方法的虛擬機(jī)會為每個方法(甚至是代碼塊)建立計數(shù)器,統(tǒng)計方法的執(zhí)行次數(shù),如果執(zhí)行次數(shù)超過一定的閾值就認(rèn)為它是“熱點方法”。這種統(tǒng)計方法實現(xiàn)起來要麻煩一些,需要為每個方法建立并維護(hù)計數(shù)器,而且不能直接獲取到方法的調(diào)用關(guān)系。但是它的統(tǒng)計結(jié)果相對來說更加精確嚴(yán)謹(jǐn)。
這兩種探測手段在商用Java虛擬機(jī)中都有使用到,譬如J9用過第一種采樣熱點探測,而在HotSpot虛擬機(jī)中使用的是第二種基于計數(shù)器的熱點探測方法,為了實現(xiàn)熱點計數(shù),HotSpot為每個方法準(zhǔn)備了兩類計數(shù)器:方法調(diào)用計數(shù)器(Invocation Counter)和回邊計數(shù)器(Back Edge Counter,“回邊”的意思就是指在循環(huán)邊界往回跳轉(zhuǎn))。當(dāng)虛擬機(jī)運行參數(shù)確定的前提下,這兩個計數(shù)器都有一個明確的閾值,計數(shù)器閾值一旦溢出,就會觸發(fā)即時編譯。
我們首先來看看方法調(diào)用計數(shù)器。顧名思義,這個計數(shù)器就是用于統(tǒng)計方法被調(diào)用的次數(shù),它的默認(rèn)閾值在客戶端模式下是1500次,在服務(wù)端模式下是10000次,這個閾值可以通過虛擬機(jī)參數(shù)-XX:CompileThreshold來人為設(shè)定。當(dāng)一個方法被調(diào)用時,虛擬機(jī)會先檢查該方法是否存在被即時編譯過的版本,如果存在,則優(yōu)先使用編譯后的本地代碼來執(zhí)行。如果不存在已被編譯過的版本,則將該方法的調(diào)用計數(shù)器值加一,然后判斷方法調(diào)用計數(shù)器與回邊計數(shù)器值之和是否超過方法調(diào)用計數(shù)器的閾值。一旦已超過閾值的話,將會向即時編譯器提交一個該方法的代碼編譯請求。
如果沒有做過任何設(shè)置,執(zhí)行引擎默認(rèn)不會同步等待編譯請求完成,而是繼續(xù)進(jìn)入解釋器按照解釋方式執(zhí)行字節(jié)碼,直到提交的請求被即時編譯器編譯完成。當(dāng)編譯工作完成后,這個方法的調(diào)用入口地址就會被系統(tǒng)自動改寫成新值,下一次調(diào)用該方法時就會使用已編譯的版本了,整個即時編譯的交互過程如圖11-3所示。

在默認(rèn)設(shè)置下,方法調(diào)用計數(shù)器統(tǒng)計的并不是方法被調(diào)用的絕對次數(shù),而是一個相對的執(zhí)行頻率,即一段時間之內(nèi)方法被調(diào)用的次數(shù)。當(dāng)超過一定的時間限度,如果方法的調(diào)用次數(shù)仍然不足以讓它提交給即時編譯器編譯,那該方法的調(diào)用計數(shù)器就會被減少一半,這個過程被稱為方法調(diào)用計數(shù)器熱度的衰減(Counter Decay),而這段時間就稱為此方法統(tǒng)計的半衰周期(Counter Half Life Time),進(jìn)行熱度衰減的動作是在虛擬機(jī)進(jìn)行垃圾收集時順便進(jìn)行的,可以使用虛擬機(jī)參數(shù)-XX:-
UseCounterDecay來關(guān)閉熱度衰減,讓方法計數(shù)器統(tǒng)計方法調(diào)用的絕對次數(shù),這樣只要系統(tǒng)運行時間足夠長,程序中絕大部分方法都會被編譯成本地代碼。另外還可以使用-XX:CounterHalfLifeTime參數(shù)設(shè)置半衰周期的時間,單位是秒。
現(xiàn)在我們再來看看另外一個計數(shù)器——回邊計數(shù)器,它的作用是統(tǒng)計一個方法中循環(huán)體代碼執(zhí)行的次數(shù),在字節(jié)碼中遇到控制流向后跳轉(zhuǎn)的指令就稱為“回邊(Back Edge)”,很顯然建立回邊計數(shù)器統(tǒng)計的目的是為了觸發(fā)棧上的替換編譯。
當(dāng)解釋器遇到一條回邊指令時,會先查找將要執(zhí)行的代碼片段是否有已經(jīng)編譯好的版本,如果有的話,它將會優(yōu)先執(zhí)行已編譯的代碼,否則就把回邊計數(shù)器的值加一,然后判斷方法調(diào)用計數(shù)器與回邊計數(shù)器值之和是否超過回邊計數(shù)器的閾值。當(dāng)超過閾值的時候,將會提交一個棧上替換編譯請求,并且把回邊計數(shù)器的值稍微降低一些,以便繼續(xù)在解釋器中執(zhí)行循環(huán),等待編譯器輸出編譯結(jié)果。
圖11-2和圖11-3都僅僅是描述了客戶端模式虛擬機(jī)的即時編譯方式,對于服務(wù)端模式虛擬機(jī)來說,執(zhí)行情況會比上面描述還要復(fù)雜一些。從理論上了解過編譯對象和編譯觸發(fā)條件后,我們還可以從HotSpot虛擬機(jī)的源碼中簡單觀察一下這兩個計數(shù)器,在MehtodOop.hpp(一個methodOop對象代表了一個Java方法)中,定義了Java方法在虛擬機(jī)中的內(nèi)存布局,如下所示:

編譯過程
在默認(rèn)條件下,無論是方法調(diào)用產(chǎn)生的標(biāo)準(zhǔn)編譯請求,還是棧上替換編譯請求,虛擬機(jī)在編譯器還未完成編譯之前,都仍然將按照解釋方式繼續(xù)執(zhí)行代碼,而編譯動作則在后臺的編譯線程中進(jìn)行。用戶可以通過參數(shù)-XX:-BackgroundCompilation來禁止后臺編譯,后臺編譯被禁止后,當(dāng)達(dá)到觸發(fā)即時編譯的條件時,執(zhí)行線程向虛擬機(jī)提交編譯請求以后將會一直阻塞等待,直到編譯過程完成再開始執(zhí)行編譯器輸出的本地代碼。
一般來說,Java虛擬機(jī)的即時編譯過程對用戶和程序都是完全透明的,虛擬機(jī)是通過解釋來執(zhí)行代碼還是通過編譯來執(zhí)行代碼,對于用戶來說并沒有什么影響(對執(zhí)行結(jié)果沒有影響,速度上會有顯著差別),大多數(shù)情況下用戶也沒有必要知道。但是HotSpot虛擬機(jī)還是提供了一些參數(shù)用來輸出即時編譯和某些優(yōu)化措施的運行狀況,以滿足調(diào)試和調(diào)優(yōu)的需要。本節(jié)將通過實戰(zhàn)說明如何從外部觀察Java虛擬機(jī)的即時編譯行為。
代碼清單11-2 測試代碼
public static final int NUM = 15000;
public static int doubleValue(int i) {
// 這個空循環(huán)用于后面演示JIT代碼優(yōu)化過程
for(int j=0; j<100000; j++);
return i * 2;
}
public static long calcSum() {
long sum = 0;
for (int i = 1; i <= 100; i++) {
sum += doubleValue(i);
}
return sum;
}
public static void main(String[] args) {
for (int i = 0; i < NUM; i++) {
calcSum();
}
}
我們首先來運行這段代碼,并且確認(rèn)這段代碼是否觸發(fā)了即時編譯。要知道某個方法是否被編譯過,可以使用參數(shù)-XX:+PrintCompilation要求虛擬機(jī)在即時編譯時將被編譯成本地代碼的方法名稱打印出來,如代碼清單11-3所示(其中帶有“%”的輸出說明是由回邊計數(shù)器觸發(fā)的棧上替換編譯)。
代碼清單11-3 被即時編譯的代碼
VM option '-XX:+PrintCompilation'
217 1 3 java.lang.StringBuilder::<init> (7 bytes)
218 3 3 java.io.UnixFileSystem::normalize (75 bytes)
218 5 n 0 java.lang.System::arraycopy (native) (static)
218 6 3 java.lang.String::equals (81 bytes)
219 2 3 java.lang.System::getSecurityManager (4 bytes)
220 8 3 java.lang.String::length (6 bytes)
220 9 3 java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)
220 7 4 java.lang.String::hashCode (55 bytes)
220 4 4 java.lang.String::charAt (29 bytes)
221 10 % 4 java.lang.String::indexOf @ 37 (70 bytes)
222 12 4 sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
222 13 3 java.lang.String::startsWith (72 bytes)
222 15 3 java.lang.Math::min (11 bytes)
222 14 3 java.lang.String::startsWith (7 bytes)
223 11 4 java.lang.Object::<init> (1 bytes)
223 16 3 java.util.HashMap::newNode (13 bytes)
224 18 3 java.util.HashMap::putVal (300 bytes)
225 17 3 java.util.HashMap::afterNodeInsertion (1 bytes)
225 19 % 3 jvm.jit.JitTest::doubleValue @ 2 (18 bytes)
226 20 3 jvm.jit.JitTest::doubleValue (18 bytes)
226 21 % 4 jvm.jit.JitTest::doubleValue @ 2 (18 bytes)
226 19 % 3 jvm.jit.JitTest::doubleValue @ -2 (18 bytes) made not entrant
226 22 4 jvm.jit.JitTest::doubleValue (18 bytes)
226 23 3 java.lang.StringBuilder::append (8 bytes)
227 20 3 jvm.jit.JitTest::doubleValue (18 bytes) made not entrant
227 24 3 jvm.jit.JitTest::calcSum (26 bytes)
228 25 % 4 jvm.jit.JitTest::calcSum @ 4 (26 bytes)
230 26 4 jvm.jit.JitTest::calcSum (26 bytes)
231 27 3 java.lang.String::<init> (82 bytes)
231 29 4 java.lang.String::indexOf (70 bytes)
231 24 3 jvm.jit.JitTest::calcSum (26 bytes) made not entrant
231 28 3 java.lang.String::indexOf (7 bytes)
從代碼清單11-3輸出的信息中可以確認(rèn),main()、calcSum()和doubleValue()方法已經(jīng)被編譯,我們還可以加上參數(shù)-XX:+PrintInlining要求虛擬機(jī)輸出方法內(nèi)聯(lián)信息.
提前編譯器
提前編譯在Java技術(shù)體系中并不是新事物。1996年JDK 1.0發(fā)布,Java有了正式的運行環(huán)境,第一個可以使用外掛即時編譯器的Java版本是1996年7月發(fā)布的JDK 1.0.2,而Java提前編譯器的誕生并沒有比這晚多少。僅幾個月后,IBM公司就推出了第一款用于Java語言的提前編譯器(IBM High Performance Compiler for Java)。在1998年,GNU組織公布了著名的GCC家族(GNU Compiler Collection)的新成員GNU Compiler for Java(GCJ,2018年從GCC家族中除名),這也是一款Java的提前編譯器,而且曾經(jīng)被廣泛應(yīng)用。在OpenJDK流行起來之前,各種Linux發(fā)行版帶的Java實現(xiàn)通常就是GCJ。
但是提前編譯很快又在Java世界里沉寂了下來,因為當(dāng)時Java的一個核心優(yōu)勢是平臺中立性,其宣傳口號是“一次編譯,到處運行”,這與平臺相關(guān)的提前編譯在理念上就是直接沖突的。GCJ出現(xiàn)之后在長達(dá)15年的時間里,提前編譯這條故事線上基本就再沒有什么大的新聞和進(jìn)展了。類似的狀況一直持續(xù)至2013年,直到在Android的世界里,劍走偏鋒使用提前編譯的ART(Android Runtime)橫空出世。ART一誕生馬上就把使用即時編譯的Dalvik虛擬機(jī)按在地上使勁蹂躪,僅經(jīng)過Android 4.4一個版本
的短暫交鋒之后,ART就迅速終結(jié)了Dalvik的性命,把它從Android系統(tǒng)里掃地出門。
盡管Android并不能直接等同于Java,但兩者畢竟有著深厚淵源,提前編譯在Android上的革命與崛起也震撼到了Java世界。在某些領(lǐng)域、某些人眼里,只要能獲得更好的執(zhí)行性能,什么平臺中立性、字節(jié)膨脹[3]、動態(tài)擴(kuò)展,一切皆可舍棄,唯一的問題就只有“提前編譯真的會是獲得更高性能的銀彈嗎?
提前編譯的優(yōu)劣得失
現(xiàn)在提前編譯產(chǎn)品和對其的研究有著兩條明顯的分支,一條分支是做與傳統(tǒng)C、C++編譯器類似的,在程序運行之前把程序代碼編譯成機(jī)器碼的靜態(tài)翻譯工作;另外一條分支是把原本即時編譯器在運行時要做的編譯工作提前做好并保存下來,下次運行到這些代碼(譬如公共庫代碼在被同一臺機(jī)器其他Java進(jìn)程使用)時直接把它加載進(jìn)來使用。
我們先來說第一條,這是傳統(tǒng)的提前編譯應(yīng)用形式,它在Java中存在的價值直指即時編譯的最大弱點:即時編譯要占用程序運行時間和運算資源。即使現(xiàn)在先進(jìn)的即時編譯器已經(jīng)足夠快,以至于能夠容忍相當(dāng)高的優(yōu)化復(fù)雜度了;即使現(xiàn)在先進(jìn)的即時編譯器架構(gòu)有了分層編譯的支持,可以先用快速但低質(zhì)量的即時編譯器為高質(zhì)量的即時編譯器爭取出更多編譯時間,但是,無論如何,即時編譯消耗的時間都是原本可用于程序運行的時間,消耗的運算資源都是原本可用于程序運行的資源,這個約束從未減弱,更不會消失,始終是懸在即時編譯頭頂?shù)倪_(dá)摩克利斯之劍。
關(guān)于提前編譯的第二條路徑,本質(zhì)是給即時編譯器做緩存加速,去改善Java程序的啟動時間,以及需要一段時間預(yù)熱后才能到達(dá)最高性能的問題。這種提前編譯被稱為動態(tài)提前編譯(DynamicAOT)或者索性就大大方方地直接叫即時編譯緩存(JIT Caching)。在目前的Java技術(shù)體系里,這條路徑的提前編譯已經(jīng)完全被主流的商用JDK支持。在商業(yè)應(yīng)用中,這條路徑最早出現(xiàn)在JDK 6版本的IBM J9虛擬機(jī)上,那時候在它的CDS(Class Data Sharing)功能的緩存中就有一塊是即時編譯緩存。不過這個緩存和CDS緩存一樣是虛擬機(jī)運行時自動生成的,直接來源于J9的即時編譯器,而且為了進(jìn)程兼容性,很多激進(jìn)優(yōu)化都不能肆意運用,所以編譯輸出的代碼質(zhì)量反而要低于即時編譯器。
真正引起業(yè)界普遍關(guān)注的是OpenJDK/OracleJDK 9中所帶的Jaotc提前編譯器,這是一個基于Graal編譯器實現(xiàn)的新工具,目的是讓用戶可以針對目標(biāo)機(jī)器,為應(yīng)用程序進(jìn)行提前編譯。HotSpot運行時可以直接加載這些編譯的結(jié)果,實現(xiàn)加快程序啟動速度,減少程序達(dá)到全速運行狀態(tài)所需時間的目的。這里面確實有比較大的優(yōu)化價值,試想一下,各種Java應(yīng)用最起碼會用到Java的標(biāo)準(zhǔn)類庫,如java.base等模塊,如果能夠?qū)⑦@個類庫提前編譯好,并進(jìn)行比較高質(zhì)量的優(yōu)化,顯然能夠節(jié)約不少應(yīng)用運行時的編譯成本。
最后,我們還要思考一個問題:提前編譯的代碼輸出質(zhì)量,一定會比即時編譯更高嗎?提前編譯因為沒有執(zhí)行時間和資源限制的壓力,能夠毫無顧忌地使用重負(fù)載的優(yōu)化手段,這當(dāng)然是一個極大的優(yōu)勢,但即時編譯難道就沒有能與其競爭的強(qiáng)項了嗎?當(dāng)然是有的,盡管即時編譯在時間和運算資源方面的劣勢是無法忽視的,但其依然有自己的優(yōu)勢。接下來便要開始即時編譯器的絕地反擊了
首先,是性能分析制導(dǎo)優(yōu)化(Profile-Guided Optimization,PGO)。上一節(jié)介紹HotSpot的即時編譯器時就多次提及在解釋器或者客戶端編譯器運行過程中,會不斷收集性能監(jiān)控信息,譬如某個程序點抽象類通常會是什么實際類型、條件判斷通常會走哪條分支、方法調(diào)用通常會選擇哪個版本、循環(huán)通常會進(jìn)行多少次等,這些數(shù)據(jù)一般在靜態(tài)分析時是無法得到的,或者不可能存在確定且唯一的解,最多只能依照一些啟發(fā)性的條件去進(jìn)行猜測。但在動態(tài)運行時卻能看出它們具有非常明顯的偏好性。如果一個條件分支的某一條路徑執(zhí)行特別頻繁,而其他路徑鮮有問津,那就可以把熱的代碼集中放到一起,集中優(yōu)化和分配更好的資源(分支預(yù)測、寄存器、緩存等)給它。
其次,是激進(jìn)預(yù)測性優(yōu)化(Aggressive Speculative Optimization),這也已經(jīng)成為很多即時編譯優(yōu)化措施的基礎(chǔ)。靜態(tài)優(yōu)化無論如何都必須保證優(yōu)化后所有的程序外部可見影響(不僅僅是執(zhí)行結(jié)果)與優(yōu)化前是等效的,不然優(yōu)化之后會導(dǎo)致程序報錯或者結(jié)果不對,若出現(xiàn)這種情況,則速度再快也是沒有價值的。然而,相對于提前編譯來說,即時編譯的策略就可以不必這樣保守,如果性能監(jiān)控信息能夠支持它做出一些正確的可能性很大但無法保證絕對正確的預(yù)測判斷,就已經(jīng)可以大膽地按照高概率的假設(shè)進(jìn)行優(yōu)化,萬一真的走到罕見分支上,大不了退回到低級編譯器甚至解釋器上去執(zhí)行,并不會出現(xiàn)無法挽救的后果。只要出錯概率足夠低,這樣的優(yōu)化往往能夠大幅度降低目標(biāo)程序的復(fù)雜度,輸出運行速度非常高的代碼。譬如在Java語言中,默認(rèn)方法都是虛方法調(diào)用,部分C、C++程序員(甚至一些老舊教材)會說虛方法是不能內(nèi)聯(lián)的,但如果Java虛擬機(jī)真的遇到虛方法就去查虛表而不做內(nèi)聯(lián)的話,Java技術(shù)可能就已經(jīng)因性能問題而被淘汰很多年了。實際上虛擬機(jī)會通過類繼承關(guān)系分析等一系列激進(jìn)的猜測去做去虛擬化(Devitalization),以保證絕大部分有內(nèi)聯(lián)價值的虛方法都可以順利內(nèi)聯(lián)。內(nèi)聯(lián)是最基礎(chǔ)的一項優(yōu)化措施.
最后,是鏈接時優(yōu)化(Link-Time Optimization,LTO),Java語言天生就是動態(tài)鏈接的,一個個Class文件在運行期被加載到虛擬機(jī)內(nèi)存當(dāng)中,然后在即時編譯器里產(chǎn)生優(yōu)化后的本地代碼,這類事情在Java程序員眼里看起來毫無違和之處。但如果類似的場景出現(xiàn)在使用提前編譯的語言和程序上,譬如C、C++的程序要調(diào)用某個動態(tài)鏈接庫的某個方法,就會出現(xiàn)很明顯的邊界隔閡,還難以優(yōu)化。這是因為主程序與動態(tài)鏈接庫的代碼在它們編譯時是完全獨立的,兩者各自編譯、優(yōu)化自己的代碼。這些代碼的作者、編譯的時間,以及編譯器甚至很可能都是不同的,當(dāng)出現(xiàn)跨鏈接庫邊界的調(diào)用時,那些理論上應(yīng)該要做的優(yōu)化——譬如做對調(diào)用方法的內(nèi)聯(lián),就會執(zhí)行起來相當(dāng)?shù)睦щy。如果剛才說的虛方法內(nèi)聯(lián)讓C、C++程序員理解還算比較能夠接受的話(其實C++編譯器也可以通過一些技巧來做到虛方法內(nèi)聯(lián)),那這種跨越動態(tài)鏈接庫的方法內(nèi)聯(lián)在他們眼里可能就近乎于離經(jīng)叛道了(但實際上依然是可行的)。
經(jīng)過以上的討論,大家應(yīng)該能夠理解提前編譯器的價值與優(yōu)勢所在了,但忽略具體的應(yīng)用場景就說它是萬能的銀彈,那肯定是有失偏頗的,提前編譯有它的應(yīng)用場景,也有它的弱項與不足,相信未來很長一段時間內(nèi),即時編譯和提前編譯都會是Java后端編譯技術(shù)的共同主角。
編譯器優(yōu)化技術(shù)
經(jīng)過前面對即時編譯、提前編譯的講解,讀者應(yīng)該已經(jīng)建立起一個認(rèn)知:編譯器的目標(biāo)雖然是做由程序代碼翻譯為本地機(jī)器碼的工作,但其實難點并不在于能不能成功翻譯出機(jī)器碼,輸出代碼優(yōu)化質(zhì)量的高低才是決定編譯器優(yōu)秀與否的關(guān)鍵。
這里通過大家熟悉的Java代碼變化來展示其中幾種優(yōu)化技術(shù)是如何發(fā)揮作用的。不過首先需要明確一點,即時編譯器對這些代碼優(yōu)化變換是建立在代碼的中間表示或者是機(jī)器碼之上的,絕不是直接在Java源碼上去做的,這里只是筆者為了方便講解,使用了Java語言的語法來表示這些優(yōu)化技術(shù)所發(fā)揮的作用。
第一步,從原始代碼開始,如代碼清單11-6所示
代碼清單11-6 優(yōu)化前的原始代碼
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
代碼清單11-6所示的內(nèi)容已經(jīng)非常簡化了,但是仍有不少優(yōu)化的空間。首先,第一個要進(jìn)行的優(yōu)化是方法內(nèi)聯(lián),它的主要目的有兩個:一是去除方法調(diào)用的成本(如查找方法版本、建立棧幀等);二是為其他優(yōu)化建立良好的基礎(chǔ)。方法內(nèi)聯(lián)膨脹之后可以便于在更大范圍上進(jìn)行后續(xù)的優(yōu)化手段,可以獲取更好的優(yōu)化效果。因此各種編譯器一般都會把內(nèi)聯(lián)優(yōu)化放在優(yōu)化序列最靠前的位置。內(nèi)聯(lián)后的代碼如代碼清單11-7所示。
代碼清單11-7 內(nèi)聯(lián)后的代碼
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
第二步進(jìn)行冗余訪問消除(Redundant Loads Elimination),假設(shè)代碼中間注釋掉的“…do stuff…”所代表的操作不會改變b.value的值,那么就可以把“z=b.value”替換為“z=y”,因為上一句“y=b.value”已經(jīng)保證了變量y與b.value是一致的,這樣就可以不再去訪問對象b的局部變量了。如果
把b.value看作一個表達(dá)式,那么也可以把這項優(yōu)化看作一種公共子表達(dá)式消除(Common Subexpression Elimination),優(yōu)化后的代碼如代碼清單11-8所示。
代碼清單11-8 冗余存儲消除的代碼
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
第三步進(jìn)行復(fù)寫傳播(Copy Propagation),因為這段程序的邏輯之中沒有必要使用一個額外的變量z,它與變量y是完全相等的,因此我們可以使用y來代替z。復(fù)寫傳播之后的程序如代碼清單11-9所示。
代碼清單11-9 復(fù)寫傳播的代碼
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
第四步進(jìn)行無用代碼消除(Dead Code Elimination),無用代碼可能是永遠(yuǎn)不會被執(zhí)行的代碼,也可能是完全沒有意義的代碼。因此它又被很形象地稱為“Dead Code”,在代碼清單11-9中,“y=y”是沒有意義的,把它消除后的程序如代碼清單11-10所示。
代碼清單11-10 進(jìn)行無用代碼消除的代碼
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
經(jīng)過四次優(yōu)化之后,代碼清單11-10所示代碼與代碼清單11-6所示代碼所達(dá)到的效果是一致的,但是前者比后者省略了許多語句,體現(xiàn)在字節(jié)碼和機(jī)器碼指令上的差距會更大,執(zhí)行效率的差距也會更高。編譯器的這些優(yōu)化技術(shù)實現(xiàn)起來也許確實復(fù)雜,但是要理解它們的行為,對于一個初學(xué)者來說都是沒有什么困難的,完全不需要有任何的恐懼心理。
方法內(nèi)聯(lián)
在前面的講解中,我們多次提到方法內(nèi)聯(lián),說它是編譯器最重要的優(yōu)化手段,甚至都可以不加上“之一”。內(nèi)聯(lián)被業(yè)內(nèi)戲稱為優(yōu)化之母,因為除了消除方法調(diào)用的成本之外,它更重要的意義是為其他優(yōu)化手段建立良好的基礎(chǔ),代碼清單11-11所示的簡單例子就揭示了內(nèi)聯(lián)對其他優(yōu)化手段的巨大價值:沒有內(nèi)聯(lián),多數(shù)其他優(yōu)化都無法有效進(jìn)行。例子里testInline()方法的內(nèi)部全部是無用的代碼,但如果不做內(nèi)聯(lián),后續(xù)即使進(jìn)行了無用代碼消除的優(yōu)化,也無法發(fā)現(xiàn)任何“Dead Code”的存在。如果分開來看,foo()和testInline()兩個方法里面的操作都有可能是有意義的。
代碼清單11-11 未作任何優(yōu)化的字節(jié)碼
public static void foo(Object obj) {
if (obj != null) {
System.out.println("do something");
}
}
public static void testInline(String[] args) {
Object obj = null;
foo(obj);
}
方法內(nèi)聯(lián)的優(yōu)化行為理解起來是沒有任何困難的,不過就是把目標(biāo)方法的代碼原封不動地“復(fù)制”到發(fā)起調(diào)用的方法之中,避免發(fā)生真實的方法調(diào)用而已。但實際上Java虛擬機(jī)中的內(nèi)聯(lián)過程卻遠(yuǎn)沒有想象中容易,甚至如果不是即時編譯器做了一些特殊的努力,按照經(jīng)典編譯原理的優(yōu)化理論,大多數(shù)的Java方法都無法進(jìn)行內(nèi)聯(lián)
無法內(nèi)聯(lián)的原因其實在之前文章講解Java方法解析和分派調(diào)用的時候就已經(jīng)解釋過:只有使用invokespecial指令調(diào)用的私有方法、實例構(gòu)造器、父類方法和使用invokestatic指令調(diào)用的靜態(tài)方法才會在編譯期進(jìn)行解析。除了上述四種方法之外(最多再除去被final修飾的方法這種特殊情況,盡管它使用invokevirtual指令調(diào)用,但也是非虛方法,《Java語言規(guī)范》中明確說明了這點),其他的Java方法調(diào)用都必須在運行時進(jìn)行方法接收者的多態(tài)選擇,它們都有可能存在多于一個版本的方法接收者,簡而言之,Java語言中默認(rèn)的實例方法是虛方法。
對于一個虛方法,編譯器靜態(tài)地去做內(nèi)聯(lián)的時候很難確定應(yīng)該使用哪個方法版本,以將代碼清單11-7中所示b.get()直接內(nèi)聯(lián)為b.value為例,如果不依賴上下文,是無法確定b的實際類型是什么的。假如有ParentB和SubB是兩個具有繼承關(guān)系的父子類型,并且子類重寫了父類的get()方法,那么b.get()是執(zhí)行父類的get()方法還是子類的get()方法,這應(yīng)該是根據(jù)實際類型動態(tài)分派的,而實際類型必須在實際運行到這一行代碼時才能確定,編譯器很難在編譯時得出絕對準(zhǔn)確的結(jié)論。
更糟糕的情況是,由于Java提倡使用面向?qū)ο蟮姆绞竭M(jìn)行編程,而Java對象的方法默認(rèn)就是虛方法,可以說Java間接鼓勵了程序員使用大量的虛方法來實現(xiàn)程序邏輯。根據(jù)上面的分析可知,內(nèi)聯(lián)與虛方法之間會產(chǎn)生“矛盾”,那是不是為了提高執(zhí)行性能,就應(yīng)該默認(rèn)給每個方法都使用final關(guān)鍵字去修飾呢?C和C++語言的確是這樣做的,默認(rèn)的方法是非虛方法,如果需要用到多態(tài),就用virtual關(guān)鍵字來修飾,但Java選擇了在虛擬機(jī)中解決這個問題。
為了解決虛方法的內(nèi)聯(lián)問題,Java虛擬機(jī)首先引入了一種名為類型繼承關(guān)系分析(Class Hierarchy Analysis,CHA)的技術(shù),這是整個應(yīng)用程序范圍內(nèi)的類型分析技術(shù),用于確定在目前已加載的類中,某個接口是否有多于一種的實現(xiàn)、某個類是否存在子類、某個子類是否覆蓋了父類的某個虛方法等信息。這樣,編譯器在進(jìn)行內(nèi)聯(lián)時就會分不同情況采取不同的處理:如果是非虛方法,那么直接進(jìn)行內(nèi)聯(lián)就可以了,這種的內(nèi)聯(lián)是有百分百安全保障的;如果遇到虛方法,則會向CHA查詢此方法在當(dāng)前程序狀態(tài)下是否真的有多個目標(biāo)版本可供選擇,如果查詢到只有一個版本,那就可以假設(shè)“應(yīng)用程序的全貌就是現(xiàn)在運行的這個樣子”來進(jìn)行內(nèi)聯(lián),這種內(nèi)聯(lián)被稱為守護(hù)內(nèi)聯(lián)(Guarded Inlining)。不過由于Java程序是動態(tài)連接的,說不準(zhǔn)什么時候就會加載到新的類型從而改變CHA結(jié)論,因此這種內(nèi)聯(lián)屬于激進(jìn)預(yù)測性優(yōu)化,必須預(yù)留好“逃生門”,即當(dāng)假設(shè)條件不成立時的“退路”(Slow Path)。假如在程序的后續(xù)執(zhí)行過程中,虛擬機(jī)一直沒有加載到會令這個方法的接收者的繼承關(guān)系發(fā)生變化的類,那這個內(nèi)聯(lián)優(yōu)化的代碼就可以一直使用下去。如果加載了導(dǎo)致繼承關(guān)系發(fā)生變化的新類,那么就必須拋棄已經(jīng)編譯的代碼,退回到解釋狀態(tài)進(jìn)行執(zhí)行,或者重新進(jìn)行編譯。
假如向CHA查詢出來的結(jié)果是該方法確實有多個版本的目標(biāo)方法可供選擇,那即時編譯器還將進(jìn)行最后一次努力,使用內(nèi)聯(lián)緩存(Inline Cache)的方式來縮減方法調(diào)用的開銷。這種狀態(tài)下方法調(diào)用是真正發(fā)生了的,但是比起直接查虛方法表還是要快一些。內(nèi)聯(lián)緩存是一個建立在目標(biāo)方法正常入口之前的緩存,它的工作原理大致為:在未發(fā)生方法調(diào)用之前,內(nèi)聯(lián)緩存狀態(tài)為空,當(dāng)?shù)谝淮握{(diào)用發(fā)生后,緩存記錄下方法接收者的版本信息,并且每次進(jìn)行方法調(diào)用時都比較接收者的版本。如果以后進(jìn)來的每次調(diào)用的方法接收者版本都是一樣的,那么這時它就是一種單態(tài)內(nèi)聯(lián)緩存(Monomorphic Inline Cache)。通過該緩存來調(diào)用,比用不內(nèi)聯(lián)的非虛方法調(diào)用,僅多了一次類型判斷的開銷而已。但如果真的出現(xiàn)方法接收者不一致的情況,就說明程序用到了虛方法的多態(tài)特性,這時候會退化成超多態(tài)內(nèi)聯(lián)緩存(Megamorphic Inline Cache),其開銷相當(dāng)于真正查找虛方法表來進(jìn)行方法分派。
所以說,在多數(shù)情況下Java虛擬機(jī)進(jìn)行的方法內(nèi)聯(lián)都是一種激進(jìn)優(yōu)化。事實上,激進(jìn)優(yōu)化的應(yīng)用在高性能的Java虛擬機(jī)中比比皆是,極為常見。除了方法內(nèi)聯(lián)之外,對于出現(xiàn)概率很?。ㄍㄟ^經(jīng)驗數(shù)據(jù)或解釋器收集到的性能監(jiān)控信息確定概率大?。┑碾[式異常、使用概率很小的分支等都可以被激進(jìn)優(yōu)化“移除”,如果真的出現(xiàn)了小概率事件,這時才會從“逃生門”回到解釋狀態(tài)重新執(zhí)行。
逃逸分析
逃逸分析(Escape Analysis)是目前Java虛擬機(jī)中比較前沿的優(yōu)化技術(shù),它與類型繼承關(guān)系分析一樣,并不是直接優(yōu)化代碼的手段,而是為其他優(yōu)化措施提供依據(jù)的分析技術(shù)。
逃逸分析的基本原理是:分析對象動態(tài)作用域,當(dāng)一個對象在方法里面被定義后,它可能被外部方法所引用,例如作為調(diào)用參數(shù)傳遞到其他方法中,這種稱為方法逃逸;甚至還有可能被外部線程訪問到,譬如賦值給可以在其他線程中訪問的實例變量,這種稱為線程逃逸;從不逃逸、方法逃逸到線程逃逸,稱為對象由低到高的不同逃逸程度。
如果能證明一個對象不會逃逸到方法或線程之外(換句話說是別的方法或線程無法通過任何途徑訪問到這個對象),或者逃逸程度比較低(只逃逸出方法而不會逃逸出線程),則可能為這個對象實例采取不同程度的優(yōu)化,如:
棧上分配(Stack Allocations):在Java虛擬機(jī)中,Java堆上分配創(chuàng)建對象的內(nèi)存空間幾乎是Java程序員都知道的常識,Java堆中的對象對于各個線程都是共享和可見的,只要持有這個對象的引用,就可以訪問到堆中存儲的對象數(shù)據(jù)。虛擬機(jī)的垃圾收集子系統(tǒng)會回收堆中不再使用的對象,但回收動作無論是標(biāo)記篩選出可回收對象,還是回收和整理內(nèi)存,都需要耗費大量資源。如果確定一個對象不會逃逸出線程之外,那讓這個對象在棧上分配內(nèi)存將會是一個很不錯的主意,對象所占用的內(nèi)存空間就可以隨棧幀出棧而銷毀。在一般應(yīng)用中,完全不會逃逸的局部對象和不會逃逸出線程的對象所占的比例是很大的,如果能使用棧上分配,那大量的對象就會隨著方法的結(jié)束而自動銷毀了,垃圾收集子系統(tǒng)的壓力將會下降很多。棧上分配可以支持方法逃逸,但不能支持線程逃逸。
·標(biāo)量替換(Scalar Replacement):若一個數(shù)據(jù)已經(jīng)無法再分解成更小的數(shù)據(jù)來表示了,Java虛擬機(jī)中的原始數(shù)據(jù)類型(int、long等數(shù)值類型及reference類型等)都不能再進(jìn)一步分解了,那么這些數(shù)據(jù)就可以被稱為標(biāo)量。相對的,如果一個數(shù)據(jù)可以繼續(xù)分解,那它就被稱為聚合量(Aggregate),Java中的對象就是典型的聚合量。如果把一個Java對象拆散,根據(jù)程序訪問的情況,將其用到的成員變量恢復(fù)為原始類型來訪問,這個過程就稱為標(biāo)量替換。假如逃逸分析能夠證明一個對象不會被方法外部訪問,并且這個對象可以被拆散,那么程序真正執(zhí)行的時候?qū)⒖赡懿蝗?chuàng)建這個對象,而改為直接創(chuàng)建它的若干個被這個方法使用的成員變量來代替。將對象拆分后,除了可以讓對象的成員變量在棧上
(棧上存儲的數(shù)據(jù),很大機(jī)會被虛擬機(jī)分配至物理機(jī)器的高速寄存器中存儲)分配和讀寫之外,還可以為后續(xù)進(jìn)一步的優(yōu)化手段創(chuàng)建條件。標(biāo)量替換可以視作棧上分配的一種特例,實現(xiàn)更簡單(不用考慮整個對象完整結(jié)構(gòu)的分配),但對逃逸程度的要求更高,它不允許對象逃逸出方法范圍內(nèi)。同步消除(Synchronization Elimination):線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那么這個變量的讀寫肯定就不會有競爭,對這個變量實施的同步措施也就可以安全地消除掉。
關(guān)于逃逸分析的研究論文早在1999年就已經(jīng)發(fā)表,但直到JDK 6,HotSpot才開始支持初步的逃逸分析,而且到現(xiàn)在這項優(yōu)化技術(shù)尚未足夠成熟,仍有很大的改進(jìn)余地。不成熟的原因主要是逃逸分析的計算成本非常高,甚至不能保證逃逸分析帶來的性能收益會高于它的消耗。如果要百分之百準(zhǔn)確地判斷一個對象是否會逃逸,需要進(jìn)行一系列復(fù)雜的數(shù)據(jù)流敏感的過程間分析,才能確定程序各個分支執(zhí)行時對此對象的影響。前面介紹即時編譯、提前編譯優(yōu)劣勢時提到了過程間分析這種大壓力的分析算法正是即時編譯的弱項??梢栽囅胍幌拢绻右莘治鐾戤吅蟀l(fā)現(xiàn)幾乎找不到幾個不逃逸的對象,那這些運行期耗用的時間就白白浪費了,所以目前虛擬機(jī)只能采用不那么準(zhǔn)確,但時間壓力相對較小
的算法來完成分析。
C和C++語言里面原生就支持了棧上分配(不使用new操作符即可),而C#也支持值類型,可以很自然地做到標(biāo)量替換(但并不會對引用類型做這種優(yōu)化)。在靈活運用棧內(nèi)存方面,確實是Java的一個弱項。在現(xiàn)在仍處于實驗階段的Valhalla項目里,設(shè)計了新的inline關(guān)鍵字用于定義Java的內(nèi)聯(lián)類型,目的是實現(xiàn)與C#中值類型相對標(biāo)的功能。有了這個標(biāo)識與約束,以后逃逸分析做起來就會簡單很多。
下面筆者將通過一系列Java偽代碼的變化過程來模擬逃逸分析是如何工作的,展示逃逸分析能夠?qū)崿F(xiàn)的效果。初始代碼如下所示:
// 完全未優(yōu)化的代碼
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
此處省略了Point類的代碼,這就是一個包含x和y坐標(biāo)的POJO類型,讀者應(yīng)該很容易想象它的樣子。
第一步,將Point的構(gòu)造函數(shù)和getX()方法進(jìn)行內(nèi)聯(lián)優(yōu)化:
// 步驟1:構(gòu)造函數(shù)內(nèi)聯(lián)后的樣子
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P對象的示意方法
p.x = xx; // Point構(gòu)造函數(shù)被內(nèi)聯(lián)后的樣子
p.y = 42
return p.x; // Point::getX()被內(nèi)聯(lián)后的樣子
}
第二步,經(jīng)過逃逸分析,發(fā)現(xiàn)在整個test()方法的范圍內(nèi)Point對象實例不會發(fā)生任何程度的逃逸,這樣可以對它進(jìn)行標(biāo)量替換優(yōu)化,把其內(nèi)部的x和y直接置換出來,分解為test()方法內(nèi)的局部變量,從而避免Point對象實例被實際創(chuàng)建,優(yōu)化后的結(jié)果如下所示:
// 步驟2:標(biāo)量替換后的樣子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42
return px;
}
第三步,通過數(shù)據(jù)流分析,發(fā)現(xiàn)py的值其實對方法不會造成任何影響,那就可以放心地去做無效代碼消除得到最終優(yōu)化結(jié)果,如下所示:
// 步驟3:做無效代碼消除后的樣子
public int test(int x) {
return x + 2;
}
從測試結(jié)果來看,實施逃逸分析后的程序在MicroBenchmarks中往往能得到不錯的成績,但是在實際的應(yīng)用程序中,尤其是大型程序中反而發(fā)現(xiàn)實施逃逸分析可能出現(xiàn)效果不穩(wěn)定的情況,或分析過程耗時但卻無法有效判別出非逃逸對象而導(dǎo)致性能(即時編譯的收益)下降,所以曾經(jīng)在很長的一段時間里,即使是服務(wù)端編譯器,也默認(rèn)不開啟逃逸分析[2],甚至在某些版本(如JDK 6 Update 18)中還曾經(jīng)完全禁止了這項優(yōu)化,一直到JDK 7時這項優(yōu)化才成為服務(wù)端編譯器默認(rèn)開啟的選項。如果有需要,或者確認(rèn)對程序運行有益,用戶也可以使用參數(shù)-XX:+DoEscapeAnalysis來手動開啟逃逸分析,開啟之后可以通過參數(shù)-XX:+PrintEscapeAnalysis來查看分析結(jié)果。有了逃逸分析支持之后,用戶可
以使用參數(shù)-XX:+EliminateAllocations來開啟標(biāo)量替換,使用+XX:+EliminateLocks來開啟同步消除,使用參數(shù)-XX:+PrintEliminateAllocations查看標(biāo)量的替換情況。
盡管目前逃逸分析技術(shù)仍在發(fā)展之中,未完全成熟,但它是即時編譯器優(yōu)化技術(shù)的一個重要前進(jìn)方向,在日后的Java虛擬機(jī)中,逃逸分析技術(shù)肯定會支撐起一系列更實用、有效的優(yōu)化技術(shù)。
公共子表達(dá)式消除
公共子表達(dá)式消除是一項非常經(jīng)典的、普遍應(yīng)用于各種編譯器的優(yōu)化技術(shù),它的含義是:如果一個表達(dá)式E之前已經(jīng)被計算過了,并且從先前的計算到現(xiàn)在E中所有變量的值都沒有發(fā)生變化,那么E的這次出現(xiàn)就稱為公共子表達(dá)式。對于這種表達(dá)式,沒有必要花時間再對它重新進(jìn)行計算,只需要直接用前面計算過的表達(dá)式結(jié)果代替E。如果這種優(yōu)化僅限于程序基本塊內(nèi),便可稱為局部公共子表達(dá)式消除(Local Common Subexpression Elimination),如果這種優(yōu)化的范圍涵蓋了多個基本塊,那就稱為全局公共子表達(dá)式消除(Global Common Subexpression Elimination).下面舉個簡單的例子來說明它的優(yōu)化過程,假設(shè)存在如下代碼
int d = (c * b) * 12 + a + (a + b * c);
如果這段代碼交給Javac編譯器則不會進(jìn)行任何優(yōu)化,那生成的代碼將如代碼清單11-12所示,是完全遵照J(rèn)ava源碼的寫法直譯而成的。
代碼清單11-12 未作任何優(yōu)化的字節(jié)碼
iload_2 // b
imul // 計算b*c
bipush 12 // 推入12
imul // 計算(c * b) * 12
iload_1 // a
iadd // 計算(c * b) * 12 + a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 計算b * c
iadd // 計算a + b * c
iadd // 計算(c * b) * 12 + a + a + b * c
istore 4
當(dāng)這段代碼進(jìn)入虛擬機(jī)即時編譯器后,它將進(jìn)行如下優(yōu)化:編譯器檢測到cb與bc是一樣的表達(dá)式,而且在計算期間b與c的值是不變的。
因此這條表達(dá)式就可能被視為:
int d = E * 12 + a + (a + E);
這時候,編譯器還可能(取決于哪種虛擬機(jī)的編譯器以及具體的上下文而定)進(jìn)行另外一種優(yōu)化——代數(shù)化簡(Algebraic Simplification),在E本來就有乘法運算的前提下,把表達(dá)式變?yōu)椋?/p>
int d = E * 13 + a + a;
表達(dá)式進(jìn)行變換之后,再計算起來就可以節(jié)省一些時間了。
數(shù)組邊界檢查消除
數(shù)組邊界檢查消除(Array Bounds Checking Elimination)是即時編譯器中的一項語言相關(guān)的經(jīng)典優(yōu)化技術(shù)。我們知道Java語言是一門動態(tài)安全的語言,對數(shù)組的讀寫訪問也不像C、C++那樣實質(zhì)上就是裸指針操作。如果有一個數(shù)組foo[],在Java語言中訪問數(shù)組元素foo[i]的時候系統(tǒng)將會自動進(jìn)行上下界的范圍檢查,即i必須滿足“i>=0&&i<foo.length”的訪問條件,否則將拋出一個運行時異常:java.lang.ArrayIndexOutOfBoundsException。這對軟件開發(fā)者來說是一件很友好的事情,即使程序員沒有專門編寫防御代碼,也能夠避免大多數(shù)的溢出攻擊。但是對于虛擬機(jī)的執(zhí)行子系統(tǒng)來說,每次數(shù)組元素的讀寫都帶有一次隱含的條件判定操作,對于擁有大量數(shù)組訪問的程序代碼,這必定是一種性能負(fù)擔(dān)。
無論如何,為了安全,數(shù)組邊界檢查肯定是要做的,但數(shù)組邊界檢查是不是必須在運行期間一次不漏地進(jìn)行則是可以“商量”的事情。例如下面這個簡單的情況:數(shù)組下標(biāo)是一個常量,如foo[3],只要在編譯期根據(jù)數(shù)據(jù)流分析來確定foo.length的值,并判斷下標(biāo)“3”沒有越界,執(zhí)行的時候就無須判斷了。更加常見的情況是,數(shù)組訪問發(fā)生在循環(huán)之中,并且使用循環(huán)變量來進(jìn)行數(shù)組的訪問。如果編譯器只要通過數(shù)據(jù)流分析就可以判定循環(huán)變量的取值范圍永遠(yuǎn)在區(qū)間[0,foo.length)之內(nèi),那么在循環(huán)中就可
以把整個數(shù)組的上下界檢查消除掉,這可以節(jié)省很多次的條件判斷操作。
把這個數(shù)組邊界檢查的例子放在更高的視角來看,大量的安全檢查使編寫Java程序比編寫C和C++程序容易了很多,比如:數(shù)組越界會得到ArrayIndexOutOfBoundsException異常;空指針訪問會得到NullPointException異常;除數(shù)為零會得到ArithmeticException異?!贑和C++程序中出現(xiàn)類似的問題,一個不小心就會出現(xiàn)Segment Fault信號或者Windows編程中常見的“XXX內(nèi)存不能為Read/Write”之類的提示,處理不好程序就直接崩潰退出了。但這些安全檢查也導(dǎo)致出現(xiàn)相同的程序,從而使Java比C和C++要做更多的事情(各種檢查判斷),這些事情就會導(dǎo)致一些隱式開銷,如果不處理好它們,就很可能成為一項“Java語言天生就比較慢”的原罪。為了消除這些隱式開銷,除了如數(shù)組
邊界檢查優(yōu)化這種盡可能把運行期檢查提前到編譯期完成的思路之外,還有一種避開的處理思路——隱式異常處理,Java中空指針檢查和算術(shù)運算中除數(shù)為零的檢查都采用了這種方案。舉個例子,程序中訪問一個對象(假設(shè)對象叫foo)的某個屬性(假設(shè)屬性叫value),那以Java偽代碼來表示虛擬機(jī)訪問foo.value的過程為:
if (foo != null) {
return foo.value;
}else{
throw new NullPointException();
}
在使用隱式異常優(yōu)化之后,虛擬機(jī)會把上面的偽代碼所表示的訪問過程變?yōu)槿缦聜未a:
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
虛擬機(jī)會注冊一個Segment Fault信號的異常處理器(偽代碼中的uncommon_trap(),務(wù)必注意這里是指進(jìn)程層面的異常處理器,并非真的Java的try-catch語句的異常處理器),這樣當(dāng)foo不為空的時候,對value的訪問是不會有任何額外對foo判空的開銷的,而代價就是當(dāng)foo真的為空時,必須轉(zhuǎn)到異常處理器中恢復(fù)中斷并拋出NullPointException異常。進(jìn)入異常處理器的過程涉及進(jìn)程從用戶態(tài)轉(zhuǎn)到內(nèi)核態(tài)中處理的過程,結(jié)束后會再回到用戶態(tài),速度遠(yuǎn)比一次判空檢查要慢得多。當(dāng)foo極少為空的時候,隱式異常優(yōu)化是值得的,但假如foo經(jīng)常為空,這樣的優(yōu)化反而會讓程序更慢。幸好HotSpot虛擬機(jī)足夠聰明,它會根據(jù)運行期收集到的性能監(jiān)控信息自動選擇最合適的方案。
與語言相關(guān)的其他消除操作還有不少,如自動裝箱消除(Autobox Elimination)、安全點消除(Safepoint Elimination)、消除反射(Dereflection)等.