Java 面試-即時編譯( JIT )

當咱們在寫代碼時,一個方法內部的行數天然是越少越好,這樣邏輯清晰、方便閱讀,其實好處遠不止如此,經過即時編譯,甚至能夠提升執行時的性能,今天就讓咱們好好來了解一下其中的原理。
java

簡介

當 JVM 的初始化完成後,類在調用執行過程當中,執行引擎會把字節碼轉爲機器碼,而後在操做系統中才能執行。在字節碼轉換爲機器碼的過程當中,虛擬機中還存在着一道編譯,那就是即時編譯git

最初,JVM 中的字節碼是由解釋器( Interpreter )完成編譯的,當虛擬機發現某個方法或代碼塊的運行特別頻繁的時候,就會把這些代碼認定爲熱點代碼github

爲了提升熱點代碼的執行效率,在運行時,即時編譯器(JIT,Just In Time)會把這些代碼編譯成與本地平臺相關的機器碼,並進行各層次的優化,而後保存到內存中。編程

分類

在 HotSpot 虛擬機中,內置了兩種 JIT,分別爲C1 編譯器C2 編譯器,這兩個編譯器的編譯過程是不同的。緩存

C1 編譯器

C1 編譯器是一個簡單快速的編譯器,主要的關注點在於局部性的優化,適用於執行時間較短或對啓動性能有要求的程序,也稱爲Client Compiler,例如,GUI 應用對界面啓動速度就有必定要求。安全

C2 編譯器

C2 編譯器是爲長期運行的服務器端應用程序作性能調優的編譯器,適用於執行時間較長或對峯值性能有要求的程序,也稱爲Server Compiler,例如,服務器上長期運行的 Java 應用對穩定運行就有必定的要求。服務器

分層編譯

在 Java7 以前,須要根據程序的特性來選擇對應的 JIT,虛擬機默認採用解釋器和其中一個編譯器配合工做。app

Java7 引入了分層編譯,這種方式綜合了 C1 的啓動性能優點和 C2 的峯值性能優點,咱們也能夠經過參數 -client或者-server 強制指定虛擬機的即時編譯模式。性能

分層編譯將 JVM 的執行狀態分爲了 5 個層次:優化

第 0 層:程序解釋執行,默認開啓性能監控功能(Profiling),若是不開啓,可觸發第二層編譯;

第 1 層:可稱爲 C1 編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,不開啓 Profiling;

第 2 層:也稱爲 C1 編譯,開啓 Profiling,僅執行帶方法調用次數和循環回邊執行次數 profiling 的 C1 編譯;

第 3 層:也稱爲 C1 編譯,執行全部帶 Profiling 的 C1 編譯;

第 4 層:可稱爲 C2 編譯,也是將字節碼編譯爲本地代碼,可是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

對於 C1 的三種狀態,按執行效率從高至低:第 1 層、第 2層、第 3層。

一般狀況下,C2 的執行效率比 C1 高出30%以上。

在 Java8 中,默認開啓分層編譯,-client-server 的設置已是無效的了。若是隻想開啓 C2,能夠關閉分層編譯(-XX:-TieredCompilation),若是隻想用 C1,能夠在打開分層編譯的同時,使用參數:-XX:TieredStopAtLevel=1

你能夠經過 java -version命令行能夠直接查看到當前系統使用的編譯模式:

C:\Users\Administrator>java -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)

mixed mode表明是默認的混合編譯模式,除了這種模式外,咱們還可使用-Xint參數強制虛擬機運行於只有解釋器的編譯模式下,這時 JIT 徹底不介入工做;也可使用參數-Xcomp強制虛擬機運行於只有 JIT 的編譯模式下。例如:

C:\Users\Administrator>java -Xint -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, interpreted mode)

C:\Users\Administrator>java -Xcomp -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, compiled mode)

觸發標準

在 HotSpot 虛擬機中,熱點探測是 JIT 的觸發標準。

熱點探測是基於計數器的熱點探測,採用這種方法的虛擬機會爲每一個方法創建計數器統計方法的執行次數,若是執行次數超過必定的閾值就認爲它是「熱點方法」 。

虛擬機爲每一個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。在肯定虛擬機運行參數的前提下,這兩個計數器都有一個肯定的閾值,當計數器超過閾值溢出了,就會觸發 JIT 編譯。

方法調用計數器

方法調用計數器用於統計方法被調用的次數,默認閾值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可經過-XX: CompileThreshold來設定;而在分層編譯的狀況下-XX: CompileThreshold指定的閾值將失效,此時將會根據當前待編譯的方法數以及編譯線程數來動態調整。當方法計數器和回邊計數器之和超過方法計數器閾值時,就會觸發 JIT 編譯器。

回邊計數器

回邊計數器用於統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲「回邊」(Back Edge),該值用於計算是否觸發 C1 編譯的閾值,在不開啓分層編譯的狀況下,C1 默認爲 13995,C2 默認爲 10700,可經過-XX: OnStackReplacePercentage=N來設置;而在分層編譯的狀況下,-XX: OnStackReplacePercentage指定的閾值一樣會失效,此時將根據當前待編譯的方法數以及編譯線程數來動態調整。

創建回邊計數器的主要目的是爲了觸發 OSR(On StackReplacement)編譯,即棧上編譯。在一些循環週期比較長的代碼段中,當循環達到回邊計數器閾值時,JVM 會認爲這段是熱點代碼,JIT 編譯器就會將這段代碼編譯成機器語言並緩存,在該循環時間段內,會直接將執行代碼替換,執行緩存的機器語言。

