運行時數據區之堆

1. 概念

堆是JVM內存管理的核心區域,它是JVM管理的一塊最大的區域,一個JVM實例只存在一個堆內存。堆會在JVM啓動時被創建,此時堆大小也就被確定了。堆可以處於物理上不連續的內存空間中,但在邏輯上它應該是連續的。堆是線程共享的,這裏還可以劃分線程私有的緩衝區(Thread Local Alloction Buffer,TLAB),從而保證線程之間的併發性。

幾乎所有的對象實例都分配在堆內存空間中。數組和對象可能永遠不會存儲在棧上,因爲棧幀中保存引用,這個引用指向對象或者數組在堆中的位置。方法結束後,堆中的對象不會被立即移除,而是在垃圾收集時纔會被移除。因此,它也是垃圾回收的重點區域

例如,當前程序如下所示,程序不斷地往list中添加對象:

public class HeapOOMDemo {
    static class OOMObject{}

    public static void main(String[] args) {
        ArrayList<OOMObject> list = new ArrayList<>();
        HeapOOMDemo demo = new HeapOOMDemo();
        try {
            while (true) {
                list.add(new OOMObject());
                Thread.sleep(10000);
            }
        } catch (Exception e){
            System.out.println(list.size());
            e.printStackTrace();
        }
    }
}

通過JDK自帶的jvisualVM工具,我們可以看到堆內存空間的變化情況。
在這裏插入圖片描述

2. 堆空間劃分

在具體瞭解堆空間的劃分之前,我們首先回顧一下當創建對象或數組時,運行時數據區是怎樣變化的。假設程序如下所示,當程序經過編譯會得到相應的字節碼文件。字節碼文件經過類加載子系統的加載、鏈接和初始化等一系列的操作,將字節碼文件中的內容裝換爲運行時方法區中可以使用的類型。其中在方法區中存放的是類的實現邏輯。當調用Student s = new Student()實例化對象時,對象的創建是在堆中,類變量s保存在棧中,它存放的實例化對象在堆中的地址。
在這裏插入圖片描述

在上面的例子中,我們將堆空間簡單的看作一塊較大的空間。如果將其細分,堆空間可以分爲:

  • Java7及之後將堆分爲三部分:

    • 新生代(Young Generation Space):其中又分爲Eden區和兩個Survivor區
    • 老年代(Tenure Generation Space)
    • 永久代(Permanent Space)
  • Java8及之後將堆同樣分爲三部分:

    • 新生代
    • 老年代
    • 元空間(Meta Space)

新創建的對象會在新生代中分配內存;經過多次回收仍然存活的對象存放在老年代中;靜態屬性和類信息等存放在永久代中。新生代中的對象生命週期較短,只需要在新生代中頻繁的進行GC;老年代(元空間)中對象生命週期長,內存回收的頻率較低,不需要頻繁的GC;永久代一般不進行GC。還可以根據不同區域的特點選擇不同的GC算法,從而提高GC的效率。

2.1年輕代和老年代

存儲在JVM 中的對象可以被劃分爲兩類:

  • 生命週期較短的瞬時對象,它的創建和消亡都很快
  • 生命週期非常長,在某些極端情況下還能夠和JVM的生命週期保持一致

Java堆區的細化分可以分爲年輕代和老年代,其中年輕代包括Eden空間Survivor0空間Survivor1空間
在這裏插入圖片描述

默認情況下,新生代和老年代在堆結構中的佔比爲:-XX:NewRatio=2,表示新生代佔1,老年代佔2。

HotSpot中,Eden和兩個Survivor空間默認所佔比例爲8:1:1,當然用戶可以使用-XX:SurvivorRatio進行調整。Eden空間所佔比例最大的原因是:幾乎所有的Java對象都是在Eden區被new創建的,而且絕大多數的Java對象的銷燬在新生代中進行。

新生代中80%的對象都是朝生夕死。

