記一塊兒Java大對象引發的FullGC事件及GC知識梳理

背景

最近發生了一塊兒 Java 大對象引發的 FullGC 事件。記錄一下。html

有一位商家刷單,每單內有 50+ 商品。而後進行訂單導出。訂單導出每次會從訂單詳情服務取100條訂單數據。因爲 100 條訂單數據對象很大,致使詳情 FullGC ,影響了服務的穩定性。java

本文藉此來梳理下 Java 垃圾回收算法及分析 JVM 垃圾回收運行的方法。算法


案例分析

若是對GC不太熟悉,能夠先看看「GC姿式」部分,對 JVM 垃圾回收有一個比較清晰的理解。

bootstrap

測定大小

回頭看這個案例,顯然它極可能觸犯了「大對象容易觸發 FullGC 」 的忌諱。先來測定下,這個大數據量的訂單大小究竟有多少?數組

「HBase指定大量列集合的場景下併發拉取數據時卡住的問題排查」 有一段能夠用來計算對象 deep-size 的方法。用法以下:緩存

try {
      ClassIntrospector.ObjectInfo objectInfo = new ClassIntrospector().introspect(orderDetailInfoList);
      logger.info("object-deep-size: {} MB", (double)objectInfo.getDeepSize() / 1024.0 / 1024.0);
    } catch (IllegalAccessException e) {
      logger.warn("failed to introspect object size");
    }

計算一個含有50個商品及優惠信息的訂單,大小爲 335KB,100 個就是 33M 這個商家導出了 4 次,每次有幾百多單,會觸發詳情服務這邊接受請求的幾臺服務器 FullGC ,進而影響詳情服務的穩定性。安全


優化方法

有兩個方法能夠組合使用:服務器

  1. 檢測這個訂單是個大對象,將批量獲取的條數改成更小,好比 10;多線程

  2. 將大訂單對象與小訂單對象混合打散,下降大對象佔用大量連續空間的機率。架構


能夠作個問題抽象:有一個 0 與 1 組成的數組, 0 表示小對象, 1 表示大對象, 問題描述爲:將一個 [0,1] 組成的數組打散,使得 1 的分佈更加稀疏。 其中稀疏度能夠以下衡量: 全部 1 之間的元素數目的平均值和方差。

這個問題看上去像洗牌,但實際是有區別的。洗牌是將有序的數排列打散變成無序,而如今是要使某些元素的分佈更加均勻或稀疏。 一個簡單的算法是:

STEP1: 遍歷數組,將 0 和 1 分別放在列表 zeroList 和 oneList 裏;

STEP2: 計算 0 與 1 的比值 ratio ; 建立一個結果列表 resultList ;

STEP3: 遍歷 oneList ,對於每個 1 , 將其加入 resultList ,同時加入 ratio 個 0 ;若是 0 不夠,則僅返回剩餘的 0 。

代碼實現以下:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class DistributedUtil {

  /**
   * 一個列表,要求將知足條件 cond 的元素均勻分佈到列表中。
   */
  public static <T> List<T> even(List<T> alist, Predicate<T> cond) {
    List<T> specialElements = alist.stream().filter(cond).collect(Collectors.toList());
    List<T> normalElements = alist.stream().filter(e -> !cond.test(e)).collect(Collectors.toList());

    int normalElemSize = normalElements.size();
    int specialElemSize = specialElements.size();

    if (normalElemSize == 0 || specialElemSize == 0) {
      return alist;
    }

    // 只要 normalElements 充足 , 每個 specialElement 插入 ratio 個 normalElements
    int ratio = normalElemSize % specialElemSize ==
        0 ? (normalElemSize / specialElemSize) : (normalElemSize / specialElemSize + 1);

    List<T> finalList = new ArrayList<>();
    int pos = 0;
    for (T one: specialElements) {
      finalList.add(one);
      List<T> normalFetched = get(normalElements, ratio, pos);
      pos += normalFetched.size();
      finalList.addAll(normalFetched);

    }
    return finalList;
  }

  /**
   * 從指定位置 position 取出 n 個元素 , 不足返回剩餘元素或空元素
   */
  public static <T> List<T> get(List<T> normalList, int n, int position) {
    int size = normalList.size();
    int num = size - position;
    int realNum = Math.min(num, n);
    return normalList.subList(position, position+realNum);
  }
}

