1. 程式人生 > 實用技巧 >JVM--堆是分配物件的唯一選擇麼?

JVM--堆是分配物件的唯一選擇麼?

在《深入理解Java虛擬機器》中關於Java堆記憶體有這樣一段描述:隨著JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的物件都分配到堆上也漸漸變得不那麼“絕對”了。
  在Java虛擬機器中,物件是在Java堆中分配記憶體的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經過逃逸分析(Escape Analysis)後發現,一個物件並沒有逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配記憶體,也無須進行垃圾回收了。這也是最常見的堆外儲存技術。
  此外,前面提到的基於OpenJDK深度定製的TaoBaoVM,其中創新的GCIH(GCinvisible heap)技術實現off-heap,將生命週期較長的Java物件從heap中移至heap外,並且GC不能管理GCIH內部的Java物件,以此達到降低GC的回收頻率和提升GC的回收效率的目的。

逃逸分析

  • 如何將堆上的物件分配到棧,需要使用逃逸分析手段。
  • 這是一種可以有效減少Java程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。
  • 通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。
  • 逃逸分析的基本行為就是分析物件動態作用域:
    • 當一個物件在方法中被定義後,物件只在方法內部使用,則認為沒有發生逃逸。
    • 當一個物件在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為呼叫引數傳遞到其他地方中。
  • 如何快速的判斷是否發生了逃逸分析,就看new的物件實體是否有可能在方法外被呼叫

程式碼分析1:

public void method(){
    V v = new V();
    //use V
    //......
    v = null;
}

沒有發生逃逸的物件,則可以分配到棧上:原因如下:

1.隨著方法執行的結束,棧空間就被移除。

2.虛擬機器棧空間是執行緒私有的,不會被共享。

程式碼分析2:

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

由於上述方法返回的sb在方法外被使用,發生了逃逸,上述程式碼如果想要StringBuffer sb不逃出方法,可以這樣寫:

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

程式碼分析3:

/**
 * 逃逸分析
 *
 *  如何快速的判斷是否發生了逃逸分析,就看“new的物件實體”是否有可能在方法外被呼叫。
 */
public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /*
    方法返回EscapeAnalysis物件,發生逃逸
     */
    public EscapeAnalysis getInstance(){
        return obj == null? new EscapeAnalysis() : obj;
    }
    /*
    為成員屬性賦值,發生逃逸
     */
    public void setObj(){
        this.obj = new EscapeAnalysis();
    }
    //思考:如果當前的obj引用宣告為static的?仍然會發生逃逸。

    /*
    物件的作用域僅在當前方法中有效,沒有發生逃逸
     */
    public void useEscapeAnalysis(){
        EscapeAnalysis e = new EscapeAnalysis();
    }
    /*
    引用成員變數的值,發生逃逸
     */
    public void useEscapeAnalysis1(){
        EscapeAnalysis e = getInstance();
        //getInstance().xxx()同樣會發生逃逸
    }
}

引數設定

  • 在JDK 6u23版本之後,HotSpot中預設就已經開啟了逃逸分析
  • 如果使用了較早的版本,開發人員可以通過
    • -XX:DoEscapeAnalysis 顯式開啟逃逸分析
    • -XX:+PrintEscapeAnalysis檢視逃逸分析的篩選結果

結論:開發中能使用區域性變數的,就不要使用在方法外定義

程式碼優化理論

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

  1. 棧上分配:將堆分配轉化為棧分配。如果一個物件在子執行緒中被分配,要使指向該物件的指標永遠不會逃逸,物件可能是棧分配的候選,而不是堆分配
  2. 同步省略:如果一個物件被發現只能從一個執行緒被訪問到,那麼對於這個物件的操作可以不考慮同步。
  3. 分離物件或標量替換:有的物件可能不需要作為一個連續的記憶體結構存在也可以北方問道,那麼物件的部分(或全部)可以不儲存在堆記憶體,而是儲存在CPU暫存器中。

棧上分配

  • JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個物件並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成之後,繼續在呼叫棧內執行,最後執行緒結束,棧空間被回收,區域性變數物件也被回收。這樣就無須機型垃圾回收了
  • 常見的發生逃逸的場景:給成員變數賦值、方法返回值、例項引用傳遞
程式碼分析:
以下程式碼,關閉逃逸分析(-XX:-DoEscapeAnalysi),維護10000000個物件, 如果開啟逃逸分析(-XX:+DoEscapeAnalysi),只維護少量物件(JDK7 逃逸分析預設開啟)
/**
 * 棧上分配測試
 * -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
 */
public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        // 檢視執行時間
        long end = System.currentTimeMillis();
        System.out.println("花費的時間為: " + (end - start) + " ms");
        // 為了方便檢視堆記憶體中物件個數,執行緒sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();//未發生逃逸
    }

    static class User {

    }
}
此外,若減小堆記憶體分配大小-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails,則不開啟逃逸分析的情況必然會發生GC。

同步省略

  • 執行緒同步的代價是相當高的,同步的後果是降低併發性和效能
  • 在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。這樣就能大大提高併發性和效能。這個取消同步的過程就叫同步省略,也叫 鎖消除