同樣可以通過-Xmn參數設置新生代的大小,一般選擇默認值即可。

2.2 分代思想

堆中進行分代並不是必須的,分代的唯一理由就是優化GC的性能。如果沒有分代,那麼所有的對象都在一塊,但執行垃圾回收時,JVM爲了識別哪些是垃圾,它就需要對所有的區域進行掃描。但很多的對象都是朝生夕死的,對所有的區域進行掃描的是不必要的。

如果分代的話,把新創建的對象放到某一個地方,當GC的時候會先把這塊存放朝生夕死獨享的區域進行回收,這樣就會騰出很大的內存空間。

3. 對象分配過程

爲新對象分配內存不僅需要考慮內存如何分配、在哪裏分配等問題,並且由於內存分配算法和垃圾回收算法密切相關,所以還需要考慮GC執行完是否會在內存空間中產生內存碎片。內存分配的過程爲:

  • 首先考慮將new的新對象放在Eden區
  • 當Eden區滿時,程序又需要創建對象,JVM將對Eden區進行垃圾回收,這裏指的是Minor GC。Minor GC將Eden區中不再被其它對象所引用的對象進行銷燬,在將新對象放入Eden區
  • 然後將Eden區中的剩餘對象移動到S0區
  • 如果再次觸發垃圾回收,此時上次倖存下來的放到S0區;如果沒有回收,就會放到S1區
  • 如果再次經歷垃圾回收,此時會重新放回S0區,接着再去S1區
  • 如果對象的交換次數超過了設定的閾值,則將其放到老年代中,閾值可使用-XX:MaxTenuringThreshold=<N>參數設置

那麼如何理解上面所描述的分配過程呢,下面我們通過圖解的方式看一下。堆這裏表示爲新生代和老年代。其中新生代又可以分爲Eden區、S0區、S1區。JVM剛啓動時,由於堆中沒有對象存放,因此四個區域都爲空。假設經過一段時間,當在Eden區中放入兩個新對象後,Eden區已滿,此時就會觸發Minor GC,JVM將會執行垃圾收集。
在這裏插入圖片描述

Minor GC的過程爲:將判定爲垃圾的對象進行回收,同時將剩下的對象複製到S0區,更加準確的說應該是將剩下的對象複製到from區。因爲對於S0和S1來說,進行復制之後,誰空誰就是to區。同時這裏對象還保持一個年齡計數值,當前值爲1,因爲它只進行了一次複製。
在這裏插入圖片描述

如果某個時刻Eden區又滿了,而且from區也滿,就會再次觸發Minor GC。將Eden區中的垃圾進行回收,將剩餘的對象複製到空的to區,同時將from區中的對象也移動到to區,同時更新對象的年齡計數值。執行完GC後,S0就變成了to區,S1此時爲from區。
在這裏插入圖片描述

又經過一段時間的運行,Eden區和from區都滿了,而且from區中的部分對象的年齡計數值到達15。那麼除了垃圾收集外,移動年齡計數值小於15的對象到to區外,這裏直接將計數值爲15的對象晉升到老年代。
在這裏插入圖片描述

下面通過流程圖說明一下上圖所描述的過程:
在這裏插入圖片描述

  • 當有新對象創建的請求時,首先查看Eden區中是否還有空間存放。如果有,則爲其分配內存,正常的完成對象的創建
  • 否則,觸發YGC。JVM繼續查看Survivor空間中數是否有空間存放,如果有則放置到S0/S1空間中,即所說的from空間;否則將其放入老年代
  • 執行完YGC後,如果再有對象創建請求,仍然首先查看Eden空間的情況。如果滿足要求,則爲其正常分配內存;否則,如果此時Survivor區已滿,則查看老年代是否有空間存放。
  • 如果有,則正常分配;如果沒有,則觸發FGC。對老年代執行完垃圾收集之後再查看是否有空間,有則分配,沒有則拋出OOM異常

4. Minor GC、Major GC和Full GC

