JVM學習(二) JIT即時編譯器

JVM程序執行流程

瞭解JIT我們首先要先知道程序執行的流程

執行流程圖

Java編譯成字節碼、動態編譯和解釋爲機器碼的過程分析:
在這裏插入圖片描述

編譯器和解釋器的協調工作流程

在這裏插入圖片描述

什麼是JIT

在部分商用虛擬機中(如HotSpot),Java程序最初是通過解釋器(Interpreter)進行逐行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁時,就會把這些代碼認定爲“熱點代碼”。爲了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱爲即時編譯器(Just In Time Compiler,下文統稱JIT編譯器)。

熱點代碼

程序中的代碼只有是熱點代碼時,纔會編譯爲本地代碼,那麼什麼是熱點代碼呢?
運行過程中會被即時編譯器編譯的“熱點代碼”有兩類:

  1. 被多次調用的方法。
  2. 被多次執行的循環體。

兩種情況,編譯器都是以整個方法作爲編譯對象。 這種編譯方法因爲編譯發生在方法執行過程之中,因此形象的稱之爲棧上替換(On Stack Replacement,OSR),即方法棧幀還在棧上,方法就被替換了。

熱點檢測方式

要知道方法或一段代碼是不是熱點代碼,是不是需要觸發即時編譯,需要進行Hot SpotDetection(熱點探測)。
目前主要的熱點探測方式有以下兩種:

  1. 基於採樣的熱點探測
    採用這種方法的虛擬機會週期性地檢查各個線程的棧頂,如果發現某些方法經常出現在棧頂,那這個方法就是“熱點方法”。這種探測方法的好處是實現簡單高效,還可以很容易地獲取方法調用關係(將調用堆棧展開即可),缺點是很難精確地確認一個方法的熱度,容易因爲受到線程阻塞或別的外界因素的影響而擾亂熱點探測。
  2. 基於計數器的熱點探測
    採用這種方法的虛擬機會爲每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閥值,就認爲它是“熱點方法”。這種統計方法實現複雜一些,需要爲每個方法建立並維護計數器,而且不能直接獲取到方法的調用關係,但是它的統計結果相對更加精確嚴謹。

在HotSpot虛擬機中使用的是第二種——基於計數器的熱點探測方法,因此它爲每個方法準備了兩個計數器:方法調用計數器和回邊計數器。在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發JIT編譯

方法調用計數器

顧名思義,這個計數器用於統計方法被調用的次數。
在JVM client模式下的閥值是1500次,Server是10 000次。可以通過虛擬機參數: -XX:CompileThreshold設置。但是JVM還存在熱度衰減,時間段內調用方法的次數較少,計數器就減小。
在這裏插入圖片描述

回邊計數器

它的作用就是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲“回邊”。

JIT使用

解釋器與編譯器並存的架構

出於對編譯的時間開銷與空間開銷,JVM用解釋器+JIT編譯器的混合執行引擎。
針對熱點代碼採用編譯器編譯執行,對於只執行一次的代碼採用解釋器解釋執行。

編譯的時間開銷

解釋器的執行,抽象的看是這樣的:
輸入的代碼 -> [ 解釋器 解釋執行 ] -> 執行結果
而要JIT編譯然後再執行的話,抽象的看則是:
輸入的代碼 -> [ 編譯器 編譯 ] -> 編譯後的代碼 -> [ 執行 ] -> 執行結果
說JIT比解釋快,其實說的是“執行編譯後的代碼”比“解釋器解釋執行”要快,並不是說“編譯”這個動作比“解釋”這個動作快。JIT編譯再怎麼快,至少也比解釋執行一次略慢一些,而要得到最後的執行結果還得再經過一個“執行編譯後的代碼”的過程。所以,對“只執行一次”的代碼而言,解釋執行其實總是比JIT編譯執行要快。
怎麼算是“只執行一次的代碼”呢?粗略說,下面兩個條件同時滿足時就是嚴格的“只執行一次”
1、只被調用一次,例如類的構造器(class initializer,())
2、沒有循環
對只執行一次的代碼做JIT編譯再執行,可以說是得不償失。對只執行少量次數的代碼,JIT編譯帶來的執行速度的提升也未必能抵消掉最初編譯帶來的開銷。有對頻繁執行的代碼,JIT編譯才能保證有正面的收益。

編譯的空間開銷