優化技術

JIT 編譯運用了一些經典的編譯優化技術來實現代碼的優化,即經過一些例行檢查優化,能夠智能地編譯出運行時的最優性能代碼。主要有兩種:方法內聯逃逸分析

方法內聯

調用一個方法一般要經歷壓棧和出棧。調用方法是將程序執行順序轉移到存儲該方法的內存地址,將方法的內容執行完後,再返回到執行該方法前的位置。

這種執行操做要求在執行前保護現場並記憶執行的地址,執行後要恢復現場,並按原來保存的地址繼續執行。 所以,方法調用會產生必定的時間和空間方面的開銷(其實能夠理解爲一種上下文切換的精簡版)。

那麼對於那些方法體代碼不是很大,又頻繁調用的方法來講,這個時間和空間的消耗會很大。

方法內聯的優化行爲就是把目標方法的代碼複製到發起調用的方法之中,避免發生真實的方法調用。

JVM 會自動識別熱點方法,並對它們使用方法內聯進行優化。咱們能夠經過-XX:CompileThreshold來設置熱點方法的閾值。但要強調一點,熱點方法不必定會被 JVM 作內聯優化,若是這個方法體太大了,JVM 將不執行內聯操做。而方法體的大小閾值,咱們也能夠經過參數設置來優化:

  1. 常常執行的方法,默認狀況下,方法體大小小於 325 字節的都會進行內聯,咱們能夠經過-XX:MaxFreqInlineSize=N來設置大小值;
  2. 不是常常執行的方法,默認狀況下,方法大小小於 35 字節纔會進行內聯,咱們也能夠經過-XX:MaxInlineSize=N來重置大小值。

以後咱們就能夠經過配置 JVM 參數來查看到方法被內聯的狀況:

// 在控制檯打印編譯過程信息
-XX:+PrintCompilation
// 解鎖對 JVM 進行診斷的選項參數。默認是關閉的,開啓後支持一些特定參數對 JVM 進行診斷
-XX:+UnlockDiagnosticVMOptions
// 將內聯方法打印出來
-XX:+PrintInlining

熱點方法的優化能夠有效提升系統性能,通常咱們能夠經過如下幾種方式來提升方法內聯:

  1. 經過設置 JVM 參數來減少熱點閾值或增長方法體閾值,以便更多的方法能夠進行內聯,但這種方法意味着須要佔用更多地內存;
  2. 在編程中,避免在一個方法中寫大量代碼,習慣使用小方法體;
  3. 儘可能使用 final、private、static 關鍵字修飾方法,編碼方法由於繼承,會須要額外的類型檢查。

此處就聯繫到了最開始提出的觀點,一個方法中的內容越少,當該方法常常被執行時,則容易進行方法內聯,從而優化性能。

逃逸分析

逃逸分析(Escape Analysis)是判斷一個對象是否被外部方法引用或外部線程訪問的分析技術,編譯器會根據逃逸分析的結果對代碼進行優化。

能夠經過JVM參數進行設置:

-XX:+DoEscapeAnalysis 開啓逃逸分析(jdk1.8 默認開啓)
-XX:-DoEscapeAnalysis 關閉逃逸分析

其具體優化方法主要有三種:棧上分配鎖消除標量替換

棧上分配

在 Java 中默認建立一個對象是在堆中分配內存的,而當堆內存中的對象再也不使用時,則須要經過垃圾回收機制回收,這個過程相對分配在棧中的對象的建立和銷燬來講,更消耗時間和性能。

這個時候,逃逸分析若是發現一個對象只在方法中使用,就會將對象分配在棧上。

可是,HotSpot 虛擬機目前的實現致使棧上分配實現比較複雜,能夠說,在 HotSpot 中暫時沒有實現這項優化,因此你們可能暫時沒法體會到這種優化(我看的資料顯示在 Java8 中尚未實現,若是你們有什麼其餘的發現,歡迎留言)。

鎖消除

若是是在單線程環境下,其實徹底沒有必要使用線程安全的容器,但就算使用了,由於不會有線程競爭,這個時候 JIT 編譯會對這個對象的方法鎖進行鎖消除。例如:

public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
        }

能夠經過JVM參數進行設置:

-XX:+EliminateLocks 開啓鎖消除(jdk1.8 默認開啓)
-XX:-EliminateLocks 關閉鎖消除

標量替換

逃逸分析證實一個對象不會被外部訪問,若是這個對象能夠被拆分的話,當程序真正執行的時候可能不建立這個對象,而直接建立它的成員變量來代替。將對象拆分後,能夠分配對象的成員變量在棧或寄存器上,本來的對象就無需分配內存空間了。這種編譯優化就叫作標量替換。

例如:

public void foo() {
        TestInfo info = new TestInfo();
        info.id = 1;
        info.count = 99;
        // to do something
    }

逃逸分析後,代碼會被優化爲:

public void foo() {
        id = 1;
        count = 99;
        // to do something
    }

能夠經過JVM參數進行設置:

-XX:+EliminateAllocations 開啓標量替換(jdk1.8 默認開啓)
-XX:-EliminateAllocations 關閉就能夠了

總結

今天的內容,由最基本的常識方法內部行數和邏輯須要儘量簡單引出,瞭解了 JVM 經過即時編譯對熱點代碼進行優化的過程。若是你有什麼想法,歡迎在下方留言。

有興趣的話能夠訪問個人博客或者關注個人公衆號、頭條號,說不定會有意外的驚喜。

https://death00.github.io/

相關文章
相關標籤/搜索