1. 程式人生 > 其它 >JVM 記憶體分配、調優案例

JVM 記憶體分配、調優案例

記憶體分配

物件優先在Eden區分配

大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC。
HotSpot虛擬機器提供了-XX:+PrintGCDetails這個收集器日誌引數,告訴虛擬機器在發生垃圾收集行為時列印記憶體回收日誌,
並且在程序退出的時候輸出當前的記憶體各區域分配情況。

在程式碼清單1-1的testAllocation()方法中,嘗試分配三個2MB大小和一個4MB大小的物件,
在執行時通過-Xms20M、-Xmx20M、-Xmn10M這三個引數限制了Java堆大小為20MB,不可擴充套件,其中10MB分配給新生代,剩下的10MB分配給老年代。
-XX:Survivor-Ratio=8決定了新生代中Eden區與一個Survivor區的空間比例是8∶1,從輸出日誌可看到“eden space 8192K、from space 1024K、
to space 1024K”的資訊,新生代總可用空間為9216KB(Eden區+1個Survivor區的總容量)。

執行testAllocation()中分配allocation4物件的語句時會發生一次Minor GC,產生這次垃圾收集的原因是為allocation4分配記憶體時,
發現Eden已經被佔用了6MB,剩餘空間已不足以分配allocation4所需的4MB記憶體,因此發生Minor GC。
垃圾收集期間虛擬機器又發現已有的三個2MB大小的物件全部無法放入Survivor空間(Survivor空間只有1MB大小),
所以只好通過分配擔保機制將其提前轉移到老年代去

這次收集結束後,4MB的allocation4物件順利分配在Eden中。因此程式執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,
老年代被佔用6MB(被allocation1、2、3佔用)。通過GC日誌可以證實這一點。

程式碼清單1-1 物件優先分配在Eden區

    private static final int _1MB = 1024 * 1024;
    /**
     * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     */
    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB]; // 出現一次Minor GC
    }

大物件直接進入老年代

大物件就是指需要大量連續記憶體空間的Java物件,最典型便是很長的字串,或者元素數量很龐大的陣列。
大物件對虛擬機器的記憶體分配來說是一個不折不扣的壞訊息,更加壞的訊息是遇到一群朝生夕滅的短命大物件,寫程式時應注意避免。
在Java虛擬機器中要避免大物件的原因是,在分配空間時,它容易導致記憶體還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能安置好它們
而當複製物件時,大物件意味著高額的記憶體複製開銷。HotSpot虛擬機器提供了-XX:PretenureSizeThreshold引數,指定大於該設定值的物件直接在老年代分配,
目的就是避免在Eden區及兩個Survivor區之間來回複製,產生大量的記憶體複製操作。
執行程式碼清單1-2後,可以看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40%,也就是4MB的分配物件直接就分配在老年代中,
這是因為-XX:PretenureSizeThreshold被設定為3MB(就是3145728,這個引數不能與-Xmx之類的引數一樣直接寫3MB),
因此超過3MB的物件都會直接在老年代進行分配。

程式碼清單1-2 大物件直接進入老年代

    private static final int _1MB = 1024 * 1024;
    /**
     * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     * -XX:PretenureSizeThreshold=3145728
     */
    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4 * _1MB]; //直接分配在老年代中
    }

-XX:PretenureSizeThreshold引數只對Serial和ParNew兩款新生代收集器有效,HotSpot的其他新生代收集器,
如Parallel Scavenge並不支援這個引數。如果必須使用此引數進行調優,可考慮ParNew加CMS的收集器組合。

長期存活的物件將進入老年代

HotSpot虛擬機器中多數收集器都採用了分代收集來管理堆記憶體,那記憶體回收時就必須能決策存活物件應該放在新生代或是老年代。
為做到這點,虛擬機器給每個物件定義了一個物件年齡計數器,儲存在物件頭中。物件通常在Eden區裡誕生,如果經過第一次Minor GC後仍然存活,
並且能被Survivor容納的話,該物件會被移動到Survivor空間中,並且將其物件年齡設為1歲。若不能容納,則由空間分配擔保,移動到老年代中。
物件在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15),就會被晉升到老年代中。
物件晉升老年代的年齡閾值,可以通過引數-XX:MaxTenuringThreshold設定。

動態物件年齡判斷

為了能更好地適應不同程式的記憶體狀況,HotSpot虛擬機器並不是永遠要求物件的年齡必須達到-XX:MaxTenuringThreshold才能晉升老年代,
如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代
無須等到-XX:MaxTenuringThreshold中要求的年齡。

執行程式碼清單1-3程式後,並將設定-XX:MaxTenuring-Threshold=15,發現執行結果中Survivor佔用仍然為0%,
而老年代比預期增加了6%,也就是說allocation1、allocation2物件都直接進入了老年代,並沒有等到15歲的臨界年齡。
因為這兩個物件加起來已經到達了512KB,並且它們是同年齡的,滿足同年物件達到Survivor空間一半的規則。
我們只要註釋掉其中一個物件的new操作,就會發現另外一個就不會晉升到老年代了。