程式碼示例:
/**
 * 同步省略說明
 */
public class SynchronizedTest {
    public void f() {
        Object hollis = new Object();
        synchronized(hollis) {
            System.out.println(hollis);
        }
    }
    //程式碼中對hollis這個物件進行加鎖,但是hollis物件的生命週期只在f()方法中
    //並不會被其他執行緒所訪問控制,所以在JIT編譯階段就會被優化掉。
    //優化為 ↓
    public void f2() {
        Object hollis = new Object();
        System.out.println(hollis);
    }
}

注意:上述示例程式碼只是為了直觀的說明 同步省略這一程式碼優化手段。程式碼本身是存在問題的。如下

public class SynchronizedTest {
    public void f() {
        Object hollis = new Object();
        synchronized(hollis) {
            System.out.println(hollis);
        }
    }
}

同步程式碼需要加鎖,為不同執行緒的同步共享。但是要求所加的鎖是同一個鎖才有意義,上述同步程式碼塊每次的鎖物件hollos都是新new出來的,這並不符合加鎖的同步效果。可以說是一個錯誤的程式碼。但能說明問題!

分離物件或標量替換

  • 標量Scalar:是指一個無法在分解成更小的資料的資料。Java中的原始資料型別就是標量。
  • 相對的,那些還可以分解的資料叫做 聚合量(Aggregate),Java中物件就是聚合量,因為它可以分解成其他聚合量和標量。
  • 在JIT階段,如果經過逃逸分析,發現一個物件不會被外界訪問的話,那麼經過JIT優化,就會把這個物件拆解成若干個其中包含的若干個成員變數來替代。這個過程就是標量替換
程式碼示例1:
public class ScalarTest {
    public static void main(String[] args) {
        alloc();   
    }
    public static void alloc(){
        Point point = new Point(1,2);
    }
}
class Point{
    private int x;
    private int y;
    public Point(int x,int y){
        this.x = x;
        this.y = y;
    }
}

以上程式碼,經過標量替換後,就會變成

public static void alloc(){
    int x = 1;
    int y = 2;
}

可以看到,Point這個聚合量經過逃逸分析後,發現他並沒有逃逸,就被替換成兩個標量了。那麼標量替換有什麼好處呢?就是可以大大減少堆記憶體的佔用。因為一旦不需要建立物件了,那麼就不再需要分配堆記憶體了。
標量替換為棧上分配提供了很好的基礎。

配置引數:-XX:+EliminateAllocations,開啟標量替換。 示例程式碼2:
/**
 * 標量替換測試
 *  -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 */
public class ScalarReplace {
    public static class User {
        public int id;//標量(無法再分解成更小的資料)
        public String name;//聚合量(String還可以分解為char陣列)
    }

    public static void alloc() {
        User u = new User();//未發生逃逸
        u.id = 5;
        u.name = "www.atguigu.com";
    }

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花費的時間為: " + (end - start) + " ms");
    }
}

可以測試對比一下,如果開啟標量替換的效能提升,還是很巨大的。

逃逸分析小結

  • 關於逃逸分析的論文在1999年就已經發表了,但直到JDK1.6才有實現,而且這項技術到如今也並不是十分成熟的。
  • 其根本原因就是無法保證逃逸分析的效能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。
  • 一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
  • 雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。
  • 注意到有一些觀點,認為通過逃逸分析,JVM會在棧上分配那些不會逃逸的物件,這在理論上是可行的,但是取決於JVM設計者的選擇。據我所知,Oracle HotspotJVM中並未這麼做,這一點在逃逸分析相關的文件裡已經說明,所以可以明確所有的物件例項都是建立在堆上。迴歸本篇的提問:堆是分配物件的唯一選擇麼?一開始我們是否定的態度,現在我們又去肯定這一觀點。這豈不是自相矛盾。。正所謂否定之否定的觀點,通過這篇文章,我們還是有進步的,至少分離物件或標量替換這一點還是存在的,優化了程式碼執行的效率,減少了堆空間的佔用,體現了棧上分配的思想。
  • 目前很多書籍還是基於JDK7以前的版本,JDK已經發生了很大變化,intern字串的快取和靜態變數曾經都被分配在永久代上,而永久代已經被元資料區取代。但是,intern字串快取和靜態變數並不是被轉移到元資料區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:物件例項都是分配在堆上。
  • 年輕代是物件的誕生、省長、消亡的區域,一個物件在這裡產生、應用、最後被垃圾回收器收集、結束生命
  • 老年代防止長生命週期物件,通常都是從Survivor區域篩選拷貝過來的Java物件。當然,也有特殊情況,我們知道普通的物件會被分配在TLAB上,如果物件較大,JVM會試圖直接分配在Eden其他位置上;如果物件他打,完全無法在新生代找到足夠長的連續空閒空間,JVM就會直接分配到老年代
  • 當GC只發生在年輕代中,回收年輕物件的行為被稱為MinorGC。當GC發生在老年代時則被稱為MajorGC或者FullGC。一般的,MinorGC的發生頻率要比MajorGC高很多,即老年代中垃圾回收發生的頻率大大低於年輕代