天天看點

JVM之深入了解堆空間(二)

6. Minor GC,MajorGC、Full GC

我們都知道,JVM的調優的一個環節,也就是垃圾收集,我們需要盡量的避免垃圾回收,因為在垃圾回收的過程中,容易出現STW的問題。而 Major GC 和 Full GC出現STW的時間,是Minor GC的10倍以上

JVM在進行GC時,并非每次都對上面三個記憶體區域一起回收的,大部分時候回收的都是指新生代。

針對Hotspot VM的實作,它裡面的GC按照回收區域又分為兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(FullGC)

  1. 部分收集:不是完整收集整個Java堆的垃圾收集。其中又分為:
    • 新生代收集(MinorGC/YoungGC):隻是新生代的垃圾收集
    • 老年代收集(MajorGC/OldGC):隻是老年代的圾收集。
      • 目前,隻有CMSGC會有單獨收集老年代的行為。
      • 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。
      • 混合收集(MixedGC):收集整個新生代以及部分老年代的垃圾收集。目前,隻有G1 GC會有這種行為。
  2. 整堆收集(FullGC):收集整個java堆和方法區的垃圾收集。

6.1 Minor GC

  1. 當年輕代空間不足時,就會觸發Minor GC,這裡的年輕代滿指的是Eden代滿,

    Survivor滿不會引發GC

    。(每次Minor GC會清理年輕代的記憶體。)
  2. 因為Java對象大多都具備 

    朝生夕滅

     的特性,是以Minor GC非常頻繁,一般回收速度也比較快。這一定義既清晰又易于了解。
  3. Minor GC會引發STW(stop the word),暫停其它使用者的線程,等垃圾回收結束,使用者線程才恢複運作。
JVM之深入了解堆空間(二)

6.2 Major GC

  1. 指發生在老年代的GC,對象從老年代消失時,我們說 “Major GC” 或 “Full GC” 發生了。
  2. 出現了Major GC,經常會伴随至少一次的Minor GC(但非絕對的,在Paralle1 Scavenge收集器的收集政策裡就有直接進行Major GC的政策選擇過程)。

    • 也就是在老年代空間不足時,會先嘗試觸發Minor GC。如果之後空間還不足,則觸發Major GC。
  3. Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長。

  4. 如果Major GC後,記憶體還不足,就報OOM了。

6.3 Full GC

觸發Full GC執行的情況有如下五種:

  1. 調用System.gc()時,系統建議執行Full GC,但是不必然執行。
  2. 老年代空間不足。
  3. 方法區空間不足。
  4. 通過Minor GC後進入老年代的平均大小大于老年代的可用記憶體。
  5. 由Eden區、survivor space0(From Space)區向survivor space1(To Space)區複制時,對象大小大于To Space可用記憶體,則把該對象轉存到老年代,且老年代的可用記憶體小于該對象大小。

說明:

Full GC 是開發或調優中盡量要避免的。這樣暫時時間會短一些

6.4 GC 舉例

我們編寫一個OOM的異常,因為我們在不斷的建立字元串,是存放在元空間的。
/**
 * GC測試MinorGC 、 MajorGC、FullGC
 * -Xms9m -Xmx9m -XX:+PrintGCDetails
 */
public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "atguigu.com";
            while (true) {
                list.add(a);
                a = a + a;
                i++;
            }

        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println("周遊次數為:" + i);
        }
    }
}
           
設定JVM啟動參數
-Xms9m -Xmx9m -XX:+PrintGCDetails
           
JVM之深入了解堆空間(二)
觸發OOM的時候,一定是進行了一次Full GC,因為隻有在老年代空間不足時候,才會爆出OOM異常。

7. 堆空間分代思想

為什麼要把Java堆分代?不分代就不能正常工作了嗎?

經研究,不同對象的生命周期不同。70%-99%的對象是臨時對象。
  1. 新生代:有Eden、兩塊大小相同的survivor(又稱為from/to,s0/s1)構成,to總為空。
  2. 老年代:存放新生代中經曆多次GC仍然存活的對象。
JVM之深入了解堆空間(二)

為什麼需要把Java堆分代?不分代就不能正常工作了嗎?