程式碼清單1-3 動態物件年齡判定

    private static final int _1MB = 1024 * 1024;
    /**
     * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     -XX:MaxTenuringThreshold=15
     * -XX:+PrintTenuringDistribution
     */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold2() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大於survivo空間一半
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }

空間分配擔保

在發生Minor GC之前,虛擬機器必須先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,成立則進行Minor GC。
如果不成立,則先檢視-XX:HandlePromotionFailure引數的設定值是否允許擔保失敗;
如果允許,那會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小
如果大於,將嘗試進行一次Minor GC,儘管這次Minor GC是有風險的;
如果小於,或者-XX:HandlePromotionFailure設定不允許冒險,那這時就要改為進行一次Full GC。
這裡的冒險指的是由於新生代使用標記-複製演算法,為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,
當出現大量物件在Minor GC後仍然存活的情況——最極端的情況就是記憶體回收後新生代中所有物件都存活,這時便需要老年代進行分配擔保,
把Survivor無法容納的物件直接送入老年代。

在JDK 6 Update 24之後,-XX:HandlePromotionFailure引數不會再影響到虛擬機器的空間分配擔保策略,
雖然原始碼中定義了-XX:HandlePromotionFailure引數,但是在實際虛擬機器中已經不會再使用它。JDK 6 Update 24之後的規則變為
只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小,就會進行Minor GC,否則將進行Full GC。

調優案例

堆外記憶體溢位

堆外記憶體區域只有在JVM 發生Full GC或是程式中手動呼叫System.gc()時才會被進行垃圾回收。
但如果JVM打開了-XX:+DisableExplicitGC開關,System.gc()就會被禁止使用,在程式一直沒進行Full GC時,
雖然堆外記憶體中有許多可回收記憶體,但也不得不丟擲OOM。
JVM 常見記憶體區域如下,這些記憶體總和受到本機記憶體和作業系統程序最大記憶體的限制:

  • 堆外記憶體,Redis儲存BigKey時丟擲堆外記憶體溢位異常,Redis 內部使用Netty,而Netty又使用了Java NIO分配堆外記憶體,堆外記憶體不足導致OOM。
    可通過-XX:MaxDirectMemorySize調整堆外記憶體大小。
  • 執行緒堆疊,可通過-Xss調整大小,記憶體不足時丟擲StackOverflowError(如果執行緒請求的棧深度大於虛擬機器所允許的深度)
    或者OutOfMemoryError(如果Java虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體)。
  • Socket快取區:每個Socket連線都有Receive和Send兩個快取區,分別佔大約37KB和25KB記憶體,連線
    多的話這塊記憶體佔用也比較可觀。如果無法分配,可能會丟擲IOException:Too many open files異常。
  • JNI程式碼, 如果程式碼中使用了JNI呼叫本地庫,那本地庫使用的記憶體也不在堆中,而是佔用Java虛擬機器的本地方法棧和本地記憶體的。
  • 虛擬機器和垃圾收集器:虛擬機器、垃圾收集器的工作也是要消耗一定數量的記憶體的。

虛擬機器程序崩潰

如開放API 操作較耗時,在上次操作還未結束,呼叫方又通過非同步傳送了許多請求,
時間一長,導致等待的執行緒和Socket 介面越來越多,到超過虛擬機器承受能力時導致虛擬機器程序崩潰。

使用合適的資料結構

不正確的資料結構,在資料量較大且單個元素佔用記憶體較小時,使用Map 構建會造成很大的空間浪費。
如Map<Integer, Integer>, 有效資料僅為8個位元組,而建立Map.Entry 等的開銷遠大於此。

合理規劃堆記憶體、合理編碼

合理分配年輕代(Eden、survivor比例)、老年代記憶體比例,降低Full GC頻率。
可以通過以下幾個引數要求虛擬機器生成GC日誌:-XX:+PrintGCTimeStamps(列印GC停頓時間)、
-XX:+PrintGCDetails(列印GC詳細資訊)、-verbose:gc(列印GC資訊,輸出內容已被前一個引數包括,可以不寫)、-Xloggc:gc.log。

Minor GC 耗時較短,影響不大,而Full GC 相對來說耗時較長, 所以應該將程式 Full GC的頻率控制得足夠低。
控制Full GC頻率的關鍵是老年代的相對穩定,這取決應用中的物件是否為符合'朝生夕死'原則,
程式應儘量避免成批量的、長時間存在的大物件產生,如此才能保障老年代的穩定。

選擇合適的垃圾收集器

針對不同的應用場景,選擇適合的垃圾收集器,例如有的注重低時延,有的注重高吞吐量。