寫個簡單的單測驗證下:

import org.junit.Test
import spock.lang.Specification
import spock.lang.Unroll

import java.util.function.Predicate

class DistributedUtilTest extends Specification {

    @Unroll
    @Test
    def "testEven"() {
        expect:
        result == DistributedUtil.even(originList, { it == 1 } as Predicate)

        where:
        originList                        | result
        [1, 1, 1, 1, 1]                   | [1, 1, 1, 1, 1]
        [0, 0, 0, 0, 0]                   | [0, 0, 0, 0, 0]
        [1, 0, 0, 0, 0, 0]                | [1, 0, 0, 0, 0, 0]
        [1, 0, 1, 0, 0, 0, 0]             | [1, 0, 0, 0, 1, 0, 0]
        [1, 0, 1, 1, 0, 0, 0, 0]          | [1, 0, 0, 1, 0, 0, 1, 0]
        [1, 0, 1, 1, 1, 0, 0, 0, 0]       | [1, 0, 0, 1, 0, 0, 1, 0, 1]
        [1, 0, 1, 1, 1, 1, 0, 0, 0, 0]    | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
        [1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0] | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]


    }
}


GC姿式

Java 垃圾回收採用的算法主要是:分代垃圾回收。垃圾回收算法簡稱 GC ,下文將以 GC 代之。

分代 GC 的主要理念是:大部分生成的對象都是生命週期短暫的對象,能夠被很快回收掉;不多的對象能活動比較久。所以,分代回收算法,將垃圾回收分爲兩個階段:新生代 GC 和 老年代 GC。

新生代 GC 採用算法基於 GC 複製算法,老年代 GC 採用的算法基於 標記-清除算法。


基礎概念

變量的分配

棧與堆。

棧:臨時變量,做用域結束或函數執行完成後即被釋放;

堆: 數組與對象的存儲,不會隨函數執行完成而釋放。

棧的變量引用堆中的數組與對象。棧的變量就是根引用。引用經過指針來實現。


根引用與活動對象

從根引用出發,遍歷所能引用和抵達的全部對象,這些對象都是活動對象。而其餘則是非活動對象。

GC 的目標就是銷燬非活動對象,騰出內存空間分配給新的對象和活動對象。

根引用(引用自 MAT 工具的文檔):

  • Class loaded by bootstrap/system class loader

  • Object referred to from a currently active thread block.

  • A started, but not stopped, thread.

  • Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.

  • Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.

  • A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.

NOTE ! GC 不只僅是GC,還要與內存分配綜合考慮。


四種引用

  • 強引用: 有強引用的對象不會被回收。

  • 軟引用: 在空間不足時拋出OOM前會回收軟引用的對象。內存敏感的緩存對象,好比cache的value對象

  • 弱引用: 當JVM進行垃圾回收時,不管內存是否充足,都會回收被弱引用關聯的對象。好比canonicalizing mappings

  • 虛引用:often used for scheduling pre-mortem cleanup actions in a more flexible way than is possible with the Java finalization mechanism . get 老是返回 null


算法指標

吞吐量: HEAP_SIZE / Cost(GCa+GCb+...+GCx)

最大暫停時間: max(GCi)

堆使用效率:HEAP_SIZE / Heap(GC)


分代回收算法

  • 不一樣對象的活動週期不一樣;年輕代更快地回收,老年代回收頻率相對少。分代回收 = YoungGC + OldGC

  • YoungGC: GC 複製算法。 比較頻繁;

  • OldGC: GC 標記-清除算法。 頻度低,回收慢。


GC複製算法