其實不分代完全可以,

分代的唯一理由就是優化GC性能

。如果沒有分代,那所有的對象都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用,這樣就會對堆的所有區域進行掃描。而很多對象都是朝生夕死的,如果分代的話,把新建立的對象放到某一地方,當GC的時候先把這塊存儲“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。
JVM之深入了解堆空間(二)

8. 記憶體配置設定政策(對象的提升政策)

  1. 如果對象在Eden出生并經過第一次Minor GC後仍然存活,并且能被 Survivor 容納的話,将被移動到survivor空間中,并将對象年齡設為1。對象在survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代中。
  2. 對象晉升老年代的年齡閥值,可以通過選項-xx:MaxTenuringThreshold來設定。

  3. 針對不同年齡段的對象配置設定原則如下所示:
    • 優先配置設定到Eden
    • 大對象直接配置設定到老年代
      • 開發中比較長的字元串或者數組,會直接存在老年代,但是因為新建立的對象 都是 朝生夕死的,是以這個大對象可能也很快被回收,但是因為老年代觸發Major GC的次數比 Minor GC要更少,是以可能回收起來就會比較慢。
      • 盡量避免程式中出現過多的大對象!

    • 長期存活的對象配置設定到老年代
    • 動态對象年齡判斷
      • 如果survivor區中相同年齡的所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象可以直接進入老年代,無須等到MaxTenuringThreshold 中要求的年齡。

    • 空間配置設定擔保: -Xx:HandlePromotionFailure

      • 也就是經過Minor GC後,所有的對象都存活,因為Survivor比較小,是以就需要将Survivor無法容納的對象,存放到老年代中。

9. 為對象配置設定記憶體—TLAB

9.1 堆空間都是共享的麼?

不一定,因為還有TLAB這個概念,在堆中劃分出一塊區域,為每個線程所獨占。

9.2 為什麼有TLAB?

  1. TLAB:Thread Local Allocation Buffer

    ,也就是為每個線程單獨配置設定了一個緩沖區。
  2. 堆區是線程共享區域,任何線程都可以通路到堆區中的共享資料。

  3. 由于對象執行個體的建立在JVM中非常頻繁,是以在并發環境下從堆區中劃分記憶體空間是線程不安全的。
  4. 為避免多個線程操作同一位址,需要使用加鎖等機制,進而影響配置設定速度。

9.3 什麼是TLAB

  1. 從記憶體模型而不是垃圾收集的角度,對Eden區域繼續進行劃分,

    JVM為每個線程配置設定了一個私有緩存區域,它包含在Eden空間内。

  2. 多線程同時配置設定記憶體時,使用TLAB可以避免一系列的非線程安全問題,同時還能夠提升記憶體配置設定的吞吐量,是以我們可以将這種記憶體配置設定方式稱之為

    快速配置設定政策

  3. 據宋老師(教我JVM的人物)所知所有OpenJDK衍生出來的JVM都提供了TLAB的設計。
  4. 盡管不是所有的對象執行個體都能夠在TLAB中成功配置設定記憶體,但JVM确實是将TLAB作為記憶體配置設定的首選。
  5. 在程式中,開發人員可以通過選項“-Xx:UseTLAB”設定是否開啟TLAB空間,預設是開啟的。
  6. 預設情況下,TLAB空間的記憶體非常小,

    僅占有整個Eden空間的1%

    ,當然我們可以

    通過選項“-XX:TLABWasteTargetPercent”設定TLAB空間所占用Eden空間的百分比大小

  7. 一旦對象在TLAB空間配置設定記憶體失敗時,JVM就會嘗試着通過使用加鎖機制確定資料操作的原子性,進而直接在Eden空間中配置設定記憶體。
JVM之深入了解堆空間(二)

9.4 TLAB配置設定過程

對象首先是通過TLAB開辟空間,如果不能放入,那麼需要通過Eden來進行配置設定
JVM之深入了解堆空間(二)