JVM在進行垃圾回收時並不會針對所有的內存區域一起回收,大部分時候會指向的都是新生代。針對於HotSpot的實現,它裏面的GC按照回收區域又分爲兩種類型:

  • 部分收集(Patarial GC):不是完整收集整個Java堆的垃圾收集,其中又分爲:
    • 新生代收集(Minor GC,Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC ,Old GC):只是老年代的垃圾收集,目前只有CMS GC會有單獨進行老年代收集
    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集
  • 整堆收集(Full GC):收集整個堆和方法區的垃圾收集
4.1 新生代GC的觸發機制

當新生代空間不足時,就會觸發Minor GC,這裏的新生代指的是Eden區滿,Survivor區滿不會觸發GC。因爲Java對象大多都具備朝生夕死的特性,所以Minor GC非常頻繁,一般回收速度也比較快。而且Minor GC會引發STW,暫停其他的用戶線程,等待垃圾回收結束後才恢復。

4.2 老年代GC的觸發機制

當對象從老年代消失時,我們就說觸發了在老年代的GC,即Major GC或時Full GC。出現了Major GC,經常會伴隨至少一次的Minor GC,即當老年代空間不足時,首先會觸發Minor GC;如果執行後空間仍不足,則會觸發Major GC;如果Major GC執行後仍不足,則拋出OOM異常。但並非絕對,不同的垃圾收集器的策略不同。

Major GC的速度一般會比Minor GC慢的多,因此它的發生頻率也就低得多。

4.3 Full GC的觸發機制

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

  • 調用System.gc(),系統建議執行Full GC,但不是必然執行
  • 老年代空間不足
  • 方法區空間不足
  • 通過Minor GC後進入老年代的平均大小大於老年代的可用內存
  • 由Eden區、So區和S1區複製時,對象大小大於to區的可用內存,則把對象轉存到老年代,且老年代的可用內存大小小於該對象大小
4.4 GC示例

程序如下所示:

import java.util.ArrayList;
import java.util.List;

public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "forlogen.csdn.cn.com";
            while (true) {
                list.add(a);
                a = a + a;
                i++;
            }

        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println(i);
        }
    }
}

輸出結果爲:

[GC (Allocation Failure) [PSYoungGen: 2018K->508K(2560K)] 2018K->924K(9728K), 0.0006958 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2008K->480K(2560K)] 2424K->2096K(9728K), 0.0005525 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2509K->380K(2560K)] 7965K->6476K(9728K), 0.0007708 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 380K->0K(2560K)] [ParOldGen: 6096K->4454K(7168K)] 6476K->4454K(9728K), [Metaspace: 3137K->3137K(1056768K)], 0.0055681 secs] [Times: user=0.09 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 72K->160K(2560K)] 7087K->7174K(9728K), 0.0004976 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 160K->0K(2560K)] [ParOldGen: 7014K->5715K(7168K)] 7174K->5715K(9728K), [Metaspace: 3176K->3176K(1056768K)], 0.0028468 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 5715K->5715K(9728K), 0.0004541 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 5715K->5694K(7168K)] 5715K->5694K(9728K), [Metaspace: 3176K->3176K(1056768K)], 0.0054247 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
15

java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOfRange(Arrays.java:3664)
	at java.lang.String.<init>(String.java:207)
	at java.lang.StringBuilder.toString(StringBuilder.java:407)
	at Heap.GCTest.main(GCTest.java:20)

從輸出結果中可以看出,JVM首先執行的是Minor GC,當Minor GC執行後仍然不夠空間存放時才執行Full GC。如果Full GC執行後仍然不夠,那麼就會拋OutOfMemoryError。

5. 內存分配策略

如果對象在Eden區中存放並經過第一次Minor GC後仍然存活,並且被Survivor區所容納的話,將被移動到Survivor空間中,並將對象年齡設置爲1。對象在Survivor區中每經過一次Minor GC,年齡計數值就加1.當它的年齡計數值增加到一定程度(默認15)時,它就會被直接晉升到老年代。

