JVM性能分析

JIT

在談到 Java 的編譯機制的時候,其實應該按時期,分為兩個階段。一個是 javac 指令將 Java 源碼變?yōu)?Java 字節(jié)碼的靜態(tài)編譯過程。另一個是 Java 字節(jié)碼編譯為本地機器碼的過程,并且因為這個過程是在程序運行時期完成的所以稱之為即時編譯(JIT),下面我們討論的編譯也都是指“即時編譯”過程。

解釋器

java作為一種跨平臺的語言實現(xiàn)了一次編譯到處運行的特性,這也就決定了它編譯出來的不是機器碼而是特定的字節(jié)碼。解釋器(各平臺不同)就是將字節(jié)碼解釋為機器指令,調用操作系統(tǒng)來完成程序的執(zhí)行。

編譯器

解釋器雖然實現(xiàn)了跨平臺的特性,但是解釋執(zhí)行的效率是很低的,是以犧牲性能為代價來換取的跨平臺特性。所以 JVM 發(fā)現(xiàn)某個方法或者代碼塊的運行特別頻繁時,就會把這些代碼認定為“熱點代碼”(Hot Spot Code,不知道Sun的虛擬機命名是否跟這個有聯(lián)系)。為了提高熱點代碼的執(zhí)行效率,在運行時,虛擬機就會將這些代碼翻譯成與本地平臺相關的機器碼,并進行各種層次的優(yōu)化,完成這個任務的就是編譯器,被稱為即時編譯器(Just In Time Compiler,簡稱為JIT)。

HotSpot 虛擬機內置兩個即時編譯器,稱為 Client Compiler 和 Server Compiler,分別簡稱為 C1,C2。

  • C1 編譯器是一個簡單快速的編譯器,主要的關注點在于局部性的優(yōu)化,適用于執(zhí)行時間較短或對啟動性能有要求的程序
  • C2 編譯器是為長期運行的應用程序做性能調優(yōu)的編譯器,適用于執(zhí)行時間較長或對峰值性能有要求的程序??赡軙Υa進行激進的優(yōu)化來獲取更好的性能,這些優(yōu)化往往伴隨著耗時較長的代碼分析,同時會設定“逃生門”在激進優(yōu)化不成立的時候回退到 C1 編譯器或者解釋器繼續(xù)執(zhí)行

分層編譯

由于即時編譯器編譯本地代碼需要占用程序運行時間,而要編譯出優(yōu)化程度較高的代碼,所花費的時間可能更多。為了在程序啟動速度與運行效率之間達到平衡,HotSpot 虛擬機啟用了分層編譯(Tiered Compilation)策略。

在分層編譯中,會同時使用兩個編譯器。當 C2 編譯器在等待并分析一些代碼片段來收集信息的時候,C1 編譯器首先開始編譯。這使得 C1 編譯器能夠快速的提高性能;而 C2 編譯器將能夠更好地提高性能,因為它擁有有熱點方法更好的信息。分層編譯在 JDK1.6 時期出現(xiàn),在 JDK1.7 的 Server 模式中作為默認編譯策略開啟。

根據編譯器編譯、優(yōu)化的規(guī)模耗時,劃分出不同的編譯級別:

Level Compiler
0 僅解釋執(zhí)行
1 執(zhí)行不帶 profiling 的 C1 代碼
2 執(zhí)行僅帶方法調用次數(shù)以及循環(huán)回邊執(zhí)行次數(shù) profiling 的 C1 代碼
3 執(zhí)行帶所有 profiling 的 C1 代碼
4 執(zhí)行 C2 代碼

profiling 就是收集能夠反映程序執(zhí)行狀態(tài)的數(shù)據。其中最基本的統(tǒng)計數(shù)據就是方法的調用次數(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ā)出該方法的編譯請求的。

編譯路徑

上圖列舉了一些編譯的路徑。

通常情況下,熱點方法會經過 3 層的 C1 編譯,然后再被 4 層的 C2 編譯。

如果方法的字節(jié)碼數(shù)目比較少(如 getter/setter),而且 3 層的 profiling 沒有可收集的數(shù)據。那么 JVM 斷定該方法對于 C1 代碼和 C2 代碼的執(zhí)行效率相同。在這種情況下,Java 虛擬機會在 3 層編譯之后,直接選擇用 1 層的 C1 編譯。由于這是一個終止狀態(tài),因此 Java 虛擬機不會繼續(xù)用 4 層的 C2 編譯。

