JIT
在談到 Java 的編譯機制的時候,其實應(yīng)該按時期凌那,分為兩個階段。一個是 javac 指令將 Java 源碼變?yōu)?Java 字節(jié)碼的靜態(tài)編譯過程。另一個是 Java 字節(jié)碼編譯為本地機器碼的過程辈灼,并且因為這個過程是在程序運行時期完成的所以稱之為即時編譯(JIT),下面我們討論的編譯也都是指“即時編譯”過程役衡。
解釋器
java作為一種跨平臺的語言實現(xiàn)了一次編譯到處運行的特性茵休,這也就決定了它編譯出來的不是機器碼而是特定的字節(jié)碼。解釋器(各平臺不同)就是將字節(jié)碼解釋為機器指令,調(diào)用操作系統(tǒng)來完成程序的執(zhí)行榕莺。
編譯器
解釋器雖然實現(xiàn)了跨平臺的特性俐芯,但是解釋執(zhí)行的效率是很低的,是以犧牲性能為代價來換取的跨平臺特性钉鸯。所以 JVM 發(fā)現(xiàn)某個方法或者代碼塊的運行特別頻繁時吧史,就會把這些代碼認定為“熱點代碼”(Hot Spot Code,不知道Sun的虛擬機命名是否跟這個有聯(lián)系)唠雕。為了提高熱點代碼的執(zhí)行效率贸营,在運行時,虛擬機就會將這些代碼翻譯成與本地平臺相關(guān)的機器碼岩睁,并進行各種層次的優(yōu)化钞脂,完成這個任務(wù)的就是編譯器,被稱為即時編譯器(Just In Time Compiler捕儒,簡稱為JIT)冰啃。
HotSpot 虛擬機內(nèi)置兩個即時編譯器,稱為 Client Compiler 和 Server Compiler刘莹,分別簡稱為 C1阎毅,C2。
- C1 編譯器是一個簡單快速的編譯器点弯,主要的關(guān)注點在于局部性的優(yōu)化扇调,適用于執(zhí)行時間較短或?qū)有阅苡幸蟮某绦?/li>
- C2 編譯器是為長期運行的應(yīng)用程序做性能調(diào)優(yōu)的編譯器,適用于執(zhí)行時間較長或?qū)Ψ逯敌阅苡幸蟮某绦蚯栏亍狼钮?赡軙Υa進行激進的優(yōu)化來獲取更好的性能,這些優(yōu)化往往伴隨著耗時較長的代碼分析雌团,同時會設(shè)定“逃生門”在激進優(yōu)化不成立的時候回退到 C1 編譯器或者解釋器繼續(xù)執(zhí)行
分層編譯
由于即時編譯器編譯本地代碼需要占用程序運行時間燃领,而要編譯出優(yōu)化程度較高的代碼,所花費的時間可能更多锦援。為了在程序啟動速度與運行效率之間達到平衡猛蔽,HotSpot 虛擬機啟用了分層編譯(Tiered Compilation)策略。
在分層編譯中灵寺,會同時使用兩個編譯器曼库。當 C2 編譯器在等待并分析一些代碼片段來收集信息的時候,C1 編譯器首先開始編譯略板。這使得 C1 編譯器能夠快速的提高性能毁枯;而 C2 編譯器將能夠更好地提高性能,因為它擁有有熱點方法更好的信息叮称。分層編譯在 JDK1.6 時期出現(xiàn)种玛,在 JDK1.7 的 Server 模式中作為默認編譯策略開啟藐鹤。
根據(jù)編譯器編譯、優(yōu)化的規(guī)模耗時赂韵,劃分出不同的編譯級別:
Level | Compiler |
---|---|
0 | 僅解釋執(zhí)行 |
1 | 執(zhí)行不帶 profiling 的 C1 代碼 |
2 | 執(zhí)行僅帶方法調(diào)用次數(shù)以及循環(huán)回邊執(zhí)行次數(shù) profiling 的 C1 代碼 |
3 | 執(zhí)行帶所有 profiling 的 C1 代碼 |
4 | 執(zhí)行 C2 代碼 |
profiling 就是收集能夠反映程序執(zhí)行狀態(tài)的數(shù)據(jù)娱节。其中最基本的統(tǒng)計數(shù)據(jù)就是方法的調(diào)用次數(shù),以及循環(huán)回邊的執(zhí)行次數(shù)祭示。
通常情況下肄满,C2 代碼的執(zhí)行效率要比 C1 代碼的高出 30% 以上。對于 C1 代碼的三種狀態(tài)质涛,按執(zhí)行效率從高至低則是 1 層 > 2 層 > 3 層稠歉。其中 1 層的性能比 2 層的稍微高一些,而 2 層的性能又比 3 層高出 30%汇陆。這是因為 profiling 越多怒炸,其額外的性能開銷越大。
這 5 個層次的執(zhí)行狀態(tài)中毡代,1 層和 4 層為終止狀態(tài)横媚。當一個方法被終止狀態(tài)編譯過后,如果編譯后的代碼并沒有失效月趟,那么 Java 虛擬機將不再次發(fā)出該方法的編譯請求的。
上圖列舉了一些編譯的路徑恢口。
通常情況下孝宗,熱點方法會經(jīng)過 3 層的 C1 編譯,然后再被 4 層的 C2 編譯耕肩。
如果方法的字節(jié)碼數(shù)目比較少(如 getter/setter)因妇,而且 3 層的 profiling 沒有可收集的數(shù)據(jù)。那么 JVM 斷定該方法對于 C1 代碼和 C2 代碼的執(zhí)行效率相同猿诸。在這種情況下婚被,Java 虛擬機會在 3 層編譯之后,直接選擇用 1 層的 C1 編譯梳虽。由于這是一個終止狀態(tài)址芯,因此 Java 虛擬機不會繼續(xù)用 4 層的 C2 編譯。
默認啟用的是混合模式(解釋器與編譯器配合工作)
可以使用 -Xint 參數(shù)強制虛擬機運行于只有解釋器模式下
可以使用 -Xcomp 強制虛擬機運行于只有 JIT 的編譯模式下
Java8 中默認開啟分層編譯 -client窜觉,-server 參數(shù)已經(jīng)無效谷炸,如果只想開啟 C2,可以關(guān)閉分層編譯(-XX:-TieredCompilation)
如果只想開啟 C1禀挫,可以在打開分層編譯的同時旬陡,使用參數(shù):-XX:TieredStopAtLevel=1。
熱點探測
JIT 編譯器基于一個非秤镉ぃ基本的原則:編譯和優(yōu)化執(zhí)行頻率更高的代碼段描孟。如果代碼很少執(zhí)行驶睦,即使優(yōu)化之后提升 80% 的速度也是沒有必要的∧湫眩可以說熱點代碼是 JIT 編譯的前提场航,而熱點代碼的判定就是基于熱點探測技術(shù)。
基于采樣的熱點探測
主要是虛擬機會周期性的檢查各個線程的棧頂青抛,若某個或某些方法經(jīng)常出現(xiàn)在棧頂旗闽,那這個方法就是“熱點方法”。
優(yōu)點是實現(xiàn)簡單蜜另。
缺點是很難精確一個方法的熱度适室,容易受到線程阻塞或外界因素的影響。
基于計數(shù)器的熱點探測
主要就是虛擬機給每一個方法甚至代碼塊建立了一個計數(shù)器举瑰,統(tǒng)計方法的執(zhí)行次數(shù)捣辆,超過一定的閥值則標記為此方法為熱點方法。
HotSpot 虛擬機使用的基于計數(shù)器的熱點探測方法此迅。然后使用了兩類計數(shù)器:方法調(diào)用計數(shù)器和回邊計數(shù)器汽畴。當方法計數(shù)器和回邊計數(shù)器之和超過方法計數(shù)器閾值時,就會觸發(fā)JIT編譯器耸序。
- 方法調(diào)用計數(shù)器:方法調(diào)用計數(shù)器用于統(tǒng)計方法被調(diào)用的次數(shù)忍些,默認閾值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次坎怪,可通過 -XX: CompileThreshold 來設(shè)定罢坝;而在分層編譯的情況下 -XX: CompileThreshold 指定的閾值將失效,此時將會根據(jù)當前待編譯的方法數(shù)以及編譯線程數(shù)來動態(tài)調(diào)整搅窿。
- 回邊計數(shù)器:回邊計數(shù)器用于統(tǒng)計一個方法中循環(huán)體代碼執(zhí)行的次數(shù)嘁酿,在字節(jié)碼中遇到控制流向后跳轉(zhuǎn)的指令稱為“回邊”(Back Edge),該計數(shù)器用于計算是否觸發(fā) C1 編譯的閾值男应。HotSpot 虛擬機提供 -XX:BackEdgeThreshold 供用戶設(shè)置闹司,但是當前的 HotSpot 虛擬機實際上并未使用此參數(shù)。而需要通過 -XX: OnStackReplacePercentage 來間接調(diào)整回邊計數(shù)器的閾值沐飘,在 C1游桩,C2 模式下計算公式也有不同,需要區(qū)別配置薪铜。而在分層編譯的情況下众弓,-XX: OnStackReplacePercentage 指定的閾值同樣會失效,此時將根據(jù)當前待編譯的方法數(shù)以及編譯線程數(shù)來動態(tài)調(diào)整隔箍。
常見編譯優(yōu)化
方法內(nèi)聯(lián)
在編譯時谓娃,將方法調(diào)用優(yōu)化為直接使用方法體中的代碼進行替換,這就是方法內(nèi)聯(lián)蜒滩,這樣做減少了方法調(diào)用過程中壓棧與出棧的開銷滨达,同時也為之后的一些優(yōu)化手段提供條件奶稠。
@Benchmark
public int inline() {
CounterObj counterObj = new CounterObj();
counterObj.add(1);
counterObj.add(2);
return counterObj.getCounter();
}
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public int dontInline() {
CounterObj counterObj = new CounterObj();
counterObj.add(1);
counterObj.add(2);
return counterObj.getCounter();
}
public static class CounterObj {
@Getter
private int counter;
public void add(int num) {
this.counter = sum(this.counter, num);
}
public int sum(int a, int b) {
return a + b;
}
}
------------------------------------------------------------------------
Benchmark Mode Cnt Score Error Units
MethodInline.dontInline avgt 5 3.936 ± 0.127 ns/op
MethodInline.inline avgt 5 2.620 ± 0.042 ns/op
逃逸分析
如果一個變量的使用,在運行期檢測它的作用范圍不會超過一個方法或者一個線程的作用域捡遍。那么這個變量就不會被多個線程所共享锌订,也就是說可以不將其分配在堆空間中,而是將其線程私有化画株。
如何來檢測一個變量的作用域僅在一個方法或者線程中呢? JVM 中使用全局數(shù)據(jù)流分析機制實現(xiàn)的一種機制辆飘,稱之為逃逸分析,作為其他一些激進優(yōu)化的前提條件谓传。
可以通過 -XX:+DoEscapeAnalysis 開啟逃逸分析(jdk8中默認開啟)蜈项,-XX:-DoEscapeAnalysis 來關(guān)閉逃逸分析。下面是基于逃逸分析基礎(chǔ)上做的一些優(yōu)化续挟。
標量替換
- 標量:即不可被進一步分解的量紧卒,Java 的基本數(shù)據(jù)類型就是標量(如:int,long 等基本數(shù)據(jù)類型以及 reference 類型等)诗祸。
- 聚合量:標量的對立就是可以被進一步分解的量跑芳,被稱之為聚合量,Java 中對象就是聚合量直颅。
當對象不會被外部訪問博个,并且對象可以被進一步分解時,JVM 不會創(chuàng)建該對象功偿,而會將該對象成員變量分解若干個被這個方法使用的成員變量所代替坡倔,這個過程就是標量替換。對象將跟隨棧的創(chuàng)建而創(chuàng)建脖含,銷毀而銷毀,減輕了 GC 的負擔以及工作內(nèi)存跟主存的同步消耗投蝉。
很多人會把標量替換跟棧上分配拆開來解釋养葵,但我認為標量替換跟棧上分配說的是一件事情。因為在棧上是不能創(chuàng)建對象的(棧上只能存放一些基本類型以及對象的引用)瘩缆,只有進行了標量替換关拒,將聚合量拆分為標量之后才達成棧上分配的目的。
可以通過 -XX:+EliminateAllocations 開啟標量替換(jdk8 中默認開啟)庸娱,-XX:-EliminateAllocations 來關(guān)閉標量替換着绊。
@Benchmark
@Fork(jvmArgsAppend = "-XX:+EliminateAllocations")
public void escaped() {
methodA();
}
@Benchmark
@Fork(jvmArgsAppend = "-XX:-EliminateAllocations")
public void noEscape() {
methodA();
}
public void methodA() {
new Tmp();
}
@Data
public static class Tmp {
private int data;
}
------------------------------------------------------------------------
Benchmark Mode Cnt Score Error Units
ScalarReplace.escaped avgt 5 0.354 ± 0.055 ns/op
ScalarReplace.noEscape avgt 5 2.661 ± 0.264 ns/op
同步消除
當加鎖的變量不會發(fā)生逃逸,是線程私有的時候熟尉,那么完全沒有必要加鎖归露。 在 JIT 時期就可以將同步鎖去掉,以減少加鎖與解鎖造成的資源開銷斤儿。
@Benchmark
@Fork(jvmArgsAppend = "-XX:+EliminateLocks")
public void escaped() {
methodA();
}
@Benchmark
@Fork(jvmArgsAppend = "-XX:-EliminateLocks")
public void noEscape() {
methodA();
}
public void methodA() {
synchronized (new Object()) {
// do nothing
}
}
------------------------------------------------------------------------
Benchmark Mode Cnt Score Error Units
LockRemove.escaped avgt 5 0.357 ± 0.053 ns/op
LockRemove.noEscape avgt 5 21.847 ± 0.236 ns/op
除了上面舉例的幾種經(jīng)典優(yōu)化方式篇恒,JVM 還為我們執(zhí)行很多其他優(yōu)化逆害,如:無用代碼消除(Dead Code Elimination)次乓、循環(huán)展開(Loop Unrolling)、循環(huán)表達式外提(Loop Expression Hoisting)一铅、消除公共子表達式(Common Subexpression Elimination)、常量傳播(Constant Propagation)堕油、基本塊沖排序(Basic Block Reordering)等潘飘。
代碼緩存
經(jīng)過辛苦的編譯優(yōu)化之后的本地代碼是比較珍貴的,這些代碼會被緩存起來掉缺,當下一次運行的時候就可以直接使用了卜录,也就是所謂的代碼緩存(Code Cache)。在 32 位機器client模式默認 32MB攀圈,64 位機器默認 240MB暴凑。可以使用- XX:InitialCodeCacheSize赘来,-XX:ReservedCodeCacheSize 來修改代碼緩存的大小现喳。
代碼緩存很少引起性能問題,但是一旦發(fā)生其影響可能是毀滅性的犬辰。如果代碼緩存被占滿嗦篱,JVM 會打印出一條警告消息,并切換到 interpreted-only 模式:JIT 編譯器被停用幌缝,字節(jié)碼將不再會被編譯成機器碼灸促。應(yīng)用程序?qū)⒗^續(xù)運行,但運行速度會降低一個數(shù)量級涵卵,直到有人注意到這個問題浴栽。
通過設(shè)置 -XX:+UseCodeCacheFlushing 這個參數(shù),當代碼緩存滿了的時候轿偎,會讓 JVM 換出一部分緩存以容納新編譯的代碼典鸡,避免直接進入解釋模式使性能急劇下降。在默認情況下坏晦,這個選項是關(guān)閉的萝玷。
其他
編譯相關(guān)參數(shù)
- -XX:+TieredCompilation:開啟分層編譯,jdk8 之后默認開啟
- -XX:+CICompilerCount=N:編譯線程數(shù)昆婿,設(shè)置數(shù)量后球碉,JVM 會自動分配線程數(shù),C1:C2=1:2
- -XX:TierXBackEdgeThreshold:OSR 編譯的閾值
- -XX:TierXMinInvocationThreshold:開啟分層編譯后各層調(diào)用的閾值
- -XX:TierXCompileThreshold:開啟分層編譯后的編譯閾值
- -XX:ReservedCodeCacheSize:codeCache 最大大小
- -XX:InitialCodeCacheSize:codeCache 初始大小
- -XX:+PrintCompilation:輸出編譯過程
- -XX:+PrintInlining:輸出方法內(nèi)聯(lián)信息仓蛆,需要跟 -XX:+UnlockDiagnosticVMOptions 一起使用
由于編譯情況復(fù)雜睁冬,JVM 也會動態(tài)調(diào)整相關(guān)的閾值來保證 JVM 的性能,所以不建議手動調(diào)整編譯相關(guān)的參數(shù)看疙。除非一些特定的 Case痴突,比如 CodeCache 滿了停止編譯搂蜓,可以適當增加 CodeCache 大小×勺埃或者一些非常常用的方法帮碰,未被內(nèi)聯(lián)到而拖累了性能,可以調(diào)整內(nèi)斂層數(shù)或者內(nèi)聯(lián)方法的大小來解決拾积。
編譯輸出信息簡介
上圖是一段編譯的信息輸出殉挽,從左到右依次是:
- timestamp:從開始啟動到現(xiàn)在的時間
- compile_id:為每個編譯過的方法賦值的一個自增 ID
- attributes:表示正在編譯的代碼的狀態(tài)
- tier_level:編譯的級別,可參照上文對編譯級別的介紹
- method:編譯的方法名
- size:Java 字節(jié)碼的大小
- deopt:去優(yōu)化拓巧,也就是廢棄優(yōu)化
attributes信息
有五種不同類型的屬性來表示編譯的狀態(tài)斯碌。
% - The compilation is OSR (on-stack replacement).
s - The method is synchronized.
! - The method has an exception handler.
b - Compilation occurred in blocking mode.
n - Compilation occurred for a wrapper to a native method.
deopt 信息
該字段通常具有以下兩個值之一:“made not entrant”或“made zombie”。
- made not entrant:有兩種情況會發(fā)生這種情況肛度。①分層編譯模式下傻唾,更好的的優(yōu)化代碼出現(xiàn)時,將舊的編譯代碼無效承耿,例如完成 4 層編譯時候?qū)?3 層編譯無效②編譯器收集了更多的信息冠骄,將優(yōu)化進行回滾以便能夠再次編譯它,并基于新的信息重新優(yōu)化代碼加袋。
- made zombie:對于僵尸代碼凛辣,這基本上是一種清理機制。在一段代碼被標記為非進入者之后职烧,它最終將被標記為 zombie扁誓,并將由 GC 收集以從代碼緩存中釋放該空間。
我們可以看到為了讓我們的代碼跑的更快蚀之,JVM 默默為我們做了很多的事情蝗敢,但是凡事都是有利有弊。比如一個 QPS 較高的應(yīng)用足删,重啟之后如果沒有比較好的預(yù)熱策略前普,可能就會因為分層編譯導(dǎo)致接口響應(yīng)變慢,CPU 飆升等問題壹堰。
深入理解Java虛擬機--周志明
JVM實用參數(shù)(二)JVM類型、工作模式及代碼緩存
函數(shù)在實現(xiàn)過程內(nèi)存中的壓棧和出棧
熱點代碼骡湖、分層編譯贱纠、JIT優(yōu)化(方法內(nèi)聯(lián)、鎖消除响蕴、標量替換)