晉升判斷的閾值可以通過-XX:MaxTenuringThreshold參數設置

針對於不同年齡段的對下個分配的原則爲:

  • 優先分配到Eden區
  • 大對象直接分配到老年代
  • 長期存活的對象分配到老年代
  • 動態對象年齡判斷:如果Survivor區中相同年齡的所有對象的大小綜合大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代,無須等到MaxTenturingThreshold中要求的數值
5.1 大對象的分配

下面通過代碼理解下大對象直接分配到老年代指的是什麼,假設此時要分配一個很大的數組,那麼JVM就會直接將其分配到老年代,如下所示:

public class YoungOldAreaTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024 * 1024 * 20];//20m
    }
}

設置JVM參數-Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails,控制檯輸出爲:

Heap
 PSYoungGen      total 18432K, used 2622K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
  eden space 16384K, 16% used [0x00000000fec00000,0x00000000fee8fb00,0x00000000ffc00000)
  from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
  to   space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
 ParOldGen       total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
  object space 40960K, 50% used [0x00000000fc400000,0x00000000fd800010,0x00000000fec00000)
 Metaspace       used 3227K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 350K, capacity 388K, committed 512K, reserved 1048576K

從結果中可以看出,老年代中的20480k大小的區域存放的就是buffer數組。

6. TLAB(Thread Local Allocation Buffer)

JVM中堆空間是線程共享的,任何線程都可以訪問堆中的共享數據。由於對象的創建是很頻繁的,因此在併發環境下從堆區中劃分內存空間是線程不安全的。爲了滿足同步機制就需要加鎖等同步操作,而這會影響分配的速度。

因此,從內存模型的角度對Eden區繼續進行劃分,JVM爲每個線程分配了一個私有緩存區域,這些私有區域就構成了TLAB。多線程同時分配內存時,使用TLAB可以避免一系列的非線程安全問題,同時還能提升內存分配的吞吐量,這種分配方式也被稱爲快速分配策略

儘管TLAB在Eden區中只佔很少一部分,通常爲1%,但它是JVM內存分配的首先方式。一旦對象在TLAB空間中分配內存失敗時,JVM就會嘗試通過使用加鎖機制確保數據操作的原子性,從而直接在Eden空間中分配內存。
在這裏插入圖片描述

7.堆常用的jvm參數

  • -XX:+PrintFlagsInitial : 查看所有的參數的默認初始值
  • -XX:+PrintFlagsFinal:查看所有的參數的最終值(可能會存在修改,不再是初始值)。具體查看某個參數的指令:
    • jps:查看當前運行中的進程
    • jinfo -flag SurvivorRatio 進程id
  • -Xms:初始堆空間內存 (默認爲物理內存的1/64)
  • -Xmx:最大堆空間內存(默認爲物理內存的1/4)
  • -Xmn:設置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代與老年代在堆結構的佔比
  • -XX:SurvivorRatio:設置新生代中Eden和S0/S1空間的比例
  • -XX:MaxTenuringThreshold:設置新生代垃圾的最大年齡
  • -XX:+PrintGCDetails:輸出詳細的GC處理日誌
  • 打印gc簡要信息:①-XX:+PrintGC-verbose:gc
  • -XX:HandlePromotionFailure:是否設置空間分配擔保

Java堆的大小在JVM啓動時就被確定下來,在啓動之前可以通過-Xmx-Xms來設置堆空間的大小:

  • -Xmx:用於堆區的起始內存,默認大小爲物理電腦內存/64
  • -Xms:用於表示堆區的最大內存,默認大小爲物理電腦內存/4

一旦堆區中所需使用的內存大小超過了-Xmx所設置的大小,將會拋出OutOfMemeoryError異常。

通常將上面的兩個參數設置成相同的值,表示不容許堆區的自動擴容。這樣做能夠避免在垃圾回收時,JVM清理完堆區後重新分隔計算堆區大小的開銷,從而提升性能。

