《深入理解java虛擬機器》-晚期(執行期)優化
概述
在部分的商用虛擬機器中,java程式最初是通過直譯器(Interpreter)進行解釋執行的,當虛擬機發現某個方法或程式碼塊的執行特別頻繁時,就會把這些程式碼認定為“熱點程式碼”(Hot Spot Code)。為了提高熱點程式碼的執行效率,在執行時,虛擬機器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個過程的編譯器稱為即時編譯器(Just In Time Compiler)
java虛擬機器規範中沒有規定即時編譯器應該如何實現,也沒有規定虛擬機器必需擁有即時編譯器,這部分功能完全是虛擬機器具體實現相關的內容。本文中提及的編譯器、即時編譯器都是指HotSpot虛擬機器
HotSpot虛擬機器內的即時編譯器
直譯器和編譯器
HotSpot虛擬機器採用直譯器與編譯器並存的架構,直譯器與編譯器兩者各有優勢:
- 當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行
- 在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生代碼之後,可以獲得更高的執行效率
- 當程式執行環境中記憶體資源限制較大,可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率
- 直譯器還可以作為編譯器激進優化的一個“逃生門”,讓編譯器根據概率選擇一些大多數時候都能提升執行速度的優化手段,當激進優化的假設不成立時,可以通過逆優化退回到解釋狀態繼續執行
HotSpot虛擬機器中內建了兩個即時編譯器,分別稱為Client Compiler和Server Compiler,或者簡稱為C1編譯器和C2編譯器,虛擬機器預設採用直譯器與其中一個編譯器直接配合的方式工作
由於即時編譯器編譯原生代碼需要佔用程式執行時間,要編譯出優化程度更高的程式碼,所花費的時間可能更長;而且想要編譯出優化程度更高的程式碼,直譯器可能還要替編譯器收集效能監控資訊,這對解釋執行的速度也有影響。HotSpot虛擬機器採用分層編譯(Tiered Compilation)的策略,其中包括:
- 第0層:程式解釋執行,直譯器不開啟效能監控功能(Profiling),可觸發第1層編譯
- 第1層:也稱為C1編譯,將位元組碼編譯為原生代碼,進行簡單、可靠的優化,如有必要將加入效能監控的邏輯
- 第2層:也稱為C2編譯,也是將位元組碼編譯為原生代碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化
編譯物件與觸發條件
在執行過程中會被即時編譯器編譯的“熱點程式碼”有兩類:
- 被多次呼叫的方法
- 被多次執行的迴圈體
在這兩種情況下,都是以整個方法作為編譯物件,這種編譯方式被稱為棧上替換(On Stack Replacement,簡稱OSR編譯,即方法棧幀還在棧上,方法就被替換了)
判斷一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這樣的行為稱為熱點探測(Hot Spot Detection),目前主要的熱點探測判定方式有兩種:
- 基於取樣的熱點探測(Sample Based Hot Spot Detection):採用這種方法的虛擬機器會週期性地檢查各個執行緒地棧頂,如果發現某個方法經常出現在棧頂,那這個方法就是“熱點方法”
- 優點:實現簡單、高效,還可以很容易地獲取方法呼叫關係
- 缺點:很難精確地確認一個方法的熱度,容易因為受到執行緒阻塞或別的外界因素的影響而擾亂熱點探測
- 基於計數器的熱點探測(Counter Based Hot Spot Detection):採用這個種方法的虛擬機器會為每個方法建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認為它是“熱點方法”
- 優點:統計結果相對來說更加精確和嚴謹
- 缺點:實現複雜
在HotSpot虛擬機器中使用的是第二種,因此它為每個方法準備了兩類計數器:方法呼叫計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。在確定虛擬機器執行引數的前提下,這兩個計數器都由一個確定的閾值,當計數器超過閾值溢位了,就會觸發JIT編譯
- 方法呼叫計數器:用於統計方法被呼叫的次數,它的預設閾值在Client模式下是1500次,在Server模式在是10000次,可通過-XX: CompileThreshold來設定
- 方法被呼叫時,先檢查該方法是否存在被JIT編譯過的版本
- 存在:優先使用編譯後的原生代碼來執行
- 不存在:將此方法的呼叫計數器值加1,執行下一步
- 判斷方法呼叫計數器與彙編計數器值之和是否超過方法呼叫計數器的閾值
- 超過閾值:向即時編譯器提交一個該方法的程式碼編譯請求。預設不會同步等待編譯請求完成,而是繼續解釋執行,當編譯工作完成之後,這個方法的呼叫入口地址就會被系統自動改寫成新的,下一次呼叫該方法時就會使用已編譯版本
- 未超過:解釋執行
- 如果不做任何設定,方法呼叫計數器統計的不是方法被呼叫的絕對次數,而是一個相對執行頻率,即一段時間之內方法被呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會被減少一般,這個過程稱為方法呼叫計數器的熱度衰減(Counter Decay)
- 回邊計數器:用於統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為“回邊”(Back Edge)
- 回邊計數器閾值計算公式:
- Client模式:方法呼叫計數器閾值(CompileThreshold) * OSR比率(OnStackReplacePercentage) / 100 ==> 預設值為13995
- Server模式:方法呼叫計數器閾值(CompileThreshold) * (OSR比率(OnStackReplacePercentage) - 直譯器監控比率(InterpreterProfilePercentage) / 100) ==> 預設值為10700
- 當直譯器遇到一條回邊指令時,會先查詢將要執行的程式碼片段是否有已經編譯好的版本
- 有:優先執行已編譯程式碼
- 無:把回邊計數器的值加1,執行下一步
- 判斷方法呼叫計數器與回邊計數器值之後是否超過回邊計數器的閾值
- 超過:提交一個OSR編譯請求,並且把回邊計數器的值降低一些,以便繼續在編譯器中執行迴圈,等待編譯器輸出編譯結果
- 未超過:解釋執行
- 與方法計數器不同,回邊計數器沒有計數熱度衰減的過程,因此這個計數器統計的就是該方法迴圈執行的絕對次數
ps:上面描述的是Client VM的即時編譯方法,對於Server VM來說,執行情況會比上面的描述更復雜
編譯過程
Server Compiler和Client Compiler兩個編譯器的編譯過程是不一樣的
Client Compiler是一個簡單快速的三段式編譯器,主要的關注點在於區域性性的優化,而放棄了許多耗時較長的全域性優化手段
- 第一個階段:使用一個平臺獨立的前端將位元組碼構造成一種高階中間程式碼表示(High-Level Intermediate Representaion, HIR)。HIR使用靜態單分配(Static Single Assignment, SSA)的形式來代表程式碼值,這可以使得一些在HIR的構造過程之中和之後進行的優化動作更容易實現。在此之前編譯器會在位元組碼上完成一部分基礎優化,如方法內聯、常量傳播等
- 第二個階段:使用一個平臺相關的前端從HIR中產生低階中間程式碼表示(Low-Level Intermediate Representaion, LIR),而在此之前會在HIR上完成另外一些優化,如空值檢查清除、範圍檢查清除等
- 最後階段:使用平臺相關的後端使用線性掃描演算法(Linear Scan Register Allocation)在LIR上分離暫存器,並在LIR上做窺孔(Peephole)優化,然後產生機器程式碼
Server Compiler是專門面向服務端的典型應用併為服務端的效能配置特別調整過的編譯器,它會執行所有經典的優化動作。Server Compiler的暫存器分配器是一個全域性圖著色分配器,它可以充分利用某些處理器架構上的大暫存器集合。以即時編譯的標準來看,Server Compiler**編譯速度比較緩慢,但依然遠遠超過傳統的靜態優化編譯器,而且相對於Client Compiler編譯輸出的程式碼質量有所提高**,可以減少原生代碼的執行時間,從而抵消了額外的編譯時間開銷
編譯優化技術
在即時編譯器中採用的優化技術有很多,本節主要針對以下四種優化技術:
公共子表示式消除
公共子表示式消除是一個普遍應用與各種編譯器的經典優化技術,它的含義是:
- 如果一個表示式E已經計算過了,並且從先前的計算到現在E中的所有變數的值都沒有發生變化,那麼E的這次出現就成為了公共子表示式
- 對於這種表示式,沒有必要花時間再對它進行計算,只需要直接用前面計算過的表示式結果替代E就可以了
- 如果這種優化僅限於程式的基本塊內,便稱為區域性公共子表示式消除(Local Common Subexpression Elimination),如果這種優化的範圍涵蓋了多個基本塊,那就稱為全域性公共子表示式消除(Global Common Subexpression Elimination)
陣列邊界檢查消除
陣列邊界檢查消除(Array Bounds Checking Elimination)是即時編譯器中的一項語言相關的經典優化技術。由於java語言中訪問陣列元素時,系統將會自動進行上下界的範圍檢查,這必定會造成效能負擔。為了安全,陣列邊界檢查是必須做的,但陣列邊界檢查是否必須一次不漏的執行則是可以“商量”的事情。例如編譯器通過資料流分析判定陣列下標的取值永遠在[0,陣列.length)之內,就可以把陣列的上下界檢查消除
從更高的角度看,大量安全檢查使編寫java程式更簡單,但也造成了更多的隱式開銷,對於這些隱式開銷,除了儘可能把執行期檢查提到編譯期完成的思路之外,還可以使用隱式異常處理:
if(x != null){
return x.value;
}else{
throw new NullPointException();
}
隱式異常優化後:
try{
return x.value;
}catch(segment_fault){
uncommon_trap();
}
虛擬機器會註冊一個Segment Fault訊號的異常處理器(uncommon_trap()),這樣x不為空時,不會額外消耗一次對foo判空的開銷。代價是當x為空時,必須轉入異常處理器中恢復並丟擲NullPointException,速度遠比一次判空檢查慢
方法內聯
方法內聯是編譯器最重要的優化手段之一,除了消除方法呼叫成本之外,更重要的意義是為其他優化手段建立良好的基礎。方法內聯的優化行為只不過是把目標方法的程式碼“複製”到發起呼叫的方法之中,避免發生真實的方法呼叫而已。但實際上java虛擬機器中的內聯過程遠遠沒有那麼簡單,因為java中的方法大多數是虛方法,虛方法在編譯期做內聯的時候根本無法確定應該使用哪個方法版本
對此java虛擬機器設計團隊想了很多辦法,首先是引入了一種名為“型別繼承關係分析”(Class Hierarchy Analysis, CHA)的技術,這是一種基於整個應用程式的型別分析技術,它用於確定在目前已載入的類中,某個介面是否有多餘一種的實現,某個類是否存在子類、子類是否為抽象類等資訊
編譯器在進行內聯:
* 非虛方法:直接進行內聯,這時候的內聯是有穩定前提保障的
* 虛方法:向CHA查詢此方法只在當前程式下是否有多個目標版本可供選擇
* 只有一個:可以進行內聯,不過這種內聯屬於激進優化,需要預留一個“逃生門”,稱為守護內聯(Guarded Inlining)。如果程式的後續執行過程中,虛擬機器一直沒有載入到會令這個方法的接收者的繼承關係發生變化的類,那這個內聯遊湖的程式碼就可以一直使用下去。否則,就需要拋棄已經編譯的程式碼,退回到解釋狀態執行,或者重新進行編譯
* 有多個版本:編譯器還將進行最後一次努力,使用內聯快取(Inline Cache)來完成方法內聯。工作原理大致是:在未發生方法呼叫之前,內聯快取狀態為空,當第一次呼叫發生後,快取記錄下方法接收者的版本資訊,並且每次進行方法呼叫時都比較接收者版本,如果以後進來的每次呼叫的方法接收者版本都是一樣的,那這個內聯還可以一直用下去。如果發生了方法接收者不一致的情況,就說明程式真正使用了虛方法的多型特性,這時才會取消內聯,查詢虛方法表進行方法分派
逃逸分析
逃逸分析(Escape Analysis)是目前java虛擬機器中比較前沿的優化技術,它與型別繼承關係分析一樣,並不是直接優化程式碼的手段,而是為其他優化手段提供依據的分析技術。其基本行為是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,如作為呼叫引數傳遞到其他方法中,稱為方法逃逸;被外部執行緒訪問到,稱為執行緒逃逸
如果能證明一個物件不會逃逸到方法或執行緒之外,則可能為這個變數進行一些高效的優化:
- 棧上分配(Stack Allocation):將物件在棧上分配記憶體,這樣就可以使物件所佔記憶體空間隨棧幀出棧而銷燬,減小垃圾收集系統的壓力
- 同步消除(Synchronization Elimination):物件無法被其他執行緒訪問,這個變數的讀寫肯定不會有競爭,對這個變數實施的同步措施也就可以消除掉
- 標量替換(Scalar Replacement):標量(Scalar)是指一個數據已經無法再分解成更小的資料來表示。如果逃逸分析證明一個物件不會被外部訪問,並且這個物件可以被拆散的話,那程式真正執行的時候可能不建立這個物件,而直接建立它的成員變數來代替。將物件拆分後,除了可以讓物件的成員變數在棧上分配和讀寫之外,還可以為後續進一步的優化手段建立條件
java與C/C++的編譯器對比
java與C/C++的編譯器對比實際上代表了最經典的即時編譯器與靜態編譯器的對比。java虛擬機器的即時編譯器與C/C++的靜態優化編譯器相比,可能會由於下列原因而導致輸出的原生代碼有一些劣勢:
- 即時編譯器執行時佔用的是使用者程式的執行時間,因此即時編譯器不敢隨便引入大規模的優化技術,而編譯的時間成本在靜態優化編譯器中並不是主要的關注點
- java語言是動態的型別安全語言,這就意味著虛擬機器必須頻繁地進行安全檢查
- java語言中虛方法的使用頻率遠遠大於C/C++語言,導致即時編譯器在進行一些優化時的難度要遠大於C/C++的靜態優化編譯器
- java語言時可以動態擴充套件的語言,執行時載入新的類可能改變程式型別的繼承關係,導致許多全域性的優化措施都只能以激進優化的方式來完成
- java虛擬機器中物件的記憶體分配都是在堆上進行的,而C/C++的物件則有多種分配方式,而且C/C++中主要由使用者程式程式碼來回收分配的記憶體,因此執行效率上比垃圾收集機制要高
上面說的java語言相對C/C++的劣勢都是為了換取開發效率上的優勢而付出的代價,而且還有許多優化是java的即時編譯器能做而C/C++的靜態優化編譯器不能做或者不好做的,如別名分析、呼叫頻率預測、分支頻率預測、裁剪為被選擇的分支等