對一般的Java方法而言,編譯後代碼的大小相對於字節碼的大小,膨脹比達到10x是很正常的。同上面說的時間開銷一樣,這裏的空間開銷也是,只有對執行頻繁的代碼才值得編譯,如果把所有代碼都編譯則會顯著增加代碼所佔空間,導致“代碼爆炸”。

這也就解釋了爲什麼有些JVM會選擇不總是做JIT編譯,而是選擇用解釋器+JIT編譯器的混合執行引擎。

JIT的兩種實現

HotSpot實現了兩種JIT編譯器:Client Complier和Server Complier,簡稱爲C1、C2編譯器,分別用在客戶端和服務端。

目前主流的HotSpot虛擬機中默認是採用解釋器與其中一個編譯器直接配合的方式工作。程序使用哪個編譯器,取決於虛擬機運行的模式。HotSpot虛擬機會根據自身版本與宿主機器的硬件性能自動選擇運行模式,用戶也可以使用“-client”或“-server”參數去強制指定虛擬機運行在Client模式或Server模式。

用Client Complier獲取更高的編譯速度,用Server Complier 來獲取更好的編譯質量。爲什麼提供多個即時編譯器與爲什麼提供多個垃圾收集器類似,都是爲了適應不同的應用場景。

編譯過程

Server Compiler和Client Compiler兩個編譯器的編譯過程是不一樣的。
對Client Compiler來說,它是一個簡單快速的編譯器,主要關注點在於局部優化,而放棄許多耗時較長的全局優化手段。
而Server Compiler則是專門面向服務器端的,併爲服務端的性能配置特別調整過的編譯器,是一個充分優化過的高級編譯器。

JIT優化

公共子表達式的消除

公共子表達式消除是一個普遍應用於各種編譯器的經典優化技術,他的含義是:如果一個表達式E已經計算過了,並且從先前的計算到現在E中所有變量的值都沒有發生變化,那麼E的這次出現就成爲了公共子表達式。對於這種表達式,沒有必要花時間再對他進行計算,只需要直接用前面計算過的表達式結果代替E就可以了。

  • 如果這種優化僅限於程序的基本塊內,便稱爲局部公共子表達式消除(Local Common Subexpression Elimination)
  • 如果這種優化範圍涵蓋了多個基本塊,那就稱爲全局公共子表達式消除(Global Common Subexpression Elimination)

舉個簡單的例子來說明他的優化過程,假設存在如下代碼:

int d = (c*b)*12+a+(a+b*c);

如果這段代碼交給Javac編譯器則不會進行任何優化,那生成的代碼如下所示,是完全遵照Java源碼的寫法直譯而成的。

在這裏插入圖片描述

當這段代碼進入到虛擬機即時編譯器後,他將進行如下優化:
編譯器檢測到”cb“與”bc“是一樣的表達式,而且在計算期間b與c的值是不變的。因此,這條表達式就可能被視爲:

int d = E*12+a+(a+E);

這時,編譯器還可能(取決於哪種虛擬機的編譯器以及具體的上下文而定)進行另外一種優化:代數化簡(Algebraic Simplification),把表達式變爲:

int d = E*13+a*2;

表達式進行變換之後,再計算起來就可以節省一些時間了。

方法內聯

在使用JIT進行即時編譯時,將方法調用直接使用方法體中的代碼進行替換,這就是方法內聯,減少了方法調用過程中壓棧與入棧的開銷。同時爲之後的一些優化手段提供條件。如果JVM監測到一些小方法被頻繁的執行,它會把方法的調用替換成方法體本身。
比如說下面這個:

private int add4(int x1, int x2, int x3, int x4) {
    return add2(x1, x2) + add2(x3, x4); 
}
private int add2(int x1, int x2) {
    return x1 + x2;
}

可以肯定的是運行一段時間後JVM會把add2方法去掉,並把你的代碼翻譯成:

private int add4(int x1, int x2, int x3, int x4) {
    return x1 + x2 + x3 + x4;
}

逃逸分析

逃逸分析(Escape Analysis)是目前Java虛擬機中比較前沿的優化技術。這是一種可以有效減少Java程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用範圍從而決定是否要將這個對象分配到堆上。 逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所 引用,例如作爲調用參數傳遞到其他地方中,稱爲方法逃逸。
逃逸分析包括:

  • 全局變量賦值逃逸
  • 方法返回值逃逸
  • 實例引用發生逃逸
  • 線程逃逸:賦值給類變量或可以在其他線程中訪問的實例變量
