0. 概述
本文對后端編譯器:即時編譯器(JIT編譯器)和提前編譯器(AOT編譯器)進行分析整理饼问。
兩者都不是JVM必需的組成部分芯肤。但是树肃,后端編譯器編譯性能的好壞甸赃、代碼優(yōu)化質(zhì)量的高低,去我是衡量商用JVM優(yōu)秀與否的關(guān)鍵指標之一趟据,也是其核心所在券犁,最能提心技術(shù)水平與價值的功能。
1. 即時編譯器
目前主流的兩款商用 JVM(HotSpot汹碱、OpenJ9)中粘衬,Java 程序最初都是通過「解釋器(Interpreter)」解釋執(zhí)行的,當 JVM 發(fā)現(xiàn)某個方法或代碼塊的執(zhí)行特別頻繁咳促,就會認為它們是“熱點代碼(Hot Spot Code)”稚新。
為了提高熱點代碼的執(zhí)行效率,JVM 會在「運行時」把這部分代碼編譯成本地機器碼跪腹,并用各種手段去優(yōu)化代碼褂删。運行時完成這個任務(wù)的后端編譯器被稱為「即時編譯器」。
HotSpot VM 內(nèi)置了3個即時編譯器冲茸,分別為:
- 客戶端編譯器(Client Compiler)屯阀,簡稱C1編譯器。
- 服務(wù)端編譯器(Server Compiler)轴术,簡稱C2編譯器难衰,或Opto編譯器。
- Graal 編譯器(JDK 10 出現(xiàn)逗栽,長期目標是替代 C2 編譯器)盖袭。
1.1 解釋器與編譯器
1.1.1 執(zhí)行流程
編譯器的執(zhí)行流程大致如下:
輸入的代碼 -> [ 解釋器 解釋執(zhí)行 ] -> 執(zhí)行結(jié)果
即時編譯器的執(zhí)行流程大致如下:
輸入的代碼 -> [ 編譯器 編譯 ] -> 編譯后的代碼 -> [ 執(zhí)行 ] -> 執(zhí)行結(jié)果
1.1.2 對比分析
目前主流的商用 JVM 內(nèi)部都同時包含解釋器與編譯器,二者各有優(yōu)勢:
- 程序需要迅速啟動和執(zhí)行時彼宠,解釋器可以省去編譯時間鳄虱,立即執(zhí)行。
- 程序啟動后凭峡,編譯器逐漸發(fā)揮作用醇蝴,把越來越多的代碼編譯成本地代碼,可以減少解釋器的中間消耗想罕,提高執(zhí)行效率悠栓。
- 若運行環(huán)境的內(nèi)存資源限制較大霉涨,可使用解釋器執(zhí)行節(jié)約內(nèi)存;反之可使用編譯執(zhí)行來提升效率惭适。
總結(jié)起來就是:
- 解釋器啟動較快笙瑟,占用內(nèi)存較小,但是執(zhí)行效率稍低癞志。
- 編譯器啟動較慢往枷,占用內(nèi)存較大,但執(zhí)行效率較高凄杯。
此外错洁,解釋器還可以作為編譯器激進優(yōu)化時后備的“逃生門”,也就是給編譯器來“兜底”戒突,反之則不行屯碴。
凡事有利弊。這里仍以查詢接口為例做類比:
- 解釋執(zhí)行可以理解為直接查詢數(shù)據(jù)庫膊存,也就是不使用緩存导而。程序啟動起來比較快(無需連接緩存服務(wù)器),但后面運行的時候由于每次都要去查數(shù)據(jù)庫隔崎,會有磁盤 IO 開銷今艺,會相對慢一些。
- 而編譯執(zhí)行就相當于使用了緩存爵卒。雖然啟動會稍慢一些(需要連接緩存服務(wù)器虚缎,初次查詢時既要查詢數(shù)據(jù)庫,又要存入緩存)钓株,而且需要額外的開銷(需要緩存服務(wù)器)遥巴,但是后續(xù)的查詢效率會提高很多,因為可以直接從緩存獲取享幽,不必再查詢數(shù)據(jù)庫。
因此拾弃,使用緩存其實就是“空間換時間”值桩,編譯器與解釋器也可以類比來理解犯建。
1.1.3 運行模式
解釋器與編譯器配合使用的方式在虛擬機中被稱為“混合模式(Mixed Mode)”糠爬,比如我們查看 JDK 版本時:
$ java -version
java version "11.0.3" 2019-04-14 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)
最后面的 mixed mode 就表示混合模式吞琐。
1.2 分層編譯
JIT 編譯器的編譯過程是在「運行期」脖含,這就不可避免會占用應(yīng)用程序的資源宾舅。而且懊昨,想要把代碼優(yōu)化得更好茅撞,就要花費更多的時間秉颗。而且可能還需要解釋器幫忙收集一些性能監(jiān)控信息鸯隅,又降低了解釋器的效率澜建。這可怎么辦向挖?
那找個折衷的方案?其實就是分層編譯(Tiered Compilation)炕舵。
分了哪幾個層次呢何之?主要包括:
0 程序純解釋執(zhí)行,且解釋器不開啟性能監(jiān)控功能咽筋。
1 使用 C1 編譯器將字節(jié)碼編譯為本地代碼來執(zhí)行溶推,進行簡單可靠的穩(wěn)定優(yōu)化,不開啟性能監(jiān)控功能奸攻。
2 使用 C1 編譯器執(zhí)行蒜危,僅開啟一部分性能監(jiān)控功能(方法及回邊次數(shù)統(tǒng)計等)。
3 使用 C1 編譯器執(zhí)行睹耐,開啟全部性能監(jiān)控(在第二層之外辐赞,還會收集如分支跳轉(zhuǎn)、虛方法調(diào)用版本等全部的統(tǒng)計信息)疏橄。
4 使用 C2 編譯器將字節(jié)碼編譯為本地代碼(相比 C1 編譯器占拍,C2 編譯器會啟用更多編譯耗時更長的優(yōu)化,還會根據(jù)性能監(jiān)控信息進行一些不可靠的激進優(yōu)化)捎迫。
這幾個層次并非固定不變晃酒,可以根據(jù)不同的運行參數(shù)靈活使用。
1.3 熱點代碼
運行時會被即時編譯器編譯的目標是“熱點代碼”窄绒,主要包括下面兩類:
被多次調(diào)用的方法贝次。
被多次執(zhí)行的循環(huán)體。
前者比較容易理解:一個方法被調(diào)用的次數(shù)多了彰导,自然就成了熱點代碼蛔翅。
后者是什么場景呢?當一個方法被調(diào)用的次數(shù)雖然不多位谋,但方法體內(nèi)部存在循環(huán)次數(shù)較多的循環(huán)體山析。這種代碼也是“熱點代碼”(可以理解為方法的一部分是熱點代碼)。比如:
public void test(){
//一些其他代碼...
//即便test()方法被調(diào)用的次數(shù)不多掏父,但是當N足夠大時笋轨,該部分代碼都會成為“熱點代碼”
for(int i=0;i<N;i++){
//執(zhí)行一些操作
}
//一些其他代碼
}
前者是JVM標準的及時編譯。
至于后者赊淑,雖然熱點代碼只是方法的一部分爵政,但編譯器仍會把「整個方法」作為編譯對象,只是入口不同(并非從方法的第一行代碼開始)陶缺。由于該情況發(fā)生在方法執(zhí)行的過程中钾挟,也被稱為棧上替換(On Stack Replacement,OSR)饱岸。也就是方法的棧幀還在棧上掺出,但方法已經(jīng)被替換了徽千。
PS: 每個方法被執(zhí)行時,虛擬機棧都會創(chuàng)建一個棧幀(Stack Frame)用于存儲局部變量表蛛砰、操作數(shù)棧等信息罐栈。每個方法從被調(diào)用直至執(zhí)行完畢的過程,就對應(yīng)著一個棧幀在虛擬機棧中從入棧到出棧的過程泥畅。
1.4 熱點探測
關(guān)于熱點代碼的判定荠诬,前面一直提的都是“多次”,到底多少次才叫“多”呢位仁?這個問題不僅要“定性”柑贞,還要“定量”。
要判定一段代碼是不是熱點代碼聂抢、是否觸發(fā)即時編譯的行為稱為“熱點探測(Hot Spot Code Detection)”钧嘶。
1.4.1 定量方法
熱點探測的主流方法有以下兩種:
- 基于采樣的熱點探測(Sample Based Hot Spot Code Detection)
就是每隔一段時間去檢查一下所有線程的調(diào)用棧頂,若發(fā)現(xiàn)某個(或某些)方法經(jīng)常出現(xiàn)在棧頂琳疏,該方法就會被認為是“熱點代碼”有决。J9 虛擬機使用過該方法。
這種方法的優(yōu)缺點如下:
- 優(yōu)點:實現(xiàn)簡單高效空盼,而且可以通過堆棧信息獲取到方法之間的調(diào)用關(guān)系书幕;
- 缺點:難以精確的確定方法熱度,容易受到線程阻塞的干擾(即方法阻塞時可能長時間處于棧頂揽趾,可能產(chǎn)生誤判)台汇。
- 基于計數(shù)器的熱點探測(Counter Based Hot Spot Code Detection)
為每個方法(或代碼塊)建立計數(shù)器來統(tǒng)計方法的執(zhí)行次數(shù),當次數(shù)超過一定的閾值就認為是“熱點代碼”篱瞎。HotSpot 虛擬機就是使用該方法進行探測的苟呐。
該方法的同樣也有優(yōu)缺點:
1.優(yōu)點:統(tǒng)計結(jié)果更加精確嚴謹;
2.缺點:統(tǒng)計起來稍麻煩(要為每個方法建立并維護計數(shù)器)俐筋,而且不能直接獲取到方法的調(diào)用關(guān)系牵素。
1.4.2 兩種計數(shù)器
1.4.2.1 方法調(diào)用計數(shù)器
方法調(diào)用計數(shù)器(Invocation Counter)用來統(tǒng)計方法被調(diào)用的次數(shù)。它在客戶端和服務(wù)端模式下的默認閾值分別為 1500 次和 10000 次澄者。
該計數(shù)器觸發(fā)即時編譯的流程圖如下:
PS: 方法調(diào)用計數(shù)器統(tǒng)計的并非方法被調(diào)用的絕對次數(shù)笆呆,而是是一個相對的執(zhí)行頻率。
什么意思呢闷哆?
也就是在一段時間內(nèi),如果方法的調(diào)用次數(shù)未到達閾值单起,計數(shù)器就會減少為原先的一半抱怔。該過程被稱為熱度衰減(Counter Decay),這段時間則被稱為半衰周期(Counter Half Life Time)嘀倒。
比如屈留,若閾值是 10000局冰,半衰周期是 1 小時。如果在 1 小時內(nèi)灌危,某個方法被調(diào)用了 8000 次(未達到即時編譯的條件)康二,計數(shù)器就會認為該方法沒那么“熱”,就要給它“潑冷水”勇蝙,把次數(shù)降為 4000 (純屬個人理解)沫勿。
當然,有 JVM 參數(shù)可以對此進行調(diào)整味混,如下:
# 指定計數(shù)器的閾值
-XX:CompileThreshold
# 關(guān)閉熱度衰減
-XX:-UseCounterDecay
# 設(shè)置半衰期時間(秒)
-XX:CounterHalfLifeTime
1.4.2.2 回邊計數(shù)器
回邊計數(shù)器(Back Edge Counter)用來統(tǒng)計方法中循環(huán)體代碼執(zhí)行的次數(shù)(字節(jié)碼中遇到控制流向后跳轉(zhuǎn)的指令稱為“回邊”)产雹,目的是為了觸發(fā)棧上替換。
回邊計數(shù)器觸發(fā)即時編譯的流程如下:
與此相關(guān)的幾個 JVM 參數(shù):
# OSR 比率翁锡,默認 933
-XX:OnStackReplacePercentage
# 解釋器監(jiān)控比率蔓挖,默認 33
-XX:InterpreterProfilePercentage
2. 提前編譯器
對提前編譯的研究主要有兩個分支。
2.1 靜態(tài)翻譯
一條就是在程序運行之前馆衔,把程序代碼“翻譯”成機器碼瘟判。
JIT 編譯器的主要缺點在于:它是在「運行期」進行編譯的。這就不可避免地要占用應(yīng)用程序的運行資源(CPU角溃、內(nèi)存等)拷获,進而影響程序的執(zhí)行性能。
而這種提前編譯就是把這個編譯階段放到程序的「運行期」之前开镣,這樣就可以不占用應(yīng)用程序的資源刀诬。
2.2 即時編譯緩存
其實就是把 JIT 編譯器要做的編譯工作先做好,并保存下來邪财,當觸發(fā) JIT 編譯時陕壹,直接調(diào)用這里的代碼就好了。本質(zhì)上就是給 JIT 編譯做緩存树埠。
這種方式也被稱為動態(tài)提前編譯(Dynamic AOT)或者即時編譯緩存(JIT Caching)糠馆。
2.3 即時編譯器與提前編譯器
從上面對提前編譯器的分析來看,似乎提前編譯比 JIT 編譯運行效率更高怎憋。那它就沒缺點了嗎又碌?當然不是,否則還要 JIT 編譯器干嘛绊袋。
相比提前編譯器毕匀,JIT 編譯器的優(yōu)勢在哪里呢?
- 性能分析制導(dǎo)優(yōu)化
解釋器或客戶端編譯器在運行的過程中癌别,會不斷收集性能監(jiān)控信息(方法版本選擇皂岔、條件判斷等),這些信息可以幫助 JIT 編譯器對代碼進行集中優(yōu)化展姐。
這一點在靜態(tài)分析時是很難做到的躁垛。
- 激進預(yù)測性優(yōu)化
也就是 JIT 編譯器可以進行一些稍微“激進”的優(yōu)化行為剖毯,即便這些行為失敗了,也有解釋器可以“兜底”教馆。而靜態(tài)優(yōu)化就做不到了逊谋。
此外,提前編譯還會破壞 Java 平臺中立性土铺、產(chǎn)生字節(jié)膨脹等問題胶滋。
3. 編譯優(yōu)化技術(shù)
前面分析了 JIT 編譯器和提前編譯器,它們做的都是“翻譯”工作舒憾。但關(guān)鍵問題不在于“能不能”翻譯镀钓,而是翻譯的“好不好”。也就是編譯出來的代碼質(zhì)量高不高镀迂。
那么丁溅,它們用什么手段來提升“翻譯”的質(zhì)量呢?
HotSpot VM 的 JIT 編譯器使用了不少優(yōu)化技術(shù)(可參考:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex)探遵,下面介紹幾個非常重要的窟赏。
PS: JIT 編譯器對代碼的優(yōu)化,這里的“代碼”并非我們編寫的源代碼箱季,而是被編譯后的字節(jié)碼或者機器碼涯穷。畢竟已經(jīng)通過類加載器把 Class 文件加載到 JVM 了。
3.1 方法內(nèi)聯(lián)
方法內(nèi)聯(lián)是編譯器最重要的優(yōu)化手段藏雏,業(yè)內(nèi)戲稱為“優(yōu)化之母”拷况。是其他優(yōu)化手段的基礎(chǔ)。
它的行為理解起來其實很簡單:就是在方法調(diào)用中掘殴,把目標方法的代碼“復(fù)制”到調(diào)用的方法之中赚瘦,避免發(fā)生真實的方法調(diào)用。示例代碼如下:
public static void foo() {
if (obj != null) {
System.out.println("hello");
}
}
public static void testInline() {
Object obj = null;
foo(obj);
}
該段代碼實際是無用代碼(Dead Code)奏寨,經(jīng)過方法內(nèi)聯(lián)(把 foo 方法的代碼代入到 testInline 方法中)之后可以發(fā)現(xiàn)起意。
但若不做內(nèi)聯(lián),后續(xù)即便進行了無用代碼消除的優(yōu)化病瞳,也無法發(fā)現(xiàn)該無用代碼揽咕。
3.2 逃逸分析
逃逸分析(Escape Analysis)是目前 JVM 中比較前沿的優(yōu)化技術(shù)。但它并不直接優(yōu)化代碼套菜,而是一種為其他優(yōu)化措施提供依據(jù)的分析技術(shù)亲善。
它的基本原理是分析對象的動態(tài)作用域,當一個對象在方法中被定義后逗柴,按照逃逸程度從低到高可分為:
- 不逃逸:對象只能在本方法內(nèi)使用蛹头。
- 方法逃逸:對象可能被外部方法引用(例如作為調(diào)用參數(shù)傳遞到其他方法)。
- 線程逃逸:對象可能被外部線程訪問到(例如賦值給線程共享的變量)。
若一個對象未發(fā)生逃逸掘而,或者逃逸程度較低,可以為這個對象采取不同程度的優(yōu)化于购。
3.2.1 棧上分配
JVM 中袍睡,對象的內(nèi)存空間分配在堆上似乎是一個常識。當對象不再使用時肋僧,垃圾收集器會將其內(nèi)存空間回收斑胜,這個過程其實是要消耗大量資源的。
假如……把對象的內(nèi)存空間分配到棧上呢嫌吠?
What 止潘??辫诅?這簡直是顛覆認知凭戴!
但是,不妨沿著這個思路考慮一下:如果這樣做了有什么好處呢炕矮?
這樣一來對象占用的內(nèi)存空間就會隨著棧幀出棧而銷毀么夫,不必再由垃圾收集器費時費力地去回收了,可以節(jié)省不少資源肤视。這樣一想似乎也是不是不可以档痪。
這就是所謂的棧上分配(Stack Allocations),它可以支持「方法逃逸」邢滑,但不支持線程逃逸腐螟。
PS:由于復(fù)雜度等原因,HotSpot 目前暫未做這項優(yōu)化困后,但有些 JVM(例如 Excelsior JET)已經(jīng)在使用了乐纸。
3.2.2 標量替換
先看一下標量(Scalar)和聚合量(Aggregate)的概念:
- 標量:無法再分解為更小數(shù)據(jù)的數(shù)據(jù),例如 JVM 中的原始數(shù)據(jù)類型(int操灿、long锯仪、reference 等)。
- 聚合量:可以繼續(xù)分解的數(shù)據(jù)趾盐,例如 Java 中的對象庶喜。
所謂「標量替換(Scalar Replacement)」,就是根據(jù)實際訪問情況救鲤,將一個對象“拆解”開久窟,把用到的成員變量恢復(fù)為原始類型來訪問。
簡單來說本缠,就是把聚合量替換為標量斥扛。
若一個對象不會逃逸出「方法」,且可以被拆散,那么程序真正執(zhí)行時就可能不去創(chuàng)建這個對象稀颁,而是直接創(chuàng)建它的若干個被該方法使用的成員變量代替芬失。
還有這操作?
其實細想一下匾灶,這個操作跟前面的「棧上分配」還是有些類似的:棧上分配的是對象棱烂,而標量替換則是在棧上分配對象的一部分成員變量,連對象都懶得創(chuàng)建了阶女。
3.2.3 同步消除
線程同步本身相對耗時颊糜,如果逃逸分析能夠確定一個變量不會逃逸出線程,則該變量的讀寫就不會有線程安全問題秃踩,對該變量的同步措施就可以安全的消除了衬鱼。
換句話說,如果對線程安全的數(shù)據(jù)加了鎖憔杨,JVM 就可以把它優(yōu)化消除鸟赫。示例代碼如下:
public void t1() {
// 變量 o 不會逃逸出線程。因此消别,對它加的鎖就可以被消除
Object o = new Object();
synchronized (o) {
System.out.println(o.toString());
}
}
3.3 公共子表達式消除
如果一個表達式惯疙,在兩次計算過程中,其內(nèi)所有變量的值并沒有發(fā)生變化妖啥,那么則將其稱為公共子表達式霉颠。
舉個栗子:
int a = (b*c)*4+(c*b+d)+d
上面這段代碼在計算 b*c
的兩次中并沒有變化,因此可以將其簡寫為int a = E * 4 + (E + d) + d
荆虱,再進一步還可以進行 代數(shù)化簡 優(yōu)化,將其優(yōu)化為:
int a = E * 5 + 2 * d;
3.4 數(shù)組范圍檢查消除
假如有一個數(shù)組 array蒿偎,當我們訪問數(shù)組下標在 [0, array.length) 范圍之外的元素時,就會拋出 java.lang.ArrayIndexOutOfBoundsException 異常怀读,也就是數(shù)組越界了诉位,例如:
public void test1() {
String[] array = new String[]{"a", "b", "c"};
// 數(shù)組越界
String s = array[3];
}
其實是 JVM 在執(zhí)行的時候隱含了一次邊界判斷(運行期)。當這樣的判斷很多時菜枷,肯定對性能有一定的影響苍糠。
但這個判斷看起來似乎又是必要的,就不能優(yōu)化了嗎啤誊?
實際上也并非不能岳瞭,如果把這些判斷放在編譯期呢?代碼在編譯的時候蚊锹,就根據(jù)控制流分析(可參考前文的前端編譯)是否會產(chǎn)生數(shù)組越界瞳筏,那么在運行期間不是就不用判斷了嗎?
參考: