JVM - 內功修煉之JIT技術和逃逸分析

JVM - 內功修煉之JIT技術和逃逸分析

 大家會發現不管是從我之前的文章還是從其他各種書籍又或者是各類JVM文章中,在最最最開始都會有幾個概念烙在我們的腦海裏。

  1. 堆是線程共享的內存區域,而棧是線程私有的內存區域。
  2. 堆主要用於存放對象實例,而棧中主要存放基本數據類型以及複雜類型引用。

 那麼如果我現在告訴你,這些結論並不是百分百正確的,你是否會想順着網線來打我?好了,請先忍一忍,我們往下看完大家再決定是否要動手好了。

 我們前面的文章也講過一個Java對象在堆上進行分配時主要是會分配在新生代的Eden區域,而有些時候又會在TLAB區域,當包含大對象時也可能會直接在老年代上分配。這其中的分配規則是不固定的,既取決於何種垃圾收集器也可能和JVM的一些參數有關。不過一般情況和我們之前提到的其實大體相同。

 然而我們也知道,我們所瞭解的很多虛擬機規範其實對於各個廠商雖大致相同但也有一些差異,比如在內存分配這件事上就會有不同的優化策略。相信就算大家在還沒有打算去深入瞭解JVM之前,也會不經意聽到JIT這一概念,而正是因爲HotSpot虛擬機的JIT技術,使得對象在堆上分配內存帶有不確定性,所以你別打我,去打他。

1.即時編譯JIT(Just in Time)

 我們大家所瞭解的傳統JVM解析器執行Java程序是先通過javac對其進行源碼編譯然後轉爲字節碼文件,然後再通過解釋字節碼轉爲機器指令一條條讀取翻譯的。顯而易見Java編譯器經過編譯再執行的話,執行速度必然比直接執行要慢很多,而HotSpot虛擬機針對這種場景進行了優化,引進了JIT即時編譯技術。

JIT技術的引入不會影響原本JVM編譯執行,只是當發現某個方法或者代碼塊運行特別頻繁時會將其標記爲熱點代碼。然後會將其直接編譯爲本地機器相關的機器碼並優化,最後將這部分代碼緩存起來。

1.1 熱點代碼

 提了這麼多,那什麼纔是熱點代碼呢?當虛擬機發現某個方法或者代碼塊執行十分頻繁的時候,就會將其標記爲熱點代碼。在我們平時開發中,熱點代碼主要有:被多次調用的方法被多次執行的循環體

 那麼到底被調用多少次才屬於熱點代碼呢?這是怎樣的一個評判標準我們接下來就會提到。

1.2 熱點探測

 我們已經介紹了,要觸發JIT即時編譯需要先識別出熱點代碼,而這一過程就稱爲熱點探測。目前主要的熱點探測方式有兩種:

  1. 基於計數器的熱點探測:虛擬機爲每個方法或是代碼塊建立一個計數器,統計執行的次數,若此處超過規定閾值則標記爲熱點代碼

     優點:統計結果精準嚴謹。
     缺點:實現較爲複雜,並且需要爲每個方法或是代碼塊都建立並維護計數器,無法直接獲取方法調用關係。

  2. 基於採樣的熱點探測:虛擬機週期性檢查各個線程棧頂,若某個方法出現在棧頂頻率較高,則標記爲熱點代碼

     優點:實現簡單高效,可展開堆棧獲取方法調用關係
     缺點:缺乏精準度,線程阻塞或其他因素可能會擾亂熱點探測。

 我們所使用的HotSpot虛擬機中主要就是採用基於計數器的熱點探測。

1.3 編譯優化

 當熱點探測識別出熱點代碼後會觸發JIT,除了會對字節碼進行緩存外還會對代碼進行各種優化。而這些優化中比較重要的幾個想必大家也聽過不少:逃逸分析鎖消除鎖膨脹方法內聯空值檢查消除類型檢查消除等。而我們接下來要講就是本文的重點-逃逸分析

 另外關於JIT即時編譯如果大家有興趣深入推薦一篇文章【你瞭解JVM中的 JIT 即時編譯及優化技術嗎?】,有興趣的小夥伴可以看看。

2.逃逸分析(Escape Analysis)

 做了一系列鋪墊終於到了今天的主角。逃逸分析(Escape Analysis)是目前JVM中一項比較重要的優化技術。通過逃逸分析,HotSpot編譯器能夠分析出一個對象的使用範圍從而考慮是否將其分配在堆內存中。

 逃逸分析的核心思想就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,稱爲方法逃逸。甚至某些情況還需要被外部線程訪問,稱爲線程逃逸。舉個栗子。

package com.ithzk.springbootjvm.memoryallocation;

/**
 * @ Description   :  逃逸分析和棧上分配 -XX:+PrintGCDetails -XX:+DoEscapeAnalysis
 * @ Author        :  zekunhu
 * @ CreateDate    :  2020/4/11 15:06
 * @ UpdateUser    :  zekunhu
 * @ UpdateDate    :  2020/4/11 15:06
 * @ UpdateRemark  :
 * @ Version       :  1.0
 */
public class EscapeAnalysis {

    public void escape1(){
        Object ojb = new Object();
    }

    /**
     * 方法逃逸
     * @return
     */
    public Object escape2(){
        return new Object();
    }

}

 這裏我給出了兩個方法,escape1()內部實現只構建了一個對象沒有被外部引用,這種情況就屬於沒有逃逸出方法;escape2()同樣也構建了一個對象但是會將引用返回給調用,此時構建的對象是可以被外部訪問的,這種就稱之爲方法逃逸。

 如果我們能夠確定一個變量不會逃逸到方法或者線程外,則是有可能對其進行一些優化的:同步省略標量替換棧上分配。這裏同步省略會在後面多線程分析文章中介紹,這裏主要介紹另外兩種。

3.棧上分配(Stack Allocation)

 衆所周知,在JVM中對象的創建都是在堆上分配的,因爲堆內存上訪問是線程共享的,所有線程只要有該對象的引用就能夠訪問到堆中存儲的對象數據。而JIT經過逃逸分析後,如果確定某個對象不會逃逸到方法之外,那麼還有必要讓其在堆上分配嗎?如果能夠改變Java對象都在堆上分配的原則將其分配到棧上那會發生什麼呢?

 小夥伴們都知道垃圾回收不管是標記還是清除又或者是整理都需要耗費時間,若我們能夠改變Java對象都在堆上分配的原則能夠將沒有逃逸出方法外的對象分配在棧上,其所佔的空間就會隨着棧幀出棧(方法執行完成)而自動銷燬,可以大幅度減少垃圾收集器的壓力從而提高系統性能。

 在HotSpot JVM中,棧上分配其實並沒有真正意義上實現,但正因爲有了這種設計思想,纔有了接下來我們要介紹的標量替換,下面我們就來看看標量替換是如何實現棧上分配的。

4.標量替換

 這裏的標量(Scalar)和我們在數學中所瞭解的標量有所區別,在這裏標量指的是一個無法再分解成更小的數據。在Java中原始數據類型如int、long等都屬於標量。而其他可以繼續分解的數據都稱爲聚合量(Aggregate),最典型的就是對象。

 如果經過逃逸分析確定一個對象不會被外部訪問從而觸發JIT優化,就會嘗試將該對象進行拆解爲若干個其中包含的成員變量來代替,在執行時就不會再去直接創建這個對象了,這個過程就是標量替換。這裏我們用實際示例來描述一下讓大家更容易理解並且印象更深刻。

package com.ithzk.springbootjvm.memoryallocation;

public class EscapeAnalysis {

    public static Escape allocation(){
        Escape escape = new Escape(3,29);
        System.out.println("Escape{variable1='" + escape.variable1 + ", variable2='" + escape.variable2 + "}");
    }

    public static void main(String[] args) {
        allocation();
    }

    static class Escape{

        private int variable1;
        private int variable2;

        public Escape(int variable1, int variable2) {
            this.variable1 = variable1;
            this.variable2 = variable2;
        }
    }

}

 上面這段代碼我們可以看出,allocation()中我們構建了一個Escape對象,並且該對象沒有逃逸出方法外。那麼經過JIT優化後並不會直接去創建這個對象,而是使用兩個標量代替。

package com.ithzk.springbootjvm.memoryallocation;

public class EscapeAnalysis {

    public static Escape allocation(){
        int variable1 = 3;
        int variable2 = 29;
        System.out.println("Escape{variable1='" + variable1 + ", variable2='" + variable2 + "}");
    }

    public static void main(String[] args) {
        allocation();
    }

}

 我們可以看到,通過標量替換的優化,原本一個對象被替換成了兩個標量。原本需要再堆上分配內存現在也只要在棧中進行內存分配就可以實現功能了。

5.實踐是檢驗真理的唯一標準

 上面介紹了這麼多,那麼逃逸分析對於我們編寫的代碼是不是真的會實行並且有效呢?這裏我們就通過幾個例子帶大家更深入接觸逃逸分析。

package com.ithzk.springbootjvm.memoryallocation;

public class EscapeAnalysis {

    public static void allocation(){
        Escape escape = new Escape("a", "b");
    }

    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        for(int i = 0;i < 10000000; i++){
            allocation();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time:" + (endTime - startTime));

    }

    static class Escape{

        private String variable1;
        private String variable2;

        public Escape(String variable1, String variable2) {
            this.variable1 = variable1;
            this.variable2 = variable2;
        }

    }

}

 這段代碼讀起來應該很容易理解,就是利用循環懟了1000萬個Escape對象。並且從這個示例中我們可以看出我們定義的Escape對象並沒有逃逸出allocation()方法,我們來看看上面那些理論知識所展現真實的情況到底是怎樣的。

 這裏我們添加JVM參數-XX:+PrintGCDetails -XX:-DoEscapeAnalysis,可以追蹤到詳細的GC日誌並且這裏我們將逃逸分析關閉了,運行看看效果。
在這裏插入圖片描述
 首先我們可以看到的是這裏觸發了幾次GC,然後整個過程的運行時間也清楚記錄了下來,我們再將逃逸分析打開-XX:+DoEscapeAnalysis
在這裏插入圖片描述
 效果簡直不要太明顯,首先頻繁GC沒有出現了,並且整個過程的執行時間以目前示例來看有十倍之差,大家是不是感覺逃逸分析優化的效果十分明顯了。另外我目前示例使用的版本jdk1.8,該版本默認是開啓逃逸分析的,大家可以刪除這個JVM參數動手驗證下自己使用版本的情況。

 還是剛纔這個例子,我們稍微修改一下。

package com.ithzk.springbootjvm.memoryallocation;

public class EscapeAnalysis2 {

    public static void allocation(){
        Escape escape = new Escape("a", "b");
    }

    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        for(int i = 0;i < 1000000; i++){
            allocation();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time:" + (endTime - startTime));
        try {
            Thread.sleep(600000);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    static class Escape{

        private String variable1;
        private String variable2;

        public Escape(String variable1, String variable2) {
            this.variable1 = variable1;
            this.variable2 = variable2;
        }
    }

}

 這裏我們創建100萬個對象,讓線程睡眠可以方便我們查看堆棧信息,這裏我們先關閉逃逸分析-XX:-DoEscapeAnalysis。我們通過Jps找到對應的進程pid。
在這裏插入圖片描述
 這裏我們通過jmap -histo:live 2772>jmap_histo.log將堆中對象情況給輸出到日誌中。不熟悉jmap的可以看看【java命令–jmap命令使用】這篇博客,後面我們也會介紹JVM常用的一些命令。
在這裏插入圖片描述
 通過jamp結果我們可以清楚看到,堆裏創建了100萬個Escape對象,這裏雖然沒有逃逸出方法但是我們將逃逸分析關閉後所有對象依然是會被分配在堆中。我們開啓逃逸分析-XX:+DoEscapeAnalysis再來一遍看看。
在這裏插入圖片描述
 當我們開啓逃逸分析後,堆中只分配了15萬左右的Escape對象,效果還是十分明顯的。並且開啓逃逸分析後GC次數也明顯減少了。這種方式讓我們更貼切感受到了逃逸分析帶來的好處。

6.完美的逃逸分析?

 我們上面通過幾個栗子來親身感受了一下逃逸分析給我們帶來的效果,我們會發現當我們開啓逃逸分析後並不是直接就將所有沒有逃逸出方法的對象都進行了優化,上面我們100萬的數量最終優化到了15萬左右,也就是說JIT的優化策略並不是簡單的根據是否逃逸出方法來決定的。

 當我們去翻閱各種書籍和資料,上面會介紹說逃逸分析相關的資料在1999年就已經發表了,但是JDK1.6才實現推出。直到我們使用的JDK1.8來說也沒有資料說逃逸分析這項技術已經成熟。

 最主要的原因就是因爲逃逸分析整個過程需要經過一系列複雜的分析才能確定該對象是否真正符合條件,也就是說無法保證逃逸分析的性能消耗一定會大於優化所帶來的的收益。並且逃逸分析除了對符合條件對象的檢測外,還要進行標量替換、棧上分配、同步消除等優化,這其中花費的時間由於數據量的不確定性而無法確定。

 就用我們上面的例子來說,假如我們去創建了1000萬個對象,然後經過逃逸分析後發現沒有一個對象符合優化條件,那麼這整個逃逸分析的過程就是完全浪費的,對系統整個運行時會產生一定性能消耗的。不過我們親身感受了這項技術,它的強大和帶給我們真實的衝擊對整個編譯器發展都是有着巨大貢獻的。

 所以即使這項技術在當下並不是十分完美,但是其整個設計思想和發展軌跡都是值得我們去學習的,並且隨着不斷地發展其地位也一定十分重要。

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