10. 小結–堆空間的參數設定(重點!)

  1. -XX:+PrintFlagsInitial

    :檢視所有的參數的預設初始值。
  2. -XX:+PrintFlagsFinal

    :檢視所有的參數的最終值(可能會存在修改,不再是初始值)。
  3. -Xms

    :初始堆空間記憶體(預設為實體記憶體的1/64)。
  4. -Xmx

    :最大堆空間記憶體(預設為實體記憶體的1/4)。
  5. -Xmn

    :設定新生代的大小。(初始值及最大值)。
  6. -XX:NewRatio

    :配置新生代與老年代在堆結構的占比。
  7. -XX:SurvivorRatio

    :設定新生代中Eden和S0/S1空間的比例。
  8. -XX:MaxTenuringThreshold

    :設定新生代垃圾的最大年齡。
  9. -XX:+PrintGCDetails

    :輸出詳細的GC處理日志。
    • 列印gc簡要資訊:
      1. -Xx:+PrintGC

      2. - verbose:gc

  10. -XX:HandlePromotionFalilure

    :是否設定空間配置設定擔保。

10.1 空間配置設定擔保

在發生Minor GC之前,虛拟機會檢查老年代最大可用的連續空間是否大于新生代所有對象的總空間。

  • 如果大于,則此次Minor GC是安全的。
  • 如果小于,則虛拟機會檢視

    -XX:HandlePromotionFailure

    設定值

    是否允許擔保失敗

    • 如果

      -XX:HandlePromotionFailure=true

      ,那麼會繼續檢查

      老年代最大可用連續空間是否大于曆次晉升到老年代的對象的平均大小

      • 如果大于,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;
      • 如果小于,則改為進行一次Full GC。
    • 如果

      -XX:HandlePromotionFailure=false

      ,則改為進行一次Full GC。
在JDK6 Update24之後 ,HandlePromotionFailure參數不會再影響到虛拟機的空間配置設定擔保政策,觀察openJDK中的源碼變化,雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。

JDK6 Update 24之後的規則變為隻要老年代的連續空間大于新生代對象總大小或者曆次晉升的平均大小就會進行Minor GC,否則将進行Full GC。

11. 堆是配置設定對象的唯一選擇麼?

在《

深入了解Java虛拟機

》中關于Java堆記憶體有這樣一段描述:

随着JIT編譯期的發展與逃逸分析技術逐漸成熟

棧上配置設定

标量替換

優化技術将會導緻一些微妙的變化,

所有的對象都配置設定到堆上也漸漸變得不那麼“絕對”了。

在Java虛拟機中,對象是在Java堆中配置設定記憶體的,這是一個普遍的常識。但是,有一種特殊情況,那就是

如果經過逃逸分析(Escape Analysis)後發現,一個對象并沒有逃逸出方法的話,那麼就可能被優化成棧上配置設定。

這樣就無需在堆上配置設定記憶體,也無須進行垃圾回收了。這也是最常見的

堆外存儲技術

  • 此外,前面提到的

    基于OpenJDk深度定制的TaoBaoVM

    ,其中創新的

    GCIH(GC invisible heap)

    技術實作

    off-heap

    将生命周期較長的Java對象從heap中移至heap外

    ,并且GC不能管理GCIH内部的Java對象,以此達到降低GC的回收頻率和提升GC的回收效率的目的。

11.1 逃逸分析概述

  1. 何将堆上的對象配置設定到棧,需要使用逃逸分析手段。
  2. 這是

    一種可以有效減少Java程式中同步負載

    記憶體堆配置設定壓力的跨函數全局資料流分析算法

    。通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用範圍進而決定

    是否要将這個對象配置設定到堆上

    。逃逸分析的基本行為就是

    分析對象動态作用域

    • 當一個對象在方法中被定義後,對象隻在方法内部使用,則認為沒有發生逃逸。

    • 當一個對象在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為調用參數傳遞到其他地方中。
  3. 逃逸分析舉例:
    • 沒有發生逃逸的對象,則可以配置設定到棧上,随着方法執行的結束,棧空間就被移除,每個棧裡面包含了很多棧幀,也就是發生逃逸分析。

逃逸分析舉例:

沒有發生逃逸的對象,則可以配置設定到棧上,随着方法執行的結束,棧空間就被移除,每個棧裡面包含了很多棧幀,也就是發生逃逸分析。

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