默認啟用的是混合模式(解釋器與編譯器配合工作)
可以使用 -Xint 參數(shù)強制虛擬機運行于只有解釋器模式下
可以使用 -Xcomp 強制虛擬機運行于只有 JIT 的編譯模式下
Java8 中默認開啟分層編譯 -client,-server 參數(shù)已經無效,如果只想開啟 C2,可以關閉分層編譯(-XX:-TieredCompilation)
如果只想開啟 C1,可以在打開分層編譯的同時,使用參數(shù):-XX:TieredStopAtLevel=1。

熱點探測

JIT 編譯器基于一個非?;镜脑瓌t:編譯和優(yōu)化執(zhí)行頻率更高的代碼段。如果代碼很少執(zhí)行,即使優(yōu)化之后提升 80% 的速度也是沒有必要的。可以說熱點代碼是 JIT 編譯的前提,而熱點代碼的判定就是基于熱點探測技術。

基于采樣的熱點探測

主要是虛擬機會周期性的檢查各個線程的棧頂,若某個或某些方法經常出現(xiàn)在棧頂,那這個方法就是“熱點方法”。

優(yōu)點是實現(xiàn)簡單。

缺點是很難精確一個方法的熱度,容易受到線程阻塞或外界因素的影響。

基于計數(shù)器的熱點探測

主要就是虛擬機給每一個方法甚至代碼塊建立了一個計數(shù)器,統(tǒng)計方法的執(zhí)行次數(shù),超過一定的閥值則標記為此方法為熱點方法。

HotSpot 虛擬機使用的基于計數(shù)器的熱點探測方法。然后使用了兩類計數(shù)器:方法調用計數(shù)器和回邊計數(shù)器。當方法計數(shù)器和回邊計數(shù)器之和超過方法計數(shù)器閾值時,就會觸發(fā)JIT編譯器。

  • 方法調用計數(shù)器:方法調用計數(shù)器用于統(tǒng)計方法被調用的次數(shù),默認閾值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通過 -XX: CompileThreshold 來設定;而在分層編譯的情況下 -XX: CompileThreshold 指定的閾值將失效,此時將會根據當前待編譯的方法數(shù)以及編譯線程數(shù)來動態(tài)調整。
  • 回邊計數(shù)器:回邊計數(shù)器用于統(tǒng)計一個方法中循環(huán)體代碼執(zhí)行的次數(shù),在字節(jié)碼中遇到控制流向后跳轉的指令稱為“回邊”(Back Edge),該計數(shù)器用于計算是否觸發(fā) C1 編譯的閾值。HotSpot 虛擬機提供 -XX:BackEdgeThreshold 供用戶設置,但是當前的 HotSpot 虛擬機實際上并未使用此參數(shù)。而需要通過 -XX: OnStackReplacePercentage 來間接調整回邊計數(shù)器的閾值,在 C1,C2 模式下計算公式也有不同,需要區(qū)別配置。而在分層編譯的情況下,-XX: OnStackReplacePercentage 指定的閾值同樣會失效,此時將根據當前待編譯的方法數(shù)以及編譯線程數(shù)來動態(tài)調整。

常見編譯優(yōu)化

方法內聯(lián)

在編譯時,將方法調用優(yōu)化為直接使用方法體中的代碼進行替換,這就是方法內聯(lián),這樣做減少了方法調用過程中壓棧與出棧的開銷,同時也為之后的一些優(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ù)據流分析機制實現(xiàn)的一種機制,稱之為逃逸分析,作為其他一些激進優(yōu)化的前提條件。

可以通過 -XX:+DoEscapeAnalysis 開啟逃逸分析(jdk8中默認開啟),-XX:-DoEscapeAnalysis 來關閉逃逸分析。下面是基于逃逸分析基礎上做的一些優(yōu)化。

標量替換

  • 標量:即不可被進一步分解的量,Java 的基本數(shù)據類型就是標量(如:int,long 等基本數(shù)據類型以及 reference 類型等)。
  • 聚合量:標量的對立就是可以被進一步分解的量,被稱之為聚合量,Java 中對象就是聚合量。

當對象不會被外部訪問,并且對象可以被進一步分解時,JVM 不會創(chuàng)建該對象,而會將該對象成員變量分解若干個被這個方法使用的成員變量所代替,這個過程就是標量替換。對象將跟隨棧的創(chuàng)建而創(chuàng)建,銷毀而銷毀,減輕了 GC 的負擔以及工作內存跟主存的同步消耗。