基本思路:

  1. 複製活動對象從From空間到To空間;複製活動對象也包括該活動對象引用所抵達的全部對象,是遞歸的。

  2. 吞吐量優秀(只需複製活動對象),堆利用率比較低。高速分配、無碎片化。

局部優化:

  • 迭代複製:避免棧溢出

  • 近似深度搜索複製

  • 多空間複製


GC標記-清除算法

就像插入排序,優勢是:簡單並且適合小數據量。

基本流程:

  1. 標記階段: 從根引用出發,將全部可抵達的對象打上標記;

  2. 清理階段: 遍歷堆,將沒有標記的對象清理回收。

耗費時間與堆大小成正比,堆使用效率最高。

就地回收 -> 碎片化問題 -> 分配效率問題

局部優化:

  • 多空閒鏈表: 不一樣分塊,方便不一樣大小的分配。空間回收時建立和更新。

  • BiBOP:將堆分爲相同大小的塊【跳過】

  • 位圖標記: 活動對象標記採用位圖技術來標記

  • 延遲清除法: 分配空間時進行清除操做,減小最大暫停時間。


現實GC

垃圾收集器

選擇垃圾收集器時,須要考慮 新生代收集器與老生代收集的配合使用。

新生代收集器

  • Serial : 單線程, stop the world ; 簡單高效,桌面應用場景下,停頓時間可控制在幾十毫秒不超過一百毫秒, Client 模式下的默認;

  • ParNew: Serial 的多線程版本,Server 模式下的首選,能夠與 CMS 收集器配合使用;

  • Parallel Scavenge: 基於複製算法,多線程; 其目標是達到好的吞吐量,即便「用戶代碼CPU時間/CPU總耗時」比值更大,吞吐量優先的收集器,適合後臺任務。具備自適應調節參數控制,適合新用戶使用。


老生代收集器

  • SerialOld: 單線程,基於 標記-清理 算法,Client 模式下的默認。若用於 Server 模式,能夠與 收集Parallel Scavenge 搭配使用,以及做爲 CMS 的預備(在併發收集發生 Concurrent Mode Failure 時使用)。

  • ParallalOld: 多線程,基於 標記-清理 算法,Server 模式, 能夠與 Parallel Scavenge 配合使用,吞吐量及CPU時間敏感型應用。

  • CMS : 併發,基於 標記-清理 算法,目標是獲取最短停頓時間,能夠與用戶線程同時工做;

  • G1:併發,基於 標記-整理 算法,可預測的停頓時間模型,「隱藏級收集器」。

摘錄自《深刻理解Java虛擬機》(周志明著)


運行參數

堆內存

  • -Xms 初始堆大小 ; -Xmx 初始堆大小最大值;

  • -Xmn 新生代(包括Eden和兩個Surivior)的堆大小 ;-XX:SurvivorRation=N來調整Eden Space及SurvivorSpace的大小,表示 Eden 與一個 SurvivorSpace 的比值是 N:1

  • -XX:NewRatio=N : 新生代與老年代的比值 1: N , 年輕代的空間佔 1/(N+1)

  • -Xss : 每一個線程的棧大小


收集器

  • -XX:+UseParNewGC : 使用 ParNew 收集器 ; -XX:+UseParallelOldGC 使用 ParallalOld 收集器;

  • -XX:MaxGCPauseMillis=N : 可接受最大停頓時間,毫秒數 ;-XX:GCTimeRatio=N : 可接受GC時間佔比(目標吞吐量), 1 / (N+1), 吞吐量=1-1/(1+N)

  • -XX:+UseConcMarkSweepGC : 使用 CMS 收集器 ; -XX:+UseCMSCompactAtFullCollection :FullGC 後對老年代進行壓縮整理,減小碎片化;-XX:+CMSInitiatingOccupancyFraction=80 老年代佔用內存 80% 以上時,觸發 FullGC。
  • -XX:+UseParallelGC : 並行收集器的線程數
  • -XX:+ DisableExplicitGC : 禁止RMI調用System.gc
  • -XX:PretenureSizeThreshold :大於這個設置值的大對象將直接進入老年代。
    -XX:MaxTenuringThreshold=15 :在 Eden 區出生的對象,通過第一次 MinorGC 以後仍然存活,且被 Surivior 容納,則年齡記爲 1 ; 每通過一次依然能在 Surivior 年齡增加一 ;當到達 XX:MaxTenuringThreshold 指定的值時,就會進入老年代空間。