針對下面的代碼

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

如果想要StringBuffer sb不發生逃逸,可以這樣寫

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

逃逸分析代碼舉例

/**
 * 逃逸分析
 * 如何快速的判斷是否發生了逃逸分析,大家就看new的對象是否在方法外被調用。
 */
public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /**
     * 方法傳回EscapeAnalysis對象,發生逃逸
     */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis():obj;
    }

    /**
     * 為成員屬性指派,發生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }

    /**
     * 對象的作用于僅在目前方法中有效,沒有發生逃逸
     */
    public void useEscapeAnalysis() {
        EscapeAnalysis e = new EscapeAnalysis();
    }

    /**
     * 引用成員變量的值,發生逃逸
     */
    public void useEscapeAnalysis2() {
        EscapeAnalysis e = getInstance();
        // getInstance().XXX  發生逃逸
    }
}
           

逃逸分析參數設定

在JDK 1.7 版本之後,HotSpot中預設就已經開啟了逃逸分析。如果使用的是較早的版本,開發人員則可以通過:
  • 選項“

    -XX:+DoEscapeAnalysis

    "顯式開啟逃逸分析。
  • 通過選項“

    -XX:+PrintEscapeAnalysis

    "檢視逃逸分析的篩選結果。

逃逸分析的結論

開發中能使用局部變量的,就不要使用在方法外定義。

使用逃逸分析,編譯器可以對代碼做如下優化:
  • 棧上配置設定:

    将堆配置設定轉化為棧配置設定。如果一個對象在子程式中被配置設定,要使指向該對象的指針永遠不會發生逃逸,對象可能是棧上配置設定的候選,而不是堆上配置設定。
  • 同步省略:

    如果一個對象被發現隻有一個線程被通路到,那麼對于這個對象的操作可以不考慮同步。
  • 分離對象或标量替換:

    有的對象可能不需要作為一個連續的記憶體結構存在也可以被通路到,那麼對象的部分(或全部)可以不存儲在記憶體,而是存儲在CPU寄存器中。

11.2 棧上配置設定

  1. JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象并沒有逃逸出方法的話,就可能被優化成棧上配置設定。配置設定完成後,繼續在調用棧内執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無須進行垃圾回收了。

  2. 在逃逸分析中,已經說明了。分别是給成員變量指派、方法傳回值、執行個體引用傳遞。

棧上配置設定舉例