例如,我們可以通過Runtime類的方法來查看JVM默認設置的堆內存大小,以及系統自身的總內存大小。

public class HeapDemo {
    public static void main(String[] args) {
        //返回Java虛擬機中的堆內存總量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虛擬機試圖使用的最大堆內存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

        System.out.println("系統內存大小爲:" + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系統內存大小爲:" + maxMemory * 4.0 / 1024 + "G");
    }
}
-Xms : 126M
-Xmx : 2010M
系統內存大小爲:7.875G
系統內存大小爲:7.8515625G

或者使用jpsjstat -gc 進程id指令查看;另一種方式是設置JVM參數-XX:+PrintGCDetails來查看。

[0.003s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
[0.013s][info   ][gc,heap] Heap region size: 1M
[0.015s][info   ][gc     ] Using G1
[0.015s][info   ][gc,heap,coops] Heap address: 0x0000000082600000, size: 2010 MB, Compressed Oops mode: 32-bit
[0.040s][info   ][gc           ] Periodic GC disabled
[0.115s][info   ][gc,heap,exit ] Heap
[0.115s][info   ][gc,heap,exit ]  garbage-first heap   total 129024K, used 2048K [0x0000000082600000, 0x0000000100000000)
[0.115s][info   ][gc,heap,exit ]   region size 1024K, 3 young (3072K), 0 survivors (0K)
[0.115s][info   ][gc,heap,exit ]  Metaspace       used 621K, capacity 4531K, committed 4864K, reserved 1056768K
[0.115s][info   ][gc,heap,exit ]   class space    used 56K, capacity 402K, committed 512K, reserved 1048576K

8. 逃逸技術(Escape Analysis)

之前我們說堆是對象內存分配的唯一選擇,但這種說法準確嘛。隨着JIT編譯器的發展和逃逸技術逐漸成熟,棧上分配標量替換等技術將會導致一些微妙的變化,所有的對象都分配在堆上也漸漸變得不那麼絕對了。因爲,如果經過逃逸技術後發現,一個對象並沒有逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配內存,也無需進行GC,這就是堆外存儲技術

逃逸技術可以有效減少Java程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。通過逃逸技術,HosSpot能夠分析出一個新對象的引用範圍從而決定是否要將這個對象分配到堆上。逃逸分析的基本行爲就是分析對象的動態作用域:

  • 當一個對象在方法中被定義後,對象只在方法內部使用,那麼認爲沒有發生逃逸
  • 當一個對象在方法中被定義後,它被外部方法中所引用,則認爲發生逃逸

當對象沒有發生逃逸時,就可以將該對象分配到棧上,隨着方法執行的結束,棧空間就會被移除。

例如,程序如下所示:

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 useEscapeAnalysis1(){
        EscapeAnalysis e = getInstance();
    }
}

只要new的對象的引用出了方法體,那麼就判定發生了逃逸。

8.1 棧上分配

將堆分配轉化爲棧分配,如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能時棧分配的候選,而不是堆分配。

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

常見的棧上分配的場景有:

  • 給成員變量賦值
  • 方法返回值
  • 實例引用傳遞
8.2 同步省略

在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸技術判斷同步塊所使用的鎖獨享是否只能被一個線程訪問而沒有被髮布到其他線程。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步,這樣就大大的提高了併發性和性能。這個取消同步的過程就叫做同步省略,也叫鎖消除

8.3 標量替換

標量指一個無法再被分解成更小的數據的數據,Java中的原始數據類型就是標量。相對的,那些還可以被分解的數據叫做聚合量,Java中的對象就是聚合量,因爲它可以分解成其他的聚合量和標量。

在JIT階段,如果經過逃逸分析發現一個對象不會被外界所訪問的話,那麼經過JIT優化,就會把這個對象拆解爲若干個成員變量替代,這個過程就是標量替換。

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