GC事件
  • MinorGC : 大多數狀況,新生代對象直接分配在 Eden 區。 當 Eden 區沒有足夠空間分配時,將發生一次 MinorGC 。 特色是: 頻繁,回收快。

  • MajorGC / FullGC: 老年代GC,特色是:不多, 慢。 FullGC 指 MajorGC 中 stop the world 的部分,是須要儘可能避免的事件。

  • 大對象觸發的 FullGC :大對象,是指須要大量連續內存空間的java對象,例如很長的對象列表。此類對象會直接進入老年代,而老年代雖然有很大的剩餘空間,可是沒法找到足夠大的連續空間來分配給當前對象,此種狀況就會觸發JVM進行Full GC。

  • promotion failed和concurrent mode failure 觸發 FullGC : 採用 CMS 進行老年代 GC,尤爲要注意 GC 日誌中是否有 promotion failed 和 concurrent mode failure 兩種情況,當這兩種情況出現時可能會觸發 Full GC。promotion failed 是在進行 Minor GC 時,survivor space 放不下、對象只能放入老年代,而此時老年代也放不下形成的;concurrent mode failure 是在執行 CMS GC 的過程當中同時有對象要放入老年代,而此時老年代空間不足形成的(有時候「空間不足」是 CMS GC時當前的浮動垃圾過多致使暫時性的空間不足觸發Full GC)。

  • 空間分配擔保觸發 FullGC: 在進行 MinorGC 以前,虛擬機會檢查老年代連續最大可用空間是否大於新生代全部活動對象總大小。若是大於,則能夠保證 MinorGC 是安全的;若是不成立,會查看 HandlePromotionFailure 是否容許擔保失敗;若是能夠,則會檢查老年代連續最大可用空間是否大於歷次晉升到老年代的對象的平均大小,若是大於,則會進行有風險的 MinorGC ;不然,會進行一次 FullGC 。

  • System.gc()方法的調用來建議觸發 FullGC 。


GC日誌
  • GC (Allocaion Failure) : 當在新生代中沒有足夠空間分配對象時會發生 Allocaion Failure,觸發Young GC。 [ParNew: 1887487K->209664K(1887488K), 0.0814271 secs]表示 新生代 ParNew 收集器,GC 前該內存區域使用了 1887487K ,GC 後該內存區域使用了 209664K ,回收了 1677823K , 總容量 1887488K ; 該內存區域 GC 耗時 0.0814271 secs 。 3579779K->2056421K(3984640K), 0.0822273 secs 表示 堆區 GC 前 3579779K, GC 後 2056421K ,回收了 1523358K,GC 耗時 0.0822273 secs 。

  • concurrent mode failure : 一個是在老年代被用完以前不能完成對非活動對象的回收;一個是當新空間分配請求在老年代的剩餘空間中不能獲得知足。


小結

線上的服務運行,會遇到各類的突發狀況。好比大流量導出,多個大數據對象的訂單導出,對於通用的處理措施來講,經常會觸發一些潛在的問題,亦能引導人收穫一些新知。僅僅是知足功能服務要求是遠遠不夠的。

然而, 反過來思考,爲何老是要到問題發生的時候,纔會意識到和去處理呢 ? 是否能夠預知和處理問題呢 ? 這涉及到參悟本質: 事物的原理及關聯。冥冥之中,因果早已註定,只是不少狀況沒有達到臨界閾值,沒有達到誘發條件。

深刻理解原理,審視現有的架構設計和實現,預知和解決問題,纔是更上一層樓的方式。

參考資料

相關文章
相關標籤/搜索