/**
 * 棧上配置設定測試
 * -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 {
    }
}
           
設定JVM參數,表示

未開啟逃逸分析

-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
           
運作結果,同時還觸發了GC操作
花費的時間為:116 ms
           
然後檢視記憶體的情況,
JVM之深入了解堆空間(二)
發現有大量的User存儲。
我們在開啟逃逸分析。
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
           
然後檢視運作時間,我們能夠發現花費的時間快速減少,同時不會發生GC操作。
花費的時間為:8 ms
           
後在看記憶體情況,我們發現隻有很少的User對象,說明User發生了逃逸,因為他們存儲在棧中,随着棧的銷毀而消失。
JVM之深入了解堆空間(二)

11.3 同步省略

  1. 線程同步的代價是相當高的,同步的後果是降低并發性和性能。

  2. 在動态編譯同步塊的時候,JIT編譯器可以借助逃逸分析來判斷同步塊所使用的鎖對象是否隻能夠被一個線程通路而沒有被釋出到其他線程。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。這樣就能大大提高并發性和性能。這個取消同步的過程就叫同步省略,也叫鎖消除。☆

// 例如下面的代碼
public void f() {
    Object hellis = new Object();
    synchronized(hellis) {
        System.out.println(hellis);
    }
}

// 代碼中對hellis這個對象加鎖,但是hellis對象的生命周期隻在f()方法中,并不會被其他線程所通路到,是以在JIT編譯階段就會被優化掉,優化成:
public void f() {
    Object hellis = new Object();
	System.out.println(hellis);
}
           

我們将其轉換成位元組碼

JVM之深入了解堆空間(二)

11.4 分離對象和标量替換

  1. 标量(scalar)是指一個無法再分解成更小的資料的資料。Java中的原始資料類型就是标量。

  2. 相對的,那些

    還可以分解的資料叫做聚合量(Aggregate)

    ,Java中的對象就是聚合量,因為他可以

    分解成其他聚合量和标量。

  3. 在JIT階段,如果經過逃逸分析,發現一個對象不會被外界通路的話,那麼經過JIT優化,就會把這個對象拆解成若幹個其中包含的若幹個成員變量來代替。這個過程就是标量替換。
public static void main(String args[]) {
    alloc();
}
class Point {
    private int x;
    private int y;
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}
           
以上代碼,經過标量替換後,就會變成
private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}
           
  1. 可以看到,Point這個聚合量經過逃逸分析後,發現他并沒有逃逸,就被替換成兩個聚合量了。

    那麼标量替換有什麼好處呢?就是可以大大減少堆記憶體的占用。因為一旦不需要建立對象了,那麼就不再需要配置設定堆記憶體了。

  2. 标量替換為棧上配置設定提供了很好的基礎。

代碼優化之标量替換

上述代碼在主函數中進行了1億次alloc。調用進行對象建立,由于User對象執行個體需要占據約16位元組的空間,是以累計配置設定空間達到将近1.5GB。如果堆空間小于這個值,就必然會發生GC。使用如下參數運作上述代碼:
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
           
  • 1
這裡設定參數如下:
  1. 參數

    -server

    :啟動Server模式,因為在server模式下,才可以啟用逃逸分析。
  2. 參數

    -XX:+DoEscapeAnalysis

    :啟用逃逸分析。
  3. 參數

    -Xmx10m

    :指定了堆空間最大為10MB。
  4. 參數

    -XX:+PrintGC

    :将列印Gc日志。
  5. 參數

    -xx:+EliminateAllocations

    :開啟了标量替換(預設打開),允許将對象打散配置設定在棧上,比如對象擁有id和name兩個字段,那麼這兩個字段将會被視為兩個獨立的局部變量進行配置設定。

11.5 逃逸分析的不足

  1. 關于逃逸分析的論文在1999年就已經發表了,但直到JDK1.6才有實作,

    而且這項技術到如今也并不是十分成熟的。
  2. 其根本原因就是無法保證逃逸分析的性能消耗一定能高于他的消耗。雖然經過逃逸分析可以做标量替換、棧上配置設定、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。

    • 一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
  3. 雖然這項技術并不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。注意到有一些觀點,認為通過逃逸分析,JVM會在棧上配置設定那些不會逃逸的對象,這在理論上是可行的,但是取決于JvM設計者的選擇。

    Oracle Hotspot JVM中并未這麼做,這一點在逃逸分析相關的文檔裡已經說明,是以可以明确所有的對象執行個體都是建立在堆上。

  4. 目前很多書籍還是基于JDK7以前的版本,JDK已經發生了很大變化,

    intern字元串的緩存和靜态變量曾經都被配置設定在永久代上,而永久代已經被中繼資料區取代。

    但是,

    intern字元串緩存和靜态變量并不是被轉移到中繼資料區,而是直接在堆上配置設定

    ,是以這一點同樣符合前面一點的結論:

    對象執行個體都是配置設定在堆上。

12. 堆小結

  1. 年輕代

    是對象的誕生、成長、消亡的區域,一個對象在這裡産生、應用,最後被垃圾回收器收集、結束生命。
  2. 老年代

    放置長生命周期的對象,通常都是從survivor區域篩選拷貝過來的Java對象。
    • 當然,也有特殊情況,我們知道普通的對象會被配置設定在TLAB上;
    • 如果對象較大,JVM會試圖直接配置設定在Eden其他位置上;如果對象太大,完全無法在新生代找到足夠長的連續空閑空間,JVM就會直接配置設定到老年代。
    • 當GC隻發生在年輕代中,回收年輕代對象的行為被稱為Minor GC。
  3. 當GC發生在老年代時則被稱為Major GC或者Full GC。一般的,Minor GC的發生頻率要比Major GC高很多,即老年代中垃圾回收發生的頻率将大大低于年輕代。

資料參考

1.周志明大大的《深入了解Java虛拟機》

2.宋紅康老師《JVM精講系列》