很多人會把標量替換跟棧上分配拆開來解釋,但我認為標量替換跟棧上分配說的是一件事情。因為在棧上是不能創(chuàng)建對象的(棧上只能存放一些基本類型以及對象的引用),只有進行了標量替換,將聚合量拆分為標量之后才達成棧上分配的目的。

可以通過 -XX:+EliminateAllocations 開啟標量替換(jdk8 中默認開啟),-XX:-EliminateAllocations 來關閉標量替換。

    @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

除了上面舉例的幾種經典優(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)等。

代碼緩存

經過辛苦的編譯優(yōu)化之后的本地代碼是比較珍貴的,這些代碼會被緩存起來,當下一次運行的時候就可以直接使用了,也就是所謂的代碼緩存(Code Cache)。在 32 位機器client模式默認 32MB,64 位機器默認 240MB??梢允褂? XX:InitialCodeCacheSize,-XX:ReservedCodeCacheSize 來修改代碼緩存的大小。

代碼緩存很少引起性能問題,但是一旦發(fā)生其影響可能是毀滅性的。如果代碼緩存被占滿,JVM 會打印出一條警告消息,并切換到 interpreted-only 模式:JIT 編譯器被停用,字節(jié)碼將不再會被編譯成機器碼。應用程序將繼續(xù)運行,但運行速度會降低一個數(shù)量級,直到有人注意到這個問題。

通過設置 -XX:+UseCodeCacheFlushing 這個參數(shù),當代碼緩存滿了的時候,會讓 JVM 換出一部分緩存以容納新編譯的代碼,避免直接進入解釋模式使性能急劇下降。在默認情況下,這個選項是關閉的。

其他

編譯相關參數(shù)

  • -XX:+TieredCompilation:開啟分層編譯,jdk8 之后默認開啟
  • -XX:+CICompilerCount=N:編譯線程數(shù),設置數(shù)量后,JVM 會自動分配線程數(shù),C1:C2=1:2
  • -XX:TierXBackEdgeThreshold:OSR 編譯的閾值
  • -XX:TierXMinInvocationThreshold:開啟分層編譯后各層調用的閾值
  • -XX:TierXCompileThreshold:開啟分層編譯后的編譯閾值
  • -XX:ReservedCodeCacheSize:codeCache 最大大小
  • -XX:InitialCodeCacheSize:codeCache 初始大小
  • -XX:+PrintCompilation:輸出編譯過程
  • -XX:+PrintInlining:輸出方法內聯(lián)信息,需要跟 -XX:+UnlockDiagnosticVMOptions 一起使用

由于編譯情況復雜,JVM 也會動態(tài)調整相關的閾值來保證 JVM 的性能,所以不建議手動調整編譯相關的參數(shù)。除非一些特定的 Case,比如 CodeCache 滿了停止編譯,可以適當增加 CodeCache 大小?;蛘咭恍┓浅3S玫姆椒?,未被內聯(lián)到而拖累了性能,可以調整內斂層數(shù)或者內聯(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 層編譯時候將 3 層編譯無效②編譯器收集了更多的信息,將優(yōu)化進行回滾以便能夠再次編譯它,并基于新的信息重新優(yōu)化代碼。
  • made zombie:對于僵尸代碼,這基本上是一種清理機制。在一段代碼被標記為非進入者之后,它最終將被標記為 zombie,并將由 GC 收集以從代碼緩存中釋放該空間。

我們可以看到為了讓我們的代碼跑的更快,JVM 默默為我們做了很多的事情,但是凡事都是有利有弊。比如一個 QPS 較高的應用,重啟之后如果沒有比較好的預熱策略,可能就會因為分層編譯導致接口響應變慢,CPU 飆升等問題。

深入理解Java虛擬機--周志明

JVM實用參數(shù)(二)JVM類型、工作模式及代碼緩存

代碼緩存

JIT與C1及C2

JIT——即時編譯的原理

函數(shù)在實現(xiàn)過程內存中的壓棧和出棧

jvm之方法內聯(lián)優(yōu)化

熱點代碼、分層編譯、JIT優(yōu)化(方法內聯(lián)、鎖消除、標量替換)

HotSpot中執(zhí)行引擎技術詳解(三)——代碼緩存機制

基本功 | Java即時編譯器原理解析及實踐

Java JIT compiler explained – Part 1

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容