public class EscapeAnalysis {
    //全局變量
    public static Object object;

    public void globalVariableEscape() {
        //全局變量賦值逃逸
        object = new Object();
    }

    public Object methodEscape(){ 
        //方法返回值逃逸
        return new Object(); 
    }
    
    public void instancePassEscape(){ 
        //實例引用發生逃逸
        this.speak(this); 
    }
    public void speak(EscapeAnalysis escapeAnalysis){ 
        System.out.println("Escape Hello"); 
    }
}

使用逃逸分析,編譯器可以對代碼做如下優化:

  1. 同步省略。如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可以不考慮同 步。
  2. 將堆分配轉化爲棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對 象可能是棧分配的候選,而不是堆分配。
  3. 分離對象或標量替換。有的對象可能不需要作爲一個連續的內存結構存在也可以被訪問到,那麼對象 的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。

在Java代碼運行時,通過JVM參數可指定是否開啓逃逸分析,從jdk 1.7開始已經默認開始逃逸分析,如需關閉,需要指定 -XX:-DoEscapeAnalysis

對象的棧上內存分配

我們知道,在一般情況下,對象和數組元素的內存分配是在堆內存上進行的。但是隨着JIT編譯器的日漸成熟,很多優化使這種分配策略並不絕對。JIT編譯器就可以在編譯期間根據逃逸分析的結果,來決定是否可以將對象的內存分配從堆轉化爲棧。

public class EscapeAnalysisTest {
    public static void main(String[] args) {
        long a1 = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            alloc();
        }
        // 查看執行時間
        long a2 = System.currentTimeMillis();
        System.out.println("cost " + (a2 - a1) + " ms");
        // 爲了方便查看堆內存中對象個數,線程sleep
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();
    }

    static class User {
    }
}

其實代碼內容很簡單,就是使用for循環,在代碼中創建100萬個User對象。
我們在alloc方法中定義了User對象,但是並沒有在方法外部引用他。也就是說,這個對象並 不會逃逸到alloc外部。經過JIT的逃逸分析之後,就可以對其內存分配進行優化。
我們指定以下JVM參數並運行:

-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails - XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 後,代碼運行結束之前,我們使用jmap命令,來查看下當前堆內存中有多
少個User對象:
在這裏插入圖片描述
從上面的jmap執行結果中我們可以看到,堆中共創建了100萬個 StackAllocTest$User 實例。
在關閉逃避分析的情況下(-XX:-DoEscapeAnalysis),雖然在alloc方法中創建的User對象並沒有逃逸到方法外部,但是還是被分配在堆內存中。也就說,如果沒有JIT編譯器優化,沒有逃逸分析技術,正常情況下就應該是這樣的。即所有對象都分配到堆內存中。

接下來,我們開啓逃逸分析,再來執行下以上代碼。

-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails - XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 後,代碼運行結束之前,我們使用 jmap 命令,來查看下當前堆內存中有多少個User對象
在這裏插入圖片描述
從以上打印結果中可以發現,開啓了逃逸分析之後(-XX:+DoEscapeAnalysis),在堆內存中只有8萬多個StackAllocTest$User 對象。也就是說在經過JIT優化之後,堆內存中分配的對象數量,從100萬降到了8萬。
除了以上通過jmap驗證對象個數的方法以外,還可以嘗試將堆內存調小,然後執行以上代碼,根據GC的次數來分析,也能發現,開啓了逃逸分析之後,在運行期間,GC次數會明顯減少。正是因爲很多堆上分配被優化成了棧上分配,所以GC次數有了明顯的減少。

總結

隨着JIT編譯器的發展,在編譯期間,如果JIT經過逃逸分析,發現有些對象沒有逃逸出方法,那麼有可能堆內存分配會被優化成棧內存分配。但是這也並不是絕對的。就像我們前面看到的一樣,在開啓逃逸分析之後,也並不是所有User對象都沒有在堆上分配。

標量替換

標量(Scalar)是指一個無法再分解成更小的數據的數據 。
在JIT階段,如果經過逃逸分析,發現一個對象不會被外界訪問的話,那麼經過JIT優化,就會把這個對象拆解成若干個其中包含的若干個成員變量來代替。
在這裏插入圖片描述

同步鎖消除

同樣基於逃逸分析,當加鎖的變量不會發生逃逸,是線程私有的完全沒有必要加鎖。 在JIT編譯時期就可以將同步鎖去掉,以減少加鎖與解鎖造成的資源開銷。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章