Java中9種常見的CMS GC問題分析與解決

OkidoGreen發表於2020-11-20

1. 寫在前面

| 本文主要針對 Hotspot VM 中“CMS + ParNew”組合的一些使用場景進行總結。重點通過部分原始碼對根因進行分析以及對排查方法進行總結,排查過程會省略較多,另外本文專業術語較多,有一定的閱讀門檻,如未介紹清楚,還請自行查閱相關材料。

| 總字數 2 萬左右(不包含程式碼片段),整體閱讀時間約 30min ,文章較長,可以選擇你感興趣的場景進行研究。

1.1 引言

自 Sun 釋出 Java 語言以來,開始使用 GC 技術來進行記憶體自動管理,避免了手動管理帶來的懸掛指標(Dangling Pointer)問題,很大程度上提升了開發效率,從此 GC 技術也一舉成名。GC 有著非常悠久的歷史,1960 年有著“Lisp 之父”和“人工智慧之父”之稱的 John McCarthy 就在論文中釋出了 GC 演算法,60 年以來, GC 技術的發展也突飛猛進,但不管是多麼前沿的收集器也都是基於三種基本演算法的組合或應用,也就是說 GC 要解決的根本問題這麼多年一直都沒有變過。筆者認為,在不太遠的將來, GC 技術依然不會過時,比起日新月異的新技術,GC 這門古典技術更值得我們學習。

目前,網際網路上 Java 的 GC 資料要麼是主要講解理論,要麼就是針對單一場景的 GC 問題進行了剖析,對整個體系總結的資料少之又少。前車之鑑,後事之師,美團的幾位工程師蒐集了內部各種 GC 問題的分析文章,並結合個人的理解做了一些總結,希望能起到“拋磚引玉”的作用,文中若有錯誤之處,還請大家不吝指正。

GC 問題處理能力能不能系統性掌握?一些影響因素都是互為因果的問題該怎麼分析?比如一個服務 RT 突然上漲,有 GC 耗時增大、執行緒 Block 增多、慢查詢增多、CPU 負載高四個表象,到底哪個是誘因?如何判斷 GC 有沒有問題?使用 CMS 有哪些常見問題?如何判斷根因是什麼?如何解決或避免這些問題?閱讀完本文,相信你將會對 CMS GC 的問題處理有一個系統性的認知,更能遊刃有餘地解決這些問題,下面就讓我們開始吧!

1.2 概覽

想要系統性地掌握 GC 問題處理,筆者這裡給出一個學習路徑,整體文章的框架也是按照這個結構展開,主要分四大步。

  • 建立知識體系: 從 JVM 的記憶體結構到垃圾收集的演算法和收集器,學習 GC 的基礎知識,掌握一些常用的 GC 問題分析工具。

  • 確定評價指標: 瞭解基本 GC 的評價方法,摸清如何設定獨立系統的指標,以及在業務場景中判斷 GC 是否存在問題的手段。

  • 場景調優實踐: 運用掌握的知識和系統評價指標,分析與解決九種 CMS 中常見 GC 問題場景。

  • 總結優化經驗: 對整體過程做總結並提出筆者的幾點建議,同時將總結到的經驗完善到知識體系之中。

2. GC 基礎

在正式開始前,先做些簡要鋪墊,介紹下 JVM 記憶體劃分、收集演算法、收集器等常用概念介紹,基礎比較好的同學可以直接跳過這部分。

2.1 基礎概念

  • GC: GC 本身有三種語義,下文需要根據具體場景帶入不同的語義:

    • Garbage Collection:垃圾收集技術,名詞。

    • Garbage Collector:垃圾收集器,名詞。

    • Garbage Collecting:垃圾收集動作,動詞。

  • Mutator: 生產垃圾的角色,也就是我們的應用程式,垃圾製造者,通過 Allocator 進行 allocate 和 free。

  • TLAB: Thread Local Allocation Buffer 的簡寫,基於 CAS 的獨享執行緒(Mutator Threads)可以優先將物件分配在 Eden 中的一塊記憶體,因為是 Java 執行緒獨享的記憶體區沒有鎖競爭,所以分配速度更快,每個 TLAB 都是一個執行緒獨享的。

  • Card Table: 中文翻譯為卡表,主要是用來標記卡頁的狀態,每個卡表項對應一個卡頁。當卡頁中一個物件引用有寫操作時,寫屏障將會標記物件所在的卡表狀態改為 dirty,卡表的本質是用來解決跨代引用的問題。具體怎麼解決的可以參考 StackOverflow 上的這個問題 how-actually-card-table-and-writer-barrier-works,或者研讀一下 cardTableRS.app 中的原始碼。

2.2 JVM 記憶體劃分

從 JCP(Java Community Process)的官網中可以看到,目前 Java 版本最新已經到了 Java 16,未來的 Java 17 以及現在的 Java 11 和 Java 8 是 LTS 版本,JVM 規範也在隨著迭代在變更,由於本文主要討論 CMS,此處還是放 Java 8 的記憶體結構。

GC 主要工作在 Heap 區和 MetaSpace 區(上圖藍色部分),在 Direct Memory 中,如果使用的是 DirectByteBuffer,那麼在分配記憶體不夠時則是 GC 通過 Cleaner#clean 間接管理。

任何自動記憶體管理系統都會面臨的步驟:為新物件分配空間,然後收集垃圾物件空間,下面我們就展開介紹一下這些基礎知識。

2.3 分配物件

Java 中物件地址操作主要使用 Unsafe 呼叫了 C 的 allocate 和 free 兩個方法,分配方法有兩種:

  • 空閒連結串列(free list): 通過額外的儲存記錄空閒的地址,將隨機 IO 變為順序 IO,但帶來了額外的空間消耗。

  • 碰撞指標(bump pointer): 通過一個指標作為分界點,需要分配記憶體時,僅需把指標往空閒的一端移動與物件大小相等的距離,分配效率較高,但使用場景有限。

2.4 收集物件

2.4.1 識別垃圾

  • 引用計數法(Reference Counting): 對每個物件的引用進行計數,每當有一個地方引用它時計數器 +1、引用失效則 -1,引用的計數放到物件頭中,大於 0 的物件被認為是存活物件。雖然迴圈引用的問題可通過 Recycler 演算法解決,但是在多執行緒環境下,引用計數變更也要進行昂貴的同步操作,效能較低,早期的程式語言會採用此演算法。

  • 可達性分析,又稱引用鏈法(Tracing GC): 從 GC Root 開始進行物件搜尋,可以被搜尋到的物件即為可達物件,此時還不足以判斷物件是否存活/死亡,需要經過多次標記才能更加準確地確定,整個連通圖之外的物件便可以作為垃圾被回收掉。目前 Java 中主流的虛擬機器均採用此演算法。

備註:引用計數法是可以處理迴圈引用問題的,下次面試時不要再這麼說啦~ ~

2.4.2 收集演算法

自從有自動記憶體管理出現之時就有的一些收集演算法,不同的收集器也是在不同場景下進行組合。

  • Mark-Sweep(標記-清除): 回收過程主要分為兩個階段,第一階段為追蹤(Tracing)階段,即從 GC Root 開始遍歷物件圖,並標記(Mark)所遇到的每個物件,第二階段為清除(Sweep)階段,即回收器檢查堆中每一個物件,並將所有未被標記的物件進行回收,整個過程不會發生物件移動。整個演算法在不同的實現中會使用三色抽象(Tricolour Abstraction)、點陣圖標記(BitMap)等技術來提高演算法的效率,存活物件較多時較高效。

  • Mark-Compact (標記-整理): 這個演算法的主要目的就是解決在非移動式回收器中都會存在的碎片化問題,也分為兩個階段,第一階段與 Mark-Sweep 類似,第二階段則會對存活物件按照整理順序(Compaction Order)進行整理。主要實現有雙指標(Two-Finger)回收演算法、滑動回收(Lisp2)演算法和引線整理(Threaded Compaction)演算法等。

  • Copying(複製): 將空間分為兩個大小相同的 From 和 To 兩個半區,同一時間只會使用其中一個,每次進行回收時將一個半區的存活物件通過複製的方式轉移到另一個半區。有遞迴(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)演算法,以及解決了前兩者遞迴棧、快取行等問題的近似優先搜尋演算法。複製演算法可以通過碰撞指標的方式進行快速地分配記憶體,但是也存在著空間利用率不高的缺點,另外就是存活物件比較大時複製的成本比較高。

三種演算法在是否移動物件、空間和時間方面的一些對比,假設存活物件數量為 *L*、堆空間大小為 *H*,則:

把 mark、sweep、compaction、copying 這幾種動作的耗時放在一起看,大致有這樣的關係:

雖然 compaction 與 copying 都涉及移動物件,但取決於具體演算法,compaction 可能要先計算一次物件的目標地址,然後修正指標,最後再移動物件。copying 則可以把這幾件事情合為一體來做,所以可以快一些。另外,還需要留意 GC 帶來的開銷不能只看 Collector 的耗時,還得看 Allocator 。如果能保證記憶體沒碎片,分配就可以用 pointer bumping 方式,只需要挪一個指標就完成了分配,非常快。而如果記憶體有碎片就得用 freelist 之類的方式管理,分配速度通常會慢一些。

2.5 收集器

目前在 Hotspot VM 中主要有分代收集和分割槽收集兩大類,具體可以看下面的這個圖,不過未來會逐漸向分割槽收集發展。在美團內部,有部分業務嘗試用了 ZGC(感興趣的同學可以學習下這篇文章 新一代垃圾回收器ZGC的探索與實踐),其餘基本都停留在 CMS 和 G1 上。另外在 JDK11 後提供了一個不執行任何垃圾回收動作的回收器 Epsilon(A No-Op Garbage Collector)用作效能分析。另外一個就是 Azul 的 Zing JVM,其 C4(Concurrent Continuously Compacting Collector)收集器也在業內有一定的影響力。

備註:值得一提的是,早些年國內 GC 技術的佈道者 RednaxelaFX (江湖人稱 R 大)也曾就職於 Azul,本文的一部分材料也參考了他的一些文章。

2.5.1 分代收集器

  • ParNew: 一款多執行緒的收集器,採用複製演算法,主要工作在 Young 區,可以通過 -XX:ParallelGCThreads 引數來控制收集的執行緒數,整個過程都是 STW 的,常與 CMS 組合使用。

  • CMS: 以獲取最短回收停頓時間為目標,採用“標記-清除”演算法,分 4 大步進行垃圾收集,其中初始標記和重新標記會 STW ,多數應用於網際網路站或者 B/S 系統的伺服器端上,JDK9 被標記棄用,JDK14 被刪除,詳情可見 JEP 363

2.5.2 分割槽收集器

  • G1: 一種伺服器端的垃圾收集器,應用在多處理器和大容量記憶體環境中,在實現高吞吐量的同時,儘可能地滿足垃圾收集暫停時間的要求。

  • ZGC: JDK11 中推出的一款低延遲垃圾回收器,適用於大記憶體低延遲服務的記憶體管理和回收,SPECjbb 2015 基準測試,在 128G 的大堆下,最大停頓時間才 1.68 ms,停頓時間遠勝於 G1 和 CMS。

  • Shenandoah: 由 Red Hat 的一個團隊負責開發,與 G1 類似,基於 Region 設計的垃圾收集器,但不需要 Remember Set 或者 Card Table 來記錄跨 Region 引用,停頓時間和堆的大小沒有任何關係。停頓時間與 ZGC 接近,下圖為與 CMS 和 G1 等收集器的 benchmark。

2.5.3 常用收集器

目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要記憶體結構如下:

2.5.4 其他收集器

以上僅列出常見收集器,除此之外還有很多,如 Metronome、Stopless、Staccato、Chicken、Clover 等實時回收器,Sapphire、Compressor、Pauseless 等併發複製/整理回收器,Doligez-Leroy-Conthier 等標記整理回收器,由於篇幅原因,不在此一一介紹。

2.6 常用工具

工欲善其事,必先利其器,此處列出一些筆者常用的工具,具體情況大家可以自由選擇,本文的問題都是使用這些工具來定位和分析的。

2.6.1 命令列終端

  • 標準終端類:jps、jinfo、jstat、jstack、jmap
  • 功能整合類:jcmd、vjtools、arthas、greys

2.6.2 視覺化介面

  • 簡易:JConsole、JVisualvm、HA、GCHisto、GCViewer
  • 進階:MAT、JProfiler

命令列推薦 arthas ,視覺化介面推薦 JProfiler,此外還有一些線上的平臺 gceasyheapherofastthread ,美團內部的 Scalpel(一款自研的 JVM 問題診斷工具,暫時未開源)也比較好用。

3. GC 問題判斷

在做 GC 問題排查和優化之前,我們需要先來明確下到底是不是 GC 直接導致的問題,或者應用程式碼導致的 GC 異常,最終出現問題。

3.1 判斷 GC 有沒有問題?

3.1.1 設定評價標準

評判 GC 的兩個核心指標:

  • 延遲(Latency): 也可以理解為最大停頓時間,即垃圾收集過程中一次 STW 的最長時間,越短越好,一定程度上可以接受頻次的增大,GC 技術的主要發展方向。

  • 吞吐量(Throughput): 應用系統的生命週期內,由於 GC 執行緒會佔用 Mutator 當前可用的 CPU 時鐘週期,吞吐量即為 Mutator 有效花費的時間佔系統總執行時間的百分比,例如系統執行了 100 min,GC 耗時 1 min,則系統吞吐量為 99%,吞吐量優先的收集器可以接受較長的停頓。

目前各大網際網路公司的系統基本都更追求低延時,避免一次 GC 停頓的時間過長對使用者體驗造成損失,衡量指標需要結合一下應用服務的 SLA,主要如下兩點來判斷:

簡而言之,即為一次停頓的時間不超過應用服務的 TP9999,GC 的吞吐量不小於 99.99%。舉個例子,假設某個服務 A 的 TP9999 為 80 ms,平均 GC 停頓為 30 ms,那麼該服務的最大停頓時間最好不要超過 80 ms,GC 頻次控制在 5 min 以上一次。如果滿足不了,那就需要調優或者通過更多資源來進行並聯冗餘。(大家可以先停下來,看看監控平臺上面的 gc.meantime 分鐘級別指標,如果超過了 6 ms 那單機 GC 吞吐量就達不到 4 個 9 了。)

備註:除了這兩個指標之外還有 Footprint(資源量大小測量)、反應速度等指標,網際網路這種實時系統追求低延遲,而很多嵌入式系統則追求 Footprint。

3.1.2 讀懂 GC Cause

拿到 GC 日誌,我們就可以簡單分析 GC 情況了,通過一些工具,我們可以比較直觀地看到 Cause 的分佈情況,如下圖就是使用 gceasy 繪製的圖表:

如上圖所示,我們很清晰的就能知道是什麼原因引起的 GC,以及每次的時間花費情況,但是要分析 GC 的問題,先要讀懂 GC Cause,即 JVM 什麼樣的條件下選擇進行 GC 操作,具體 Cause 的分類可以看一下 Hotspot 原始碼:src/share/vm/gc/shared/gcCause.hpp 和 src/share/vm/gc/shared/gcCause.cpp 中。

const char* GCCause::to_string(GCCause::Cause cause) {
  switch (cause) {
    case _java_lang_system_gc:
      return "System.gc()";

    case _full_gc_alot:
      return "FullGCAlot";

    case _scavenge_alot:
      return "ScavengeAlot";

    case _allocation_profiler:
      return "Allocation Profiler";

    case _jvmti_force_gc:
      return "JvmtiEnv ForceGarbageCollection";

    case _gc_locker:
      return "GCLocker Initiated GC";

    case _heap_inspection:
      return "Heap Inspection Initiated GC";

    case _heap_dump:
      return "Heap Dump Initiated GC";

    case _wb_young_gc:
      return "WhiteBox Initiated Young GC";

    case _wb_conc_mark:
      return "WhiteBox Initiated Concurrent Mark";

    case _wb_full_gc:
      return "WhiteBox Initiated Full GC";

    case _no_gc:
      return "No GC";

    case _allocation_failure:
      return "Allocation Failure";

    case _tenured_generation_full:
      return "Tenured Generation Full";

    case _metadata_GC_threshold:
      return "Metadata GC Threshold";

    case _metadata_GC_clear_soft_refs:
      return "Metadata GC Clear Soft References";

    case _cms_generation_full:
      return "CMS Generation Full";

    case _cms_initial_mark:
      return "CMS Initial Mark";

    case _cms_final_remark:
      return "CMS Final Remark";

    case _cms_concurrent_mark:
      return "CMS Concurrent Mark";

    case _old_generation_expanded_on_last_scavenge:
      return "Old Generation Expanded On Last Scavenge";

    case _old_generation_too_full_to_scavenge:
      return "Old Generation Too Full To Scavenge";

    case _adaptive_size_policy:
      return "Ergonomics";

    case _g1_inc_collection_pause:
      return "G1 Evacuation Pause";

    case _g1_humongous_allocation:
      return "G1 Humongous Allocation";

    case _dcmd_gc_run:
      return "Diagnostic Command";

    case _last_gc_cause:
      return "ILLEGAL VALUE - last gc cause - ILLEGAL VALUE";

    default:
      return "unknown GCCause";
  }
  ShouldNotReachHere();
}

重點需要關注的幾個GC Cause:

  • System.gc(): 手動觸發GC操作。

  • CMS: CMS GC 在執行過程中的一些動作,重點關注 CMS Initial Mark 和 CMS Final Remark 兩個 STW 階段。

  • Promotion Failure: Old 區沒有足夠的空間分配給 Young 區晉升的物件(即使總可用記憶體足夠大)。

  • Concurrent Mode Failure: CMS GC 執行期間,Old 區預留的空間不足以分配給新的物件,此時收集器會發生退化,嚴重影響 GC 效能,下面的一個案例即為這種場景。

  • GCLocker Initiated GC: 如果執行緒執行在 JNI 臨界區時,剛好需要進行 GC,此時 GC Locker 將會阻止 GC 的發生,同時阻止其他執行緒進入 JNI 臨界區,直到最後一個執行緒退出臨界區時觸發一次 GC。

什麼時機使用這些 Cause 觸發回收,大家可以看一下 CMS 的程式碼,這裡就不討論了,具體在 /src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp 中。

bool CMSCollector::shouldConcurrentCollect() {
  LogTarget(Trace, gc) log;

  if (_full_gc_requested) {
    log.print("CMSCollector: collect because of explicit  gc request (or GCLocker)");
    return true;
  }

  FreelistLocker x(this);
  // ------------------------------------------------------------------
  // Print out lots of information which affects the initiation of
  // a collection.
  if (log.is_enabled() && stats().valid()) {
    log.print("CMSCollector shouldConcurrentCollect: ");

    LogStream out(log);
    stats().print_on(&out);

    log.print("time_until_cms_gen_full %3.7f", stats().time_until_cms_gen_full());
    log.print("free=" SIZE_FORMAT, _cmsGen->free());
    log.print("contiguous_available=" SIZE_FORMAT, _cmsGen->contiguous_available());
    log.print("promotion_rate=%g", stats().promotion_rate());
    log.print("cms_allocation_rate=%g", stats().cms_allocation_rate());
    log.print("occupancy=%3.7f", _cmsGen->occupancy());
    log.print("initiatingOccupancy=%3.7f", _cmsGen->initiating_occupancy());
    log.print("cms_time_since_begin=%3.7f", stats().cms_time_since_begin());
    log.print("cms_time_since_end=%3.7f", stats().cms_time_since_end());
    log.print("metadata initialized %d", MetaspaceGC::should_concurrent_collect());
  }
  // ------------------------------------------------------------------

  // If the estimated time to complete a cms collection (cms_duration())
  // is less than the estimated time remaining until the cms generation
  // is full, start a collection.
  if (!UseCMSInitiatingOccupancyOnly) {
    if (stats().valid()) {
      if (stats().time_until_cms_start() == 0.0) {
        return true;
      }
    } else {
   
      if (_cmsGen->occupancy() >= _bootstrap_occupancy) {
        log.print(" CMSCollector: collect for bootstrapping statistics: occupancy = %f, boot occupancy = %f",
                  _cmsGen->occupancy(), _bootstrap_occupancy);
        return true;
      }
    }
  }
  if (_cmsGen->should_concurrent_collect()) {
    log.print("CMS old gen initiated");
    return true;
  }

  CMSHeap* heap = CMSHeap::heap();
  if (heap->incremental_collection_will_fail(true /* consult_young */)) {
    log.print("CMSCollector: collect because incremental collection will fail ");
    return true;
  }

  if (MetaspaceGC::should_concurrent_collect()) {
    log.print("CMSCollector: collect for metadata allocation ");
    return true;
  }

  // CMSTriggerInterval starts a CMS cycle if enough time has passed.
  if (CMSTriggerInterval >= 0) {
    if (CMSTriggerInterval == 0) {
      // Trigger always
      return true;
    }

    // Check the CMS time since begin (we do not check the stats validity
    // as we want to be able to trigger the first CMS cycle as well)
    if (stats().cms_time_since_begin() >= (CMSTriggerInterval / ((double) MILLIUNITS))) {
      if (stats().valid()) {
        log.print("CMSCollector: collect because of trigger interval (time since last begin %3.7f secs)",
                  stats().cms_time_since_begin());
      } else {
        log.print("CMSCollector: collect because of trigger interval (first collection)");
      }
      return true;
    }
  }

  return false;
}

3.2 判斷是不是 GC 引發的問題?

到底是結果(現象)還是原因,在一次 GC 問題處理的過程中,如何判斷是 GC 導致的故障,還是系統本身引發 GC 問題。這裡繼續拿在本文開頭提到的一個 Case:“GC 耗時增大、執行緒 Block 增多、慢查詢增多、CPU 負載高等四個表象,如何判斷哪個是根因?”,筆者這裡根據自己的經驗大致整理了四種判斷方法供參考:

  • 時序分析: 先發生的事件是根因的概率更大,通過監控手段分析各個指標的異常時間點,還原事件時間線,如先觀察到 CPU 負載高(要有足夠的時間 Gap),那麼整個問題影響鏈就可能是:CPU 負載高 -> 慢查詢增多 -> GC 耗時增大 -> 執行緒Block增多 -> RT 上漲。

  • 概率分析: 使用統計概率學,結合歷史問題的經驗進行推斷,由近到遠按型別分析,如過往慢查的問題比較多,那麼整個問題影響鏈就可能是:慢查詢增多 -> GC 耗時增大 -> CPU 負載高 -> 執行緒 Block 增多 -> RT上漲。

  • 實驗分析: 通過故障演練等方式對問題現場進行模擬,觸發其中部分條件(一個或多個),觀察是否會發生問題,如只觸發執行緒 Block 就會發生問題,那麼整個問題影響鏈就可能是:執行緒Block增多 -> CPU 負載高 -> 慢查詢增多 -> GC 耗時增大 -> RT 上漲。

  • 反證分析: 對其中某一表象進行反證分析,即判斷表象的發不發生跟結果是否有相關性,例如我們從整個叢集的角度觀察到某些節點慢查和 CPU 都正常,但也出了問題,那麼整個問題影響鏈就可能是:GC 耗時增大 -> 執行緒 Block 增多 -> RT 上漲。

不同的根因,後續的分析方法是完全不同的。如果是 CPU 負載高那可能需要用火焰圖看下熱點、如果是慢查詢增多那可能需要看下 DB 情況、如果是執行緒 Block 引起那可能需要看下鎖競爭的情況,最後如果各個表象證明都沒有問題,那可能 GC 確實存在問題,可以繼續分析 GC 問題了。

3.3 問題分類導讀

3.3.1 Mutator 型別

Mutator 的型別根據物件存活時間比例圖來看主要分為兩種,在弱分代假說中也提到類似的說法,如下圖所示 “Survival Time” 表示物件存活時間,“Rate” 表示物件分配比例:

  • IO 互動型: 網際網路上目前大部分的服務都屬於該型別,例如分散式 RPC、MQ、HTTP 閘道器服務等,對記憶體要求並不大,大部分物件在 TP9999 的時間內都會死亡, Young 區越大越好。

  • MEM 計算型: 主要是分散式資料計算 Hadoop,分散式儲存 HBase、Cassandra,自建的分散式快取等,對記憶體要求高,物件存活時間長,Old 區越大越好。

當然,除了二者之外還有介於兩者之間的場景,本篇文章主要討論第一種情況。物件 Survival Time 分佈圖,對我們設定 GC 引數有著非常重要的指導意義,如下圖就可以簡單推算分代的邊界。

3.3.2 GC 問題分類

筆者選取了九種不同型別的 GC 問題,覆蓋了大部分場景,如果有更好的場景,歡迎在評論區給出。

  • Unexpected GC: 意外發生的 GC,實際上不需要發生,我們可以通過一些手段去避免。

    • Space Shock: 空間震盪問題,參見“場景一:動態擴容引起的空間震盪”。
    • Explicit GC: 顯示執行 GC 問題,參見“場景二:顯式 GC 的去與留”。
  • Partial GC: 部分收集操作的 GC,只對某些分代/分割槽進行回收。

    • Young GC: 分代收集裡面的 Young 區收集動作,也可以叫做 Minor GC。

      • ParNew: Young GC 頻繁,參見“場景四:過早晉升”。
    • Old GC: 分代收集裡面的 Old 區收集動作,也可以叫做 Major GC,有些也會叫做 Full GC,但其實這種叫法是不規範的,在 CMS 發生 Foreground GC 時才是 Full GC,CMSScavengeBeforeRemark 引數也只是在 Remark 前觸發一次Young GC。

      • CMS: Old GC 頻繁,參見“場景五:CMS Old GC 頻繁”。
      • CMS: Old GC 不頻繁但單次耗時大,參見“場景六:單次 CMS Old GC 耗時長”。
  • Full GC: 全量收集的 GC,對整個堆進行回收,STW 時間會比較長,一旦發生,影響較大,也可以叫做 Major GC,參見“場景七:記憶體碎片&收集器退化”。

  • MetaSpace: 元空間回收引發問題,參見“場景三:MetaSpace 區 OOM”。

  • Direct Memory: 直接記憶體(也可以稱作為堆外記憶體)回收引發問題,參見“場景八:堆外記憶體 OOM”。

  • JNI: 本地 Native 方法引發問題,參見“場景九:JNI 引發的 GC 問題”。

3.3.3 排查難度

一個問題的解決難度跟它的常見程度成反比,大部分我們都可以通過各種搜尋引擎找到類似的問題,然後用同樣的手段嘗試去解決。當一個問題在各種網站上都找不到相似的問題時,那麼可能會有兩種情況,一種這不是一個問題,另一種就是遇到一個隱藏比較深的問題,遇到這種問題可能就要深入到原始碼級別去除錯了。以下 GC 問題場景,排查難度從上到下依次遞增。

4. 常見場景分析與解決

4.1 場景一:動態擴容引起的空間震盪

4.1.1 現象

服務剛剛啟動時 GC 次數較多,最大空間剩餘很多但是依然發生 GC,這種情況我們可以通過觀察 GC 日誌或者通過監控工具來觀察堆的空間變化情況即可。GC Cause 一般為 Allocation Failure,且在 GC 日誌中會觀察到經歷一次 GC ,堆內各個空間的大小會被調整,如下圖所示:

4.1.2 原因

在 JVM 的引數中 -Xms 和 -Xmx 設定的不一致,在初始化時只會初始 -Xms 大小的空間儲存資訊,每當空間不夠用時再向作業系統申請,這樣的話必然要進行一次 GC。具體是通過 ConcurrentMarkSweepGeneration::compute_new_size() 方法計算新的空間大小:

void ConcurrentMarkSweepGeneration::compute_new_size() {
  assert_locked_or_safepoint(Heap_lock);

  // If incremental collection failed, we just want to expand
  // to the limit.
  if (incremental_collection_failed()) {
    clear_incremental_collection_failed();
    grow_to_reserved();
    return;
  }

  // The heap has been compacted but not reset yet.
  // Any metric such as free() or used() will be incorrect.

  CardGeneration::compute_new_size();

  // Reset again after a possible resizing
  if (did_compact()) {
    cmsSpace()->reset_after_compaction();
  }
}

另外,如果空間剩餘很多時也會進行縮容操作,JVM 通過 -XX:MinHeapFreeRatio 和 -XX:MaxHeapFreeRatio 來控制擴容和縮容的比例,調節這兩個值也可以控制伸縮的時機,例如擴容便是使用 GenCollectedHeap::expand_heap_and_allocate() 來完成的,程式碼如下:

HeapWord* GenCollectedHeap::expand_heap_and_allocate(size_t size, bool   is_tlab) {
  HeapWord* result = NULL;
  if (_old_gen->should_allocate(size, is_tlab)) {
    result = _old_gen->expand_and_allocate(size, is_tlab);
  }
  if (result == NULL) {
    if (_young_gen->should_allocate(size, is_tlab)) {
      result = _young_gen->expand_and_allocate(size, is_tlab);
    }
  }
  assert(result == NULL || is_in_reserved(result), "result not in heap");
  return result;
}

整個伸縮的模型理解可以看這個圖,當 committed 的空間大小超過了低水位/高水位的大小,capacity 也會隨之調整:

4.1.3 策略

定位:觀察 CMS GC 觸發時間點 Old/MetaSpace 區的 committed 佔比是不是一個固定的值,或者像上文提到的觀察總的記憶體使用率也可以。

解決:儘量將成對出現的空間大小配置引數設定成固定的,如 -Xms 和 -Xmx-XX:MaxNewSize 和 -XX:NewSize-XX:MetaSpaceSize 和 -XX:MaxMetaSpaceSize 等。

4.1.4 小結

一般來說,我們需要保證 Java 虛擬機器的堆是穩定的,確保 -Xms 和 -Xmx 設定的是一個值(即初始值和最大值一致),獲得一個穩定的堆,同理在 MetaSpace 區也有類似的問題。不過在不追求停頓時間的情況下震盪的空間也是有利的,可以動態地伸縮以節省空間,例如作為富客戶端的 Java 應用。

這個問題雖然初級,但是發生的概率還真不小,尤其是在一些規範不太健全的情況下。

4.2 場景二:顯式 GC 的去與留

4.2.1 現象

除了擴容縮容會觸發 CMS GC 之外,還有 Old 區達到回收閾值、MetaSpace 空間不足、Young 區晉升失敗、大物件擔保失敗等幾種觸發條件,如果這些情況都沒有發生卻觸發了 GC ?這種情況有可能是程式碼中手動呼叫了 System.gc 方法,此時可以找到 GC 日誌中的 GC Cause 確認下。那麼這種 GC 到底有沒有問題,翻看網上的一些資料,有人說可以新增 -XX:+DisableExplicitGC 引數來避免這種 GC,也有人說不能加這個引數,加了就會影響 Native Memory 的回收。先說結論,筆者這裡建議保留 System.gc,那為什麼要保留?我們一起來分析下。

4.2.2 原因

找到 System.gc 在 Hotspot 中的原始碼,可以發現增加 -XX:+DisableExplicitGC 引數後,這個方法變成了一個空方法,如果沒有加的話便會呼叫 Universe::heap()::collect 方法,繼續跟進到這個方法中,發現 System.gc 會引發一次 STW 的 Full GC,對整個堆做收集。

JVM_ENTRY_NO_ENV(void, JVM_GC(void))
  JVMWrapper("JVM_GC");
  if (!DisableExplicitGC) {
    Universe::heap()->collect(GCCause::_java_lang_system_gc);
  }
JVM_END
void GenCollectedHeap::collect(GCCause::Cause cause) {
  if (cause == GCCause::_wb_young_gc) {
    // Young collection for the WhiteBox API.
    collect(cause, YoungGen);
  } else {
#ifdef ASSERT
  if (cause == GCCause::_scavenge_alot) {
    // Young collection only.
    collect(cause, YoungGen);
  } else {
    // Stop-the-world full collection.
    collect(cause, OldGen);
  }
#else
    // Stop-the-world full collection.
    collect(cause, OldGen);
#endif
  }
}

保留 System.gc

此處補充一個知識點,CMS GC 共分為 Background 和 Foreground 兩種模式,前者就是我們常規理解中的併發收集,可以不影響正常的業務執行緒執行,但 Foreground Collector 卻有很大的差異,他會進行一次壓縮式 GC。此壓縮式 GC 使用的是跟 Serial Old GC 一樣的 Lisp2 演算法,其使用 Mark-Compact 來做 Full GC,一般稱之為 MSC(Mark-Sweep-Compact),它收集的範圍是 Java 堆的 Young 區和 Old 區以及 MetaSpace。由上面的演算法章節中我們知道 compact 的代價是巨大的,那麼使用 Foreground Collector 時將會帶來非常長的 STW。如果在應用程式中 System.gc 被頻繁呼叫,那就非常危險了。

去掉 System.gc

如果禁用掉的話就會帶來另外一個記憶體洩漏問題,此時就需要說一下 DirectByteBuffer,它有著零拷貝等特點,被 Netty 等各種 NIO 框架使用,會使用到堆外記憶體。堆記憶體由 JVM 自己管理,堆外記憶體必須要手動釋放,DirectByteBuffer 沒有 Finalizer,它的 Native Memory 的清理工作是通過 sun.misc.Cleaner 自動完成的,是一種基於 PhantomReference 的清理工具,比普通的 Finalizer 輕量些。

為 DirectByteBuffer 分配空間過程中會顯式呼叫 System.gc ,希望通過 Full GC 來強迫已經無用的 DirectByteBuffer 物件釋放掉它們關聯的 Native Memory,下面為程式碼實現:

// These methods should be called whenever direct memory is allocated or
// freed.  They allow the user to control the amount of direct memory
// which a process may access.  All sizes are specified in bytes.
static void reserveMemory(long size) {

    synchronized (Bits.class) {
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }
        if (size <= maxMemory - reservedMemory) {
            reservedMemory += size;
            return;
        }
    }

    System.gc();
    try {
        Thread.sleep(100);
    } catch (InterruptedException x) {
        // Restore interrupt status
        Thread.currentThread().interrupt();
    }
    synchronized (Bits.class) {
        if (reservedMemory + size > maxMemory)
            throw new OutOfMemoryError("Direct buffer memory");
        reservedMemory += size;
    }

}

HotSpot VM 只會在 Old GC 的時候才會對 Old 中的物件做 Reference Processing,而在 Young GC 時只會對 Young 裡的物件做 Reference Processing。Young 中的 DirectByteBuffer 物件會在 Young GC 時被處理,也就是說,做 CMS GC 的話會對 Old 做 Reference Processing,進而能觸發 Cleaner 對已死的 DirectByteBuffer 物件做清理工作。但如果很長一段時間裡沒做過 GC 或者只做了 Young GC 的話則不會在 Old 觸發 Cleaner 的工作,那麼就可能讓本來已經死亡,但已經晉升到 Old 的 DirectByteBuffer 關聯的 Native Memory 得不到及時釋放。這幾個實現特徵使得依賴於 System.gc 觸發 GC 來保證 DirectByteMemory 的清理工作能及時完成。如果開啟了 -XX:+DisableExplicitGC,清理工作就可能得不到及時完成,於是就有發生 Direct Memory 的 OOM。

4.2.3 策略

通過上面的分析看到,無論是保留還是去掉都會有一定的風險點,不過目前網際網路中的 RPC 通訊會大量使用 NIO,所以筆者在這裡建議保留。此外 JVM 還提供了 -XX:+ExplicitGCInvokesConcurrent 和 -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 引數來將 System.gc 的觸發型別從 Foreground 改為 Background,同時 Background 也會做 Reference Processing,這樣的話就能大幅降低了 STW 開銷,同時也不會發生 NIO Direct Memory OOM。

4.2.4 小結

不止 CMS,在 G1 或 ZGC中開啟 ExplicitGCInvokesConcurrent 模式,都會採用高效能的併發收集方式進行收集,不過還是建議在程式碼規範方面也要做好約束,規範好 System.gc 的使用。

P.S. HotSpot 對 System.gc 有特別處理,最主要的地方體現在一次 System.gc 是否與普通 GC 一樣會觸發 GC 的統計/閾值資料的更新,HotSpot 裡的許多 GC 演算法都帶有自適應的功能,會根據先前收集的效率來決定接下來的 GC 中使用的引數,但 System.gc 預設不更新這些統計資料,避免使用者強行 GC 對這些自適應功能的干擾(可以參考 -XX:+UseAdaptiveSizePolicyWithSystemGC 引數,預設是 false)。

4.3 場景三:MetaSpace 區 OOM

4.3.1 現象

JVM 在啟動後或者某個時間點開始,MetaSpace 的已使用大小在持續增長,同時每次 GC 也無法釋放,調大 MetaSpace 空間也無法徹底解決

4.3.2 原因

在討論為什麼會 OOM 之前,我們先來看一下這個區裡面會存什麼資料,Java7 之前字串常量池被放到了 Perm 區,所有被 intern 的 String 都會被存在這裡,由於 String.intern 是不受控的,所以 -XX:MaxPermSize 的值也不太好設定,經常會出現 java.lang.OutOfMemoryError: PermGen space 異常,所以在 Java7 之後常量池等字面量(Literal)、類靜態變數(Class Static)、符號引用(Symbols Reference)等幾項被移到 Heap 中。而 Java8 之後 PermGen 也被移除,取而代之的是 MetaSpace。

在最底層,JVM 通過 mmap 介面向作業系統申請記憶體對映,每次申請 2MB 空間,這裡是虛擬記憶體對映,不是真的就消耗了主存的 2MB,只有之後在使用的時候才會真的消耗記憶體。申請的這些記憶體放到一個連結串列中 VirtualSpaceList,作為其中的一個 Node。

在上層,MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 兩大部分組成。

  • Klass MetaSpace: 就是用來存 Klass 的,就是 Class 檔案在 JVM 裡的執行時資料結構,這部分預設放在 Compressed Class Pointer Space 中,是一塊連續的記憶體區域,緊接著 Heap。Compressed Class Pointer Space 不是必須有的,如果設定了 -XX:-UseCompressedClassPointers,或者 -Xmx 設定大於 32 G,就不會有這塊記憶體,這種情況下 Klass 都會存在 NoKlass Metaspace 裡。
  • NoKlass MetaSpace: 專門來存 Klass 相關的其他的內容,比如 Method,ConstantPool 等,可以由多塊不連續的記憶體組成。雖然叫做 NoKlass Metaspace,但是也其實可以存 Klass 的內容,上面已經提到了對應場景。

具體的定義都可以在原始碼 shared/vm/memory/metaspace.hpp 中找到:

class Metaspace : public AllStatic {

  friend class MetaspaceShared;

 public:
  enum MetadataType {
    ClassType,
    NonClassType,
    MetadataTypeCount
  };
  enum MetaspaceType {
    ZeroMetaspaceType = 0,
    StandardMetaspaceType = ZeroMetaspaceType,
    BootMetaspaceType = StandardMetaspaceType + 1,
    AnonymousMetaspaceType = BootMetaspaceType + 1,
    ReflectionMetaspaceType = AnonymousMetaspaceType + 1,
    MetaspaceTypeCount
  };

 private:

  // Align up the word size to the allocation word size
  static size_t align_word_size_up(size_t);

  // Aligned size of the metaspace.
  static size_t _compressed_class_space_size;

  static size_t compressed_class_space_size() {
    return _compressed_class_space_size;
  }

  static void set_compressed_class_space_size(size_t size) {
    _compressed_class_space_size = size;
  }

  static size_t _first_chunk_word_size;
  static size_t _first_class_chunk_word_size;

  static size_t _commit_alignment;
  static size_t _reserve_alignment;
  DEBUG_ONLY(static bool   _frozen;)

  // Virtual Space lists for both classes and other metadata
  static metaspace::VirtualSpaceList* _space_list;
  static metaspace::VirtualSpaceList* _class_space_list;

  static metaspace::ChunkManager* _chunk_manager_metadata;
  static metaspace::ChunkManager* _chunk_manager_class;

  static const MetaspaceTracer* _tracer;
}

MetaSpace 的物件為什麼無法釋放,我們看下面兩點:

  • MetaSpace 記憶體管理: 類和其後設資料的生命週期與其對應的類載入器相同,只要類的類載入器是存活的,在 Metaspace 中的類後設資料也是存活的,不能被回收。每個載入器有單獨的儲存空間,通過 ClassLoaderMetaspace 來進行管理 SpaceManager* 的指標,相互隔離的。

  • MetaSpace 彈性伸縮: 由於 MetaSpace 空間和 Heap 並不在一起,所以這塊的空間可以不用設定或者單獨設定,一般情況下避免 MetaSpace 耗盡 VM 記憶體都會設定一個 MaxMetaSpaceSize,在執行過程中,如果實際大小小於這個值,JVM 就會通過 -XX:MinMetaspaceFreeRatio 和 -XX:MaxMetaspaceFreeRatio 兩個引數動態控制整個 MetaSpace 的大小,具體使用可以看 MetaSpaceGC::compute_new_size() 方法(下方程式碼),這個方法會在 CMSCollector 和 G1CollectorHeap 等幾個收集器執行 GC 時呼叫。這個裡面會根據 used_after_gcMinMetaspaceFreeRatio 和 MaxMetaspaceFreeRatio 這三個值計算出來一個新的 _capacity_until_GC 值(水位線)。然後根據實際的 _capacity_until_GC 值使用 MetaspaceGC::inc_capacity_until_GC() 和 MetaspaceGC::dec_capacity_until_GC() 進行 expand 或 shrink,這個過程也可以參照場景一中的伸縮模型進行理解。

void MetaspaceGC::compute_new_size() {
  assert(_shrink_factor <= 100, "invalid shrink factor");
  uint current_shrink_factor = _shrink_factor;
  _shrink_factor = 0;
  const size_t used_after_gc = MetaspaceUtils::committed_bytes();
  const size_t capacity_until_GC = MetaspaceGC::capacity_until_GC();

  const double minimum_free_percentage = MinMetaspaceFreeRatio / 100.0;
  const double maximum_used_percentage = 1.0 - minimum_free_percentage;

  const double min_tmp = used_after_gc / maximum_used_percentage;
  size_t minimum_desired_capacity =
    (size_t)MIN2(min_tmp, double(max_uintx));
  // Don't shrink less than the initial generation size
  minimum_desired_capacity = MAX2(minimum_desired_capacity,
                                  MetaspaceSize);

  log_trace(gc, metaspace)("MetaspaceGC::compute_new_size: ");
  log_trace(gc, metaspace)("    minimum_free_percentage: %6.2f  maximum_used_percentage: %6.2f",
                           minimum_free_percentage, maximum_used_percentage);
  log_trace(gc, metaspace)("     used_after_gc       : %6.1fKB", used_after_gc / (double) K);


  size_t shrink_bytes = 0;
  if (capacity_until_GC < minimum_desired_capacity) {
    // If we have less capacity below the metaspace HWM, then
    // increment the HWM.
    size_t expand_bytes = minimum_desired_capacity - capacity_until_GC;
    expand_bytes = align_up(expand_bytes, Metaspace::commit_alignment());
    // Don't expand unless it's significant
    if (expand_bytes >= MinMetaspaceExpansion) {
      size_t new_capacity_until_GC = 0;
      bool succeeded = MetaspaceGC::inc_capacity_until_GC(expand_bytes, &new_capacity_until_GC);
      assert(succeeded, "Should always succesfully increment HWM when at safepoint");

      Metaspace::tracer()->report_gc_threshold(capacity_until_GC,
                                               new_capacity_until_GC,
                                               MetaspaceGCThresholdUpdater::ComputeNewSize);
      log_trace(gc, metaspace)("    expanding:  minimum_desired_capacity: %6.1fKB  expand_bytes: %6.1fKB  MinMetaspaceExpansion: %6.1fKB  new metaspace HWM:  %6.1fKB",
                               minimum_desired_capacity / (double) K,
                               expand_bytes / (double) K,
                               MinMetaspaceExpansion / (double) K,
                               new_capacity_until_GC / (double) K);
    }
    return;
  }

  // No expansion, now see if we want to shrink
  // We would never want to shrink more than this
  assert(capacity_until_GC >= minimum_desired_capacity,
         SIZE_FORMAT " >= " SIZE_FORMAT,
         capacity_until_GC, minimum_desired_capacity);
  size_t max_shrink_bytes = capacity_until_GC - minimum_desired_capacity;

  // Should shrinking be considered?
  if (MaxMetaspaceFreeRatio < 100) {
    const double maximum_free_percentage = MaxMetaspaceFreeRatio / 100.0;
    const double minimum_used_percentage = 1.0 - maximum_free_percentage;
    const double max_tmp = used_after_gc / minimum_used_percentage;
    size_t maximum_desired_capacity = (size_t)MIN2(max_tmp, double(max_uintx));
    maximum_desired_capacity = MAX2(maximum_desired_capacity,
                                    MetaspaceSize);
    log_trace(gc, metaspace)("    maximum_free_percentage: %6.2f  minimum_used_percentage: %6.2f",
                             maximum_free_percentage, minimum_used_percentage);
    log_trace(gc, metaspace)("    minimum_desired_capacity: %6.1fKB  maximum_desired_capacity: %6.1fKB",
                             minimum_desired_capacity / (double) K, maximum_desired_capacity / (double) K);

    assert(minimum_desired_capacity <= maximum_desired_capacity,
           "sanity check");

    if (capacity_until_GC > maximum_desired_capacity) {
      // Capacity too large, compute shrinking size
      shrink_bytes = capacity_until_GC - maximum_desired_capacity;
      shrink_bytes = shrink_bytes / 100 * current_shrink_factor;

      shrink_bytes = align_down(shrink_bytes, Metaspace::commit_alignment());

      assert(shrink_bytes <= max_shrink_bytes,
             "invalid shrink size " SIZE_FORMAT " not <= " SIZE_FORMAT,
             shrink_bytes, max_shrink_bytes);
      if (current_shrink_factor == 0) {
        _shrink_factor = 10;
      } else {
        _shrink_factor = MIN2(current_shrink_factor * 4, (uint) 100);
      }
      log_trace(gc, metaspace)("    shrinking:  initThreshold: %.1fK  maximum_desired_capacity: %.1fK",
                               MetaspaceSize / (double) K, maximum_desired_capacity / (double) K);
      log_trace(gc, metaspace)("    shrink_bytes: %.1fK  current_shrink_factor: %d  new shrink factor: %d  MinMetaspaceExpansion: %.1fK",
                               shrink_bytes / (double) K, current_shrink_factor, _shrink_factor, MinMetaspaceExpansion / (double) K);
    }
  }

  // Don't shrink unless it's significant
  if (shrink_bytes >= MinMetaspaceExpansion &&
      ((capacity_until_GC - shrink_bytes) >= MetaspaceSize)) {
    size_t new_capacity_until_GC = MetaspaceGC::dec_capacity_until_GC(shrink_bytes);
    Metaspace::tracer()->report_gc_threshold(capacity_until_GC,
                                             new_capacity_until_GC,
                                             MetaspaceGCThresholdUpdater::ComputeNewSize);
  }
}

由場景一可知,為了避免彈性伸縮帶來的額外 GC 消耗,我們會將 -XX:MetaSpaceSize 和 -XX:MaxMetaSpaceSize 兩個值設定為固定的,但是這樣也會導致在空間不夠的時候無法擴容,然後頻繁地觸發 GC,最終 OOM。所以關鍵原因就是 ClassLoader 不停地在記憶體中 load 了新的 Class ,一般這種問題都發生在動態類載入等情況上。

4.3.3 策略

瞭解大概什麼原因後,如何定位和解決就很簡單了,可以 dump 快照之後通過 JProfiler 或 MAT 觀察 Classes 的 Histogram(直方圖) 即可,或者直接通過命令即可定位, jcmd 打幾次 Histogram 的圖,看一下具體是哪個包下的 Class 增加較多就可以定位了。不過有時候也要結合InstBytes、KlassBytes、Bytecodes、MethodAll 等幾項指標綜合來看下。如下圖便是筆者使用 jcmd 排查到一個 Orika 的問題。

jcmd <PID> GC.class_stats|awk '{print$13}'|sed  's/\(.*\)\.\(.*\)/\1/g'|sort |uniq -c|sort -nrk1

如果無法從整體的角度定位,可以新增 -XX:+TraceClassLoading 和 -XX:+TraceClassUnLoading 引數觀察詳細的類載入和解除安裝資訊。

4.3.4 小結

原理理解比較複雜,但定位和解決問題會比較簡單,經常會出問題的幾個點有 Orika 的 classMap、JSON 的 ASMSerializer、Groovy 動態載入類等,基本都集中在反射、Javasisit 位元組碼增強、CGLIB 動態代理、OSGi 自定義類載入器等的技術點上。另外就是及時給 MetaSpace 區的使用率加一個監控,如果指標有波動提前發現並解決問題。

4.4 場景四:過早晉升 *

4.4.1 現象

這種場景主要發生在分代的收集器上面,專業的術語稱為“Premature Promotion”。90% 的物件朝生夕死,只有在 Young 區經歷過幾次 GC 的洗禮後才會晉升到 Old 區,每經歷一次 GC 物件的 GC Age 就會增長 1,最大通過 -XX:MaxTenuringThreshold 來控制。

過早晉升一般不會直接影響 GC,總會伴隨著浮動垃圾、大物件擔保失敗等問題,但這些問題不是立刻發生的,我們可以觀察以下幾種現象來判斷是否發生了過早晉升。

分配速率接近於晉升速率,物件晉升年齡較小。

GC 日誌中出現“Desired survivor size 107347968 bytes, new threshold 1(max 6)”等資訊,說明此時經歷過一次 GC 就會放到 Old 區。

Full GC 比較頻繁,且經歷過一次 GC 之後 Old 區的變化比例非常大

比如說 Old 區觸發的回收閾值是 80%,經歷過一次 GC 之後下降到了 10%,這就說明 Old 區的 70% 的物件存活時間其實很短,如下圖所示,Old 區大小每次 GC 後從 2.1G 回收到 300M,也就是說回收掉了 1.8G 的垃圾,只有 300M 的活躍物件。整個 Heap 目前是 4G,活躍物件只佔了不到十分之一。

過早晉升的危害:

  • Young GC 頻繁,總的吞吐量下降。
  • Full GC 頻繁,可能會有較大停頓。

4.4.2 原因

主要的原因有以下兩點:

  • Young/Eden 區過小: 過小的直接後果就是 Eden 被裝滿的時間變短,本應該回收的物件參與了 GC 並晉升,Young GC 採用的是複製演算法,由基礎篇我們知道 copying 耗時遠大於 mark,也就是 Young GC 耗時本質上就是 copy 的時間(CMS 掃描 Card Table 或 G1 掃描 Remember Set 出問題的情況另說),沒來及回收的物件增大了回收的代價,所以 Young GC 時間增加,同時又無法快速釋放空間,Young GC 次數也跟著增加。

  • 分配速率過大: 可以觀察出問題前後 Mutator 的分配速率,如果有明顯波動可以嘗試觀察網路卡流量、儲存類中介軟體慢查詢日誌等資訊,看是否有大量資料被載入到記憶體中。

同時無法 GC 掉物件還會帶來另外一個問題,引發動態年齡計算:JVM 通過 -XX:MaxTenuringThreshold 引數來控制晉升年齡,每經過一次 GC,年齡就會加一,達到最大年齡就可以進入 Old 區,最大值為 15(因為 JVM 中使用 4 個位元來表示物件的年齡)。設定固定的 MaxTenuringThreshold 值作為晉升條件:

  • MaxTenuringThreshold 如果設定得過大,原本應該晉升的物件一直停留在 Survivor 區,直到 Survivor 區溢位,一旦溢位發生,Eden + Survivor 中物件將不再依據年齡全部提升到 Old 區,這樣物件老化的機制就失效了。

  • MaxTenuringThreshold 如果設定得過小,過早晉升即物件不能在 Young 區充分被回收,大量短期物件被晉升到 Old 區,Old 區空間迅速增長,引起頻繁的 Major GC,分代回收失去了意義,嚴重影響 GC 效能。

相同應用在不同時間的表現不同,特殊任務的執行或者流量成分的變化,都會導致物件的生命週期分佈發生波動,那麼固定的閾值設定,因為無法動態適應變化,會造成和上面問題,所以 Hotspot 會使用動態計算的方式來調整晉升的閾值。

具體動態計算可以看一下 Hotspot 原始碼,具體在 /src/hotspot/share/gc/shared/ageTable.cpp 的 compute_tenuring_threshold 方法中:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  //TargetSurvivorRatio預設50,意思是:在回收之後希望survivor區的佔用率達到這個比例
  size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  size_t total = 0;
  uint age = 1;
  assert(sizes[0] == 0, "no objects with age zero should be recorded");
  while (age < table_size) {//table_size=16
    total += sizes[age];
    //如果加上這個年齡的所有物件的大小之後,佔用量>期望的大小,就設定age為新的晉升閾值
    if (total > desired_survivor_size) break;
    age++;
  }

  uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  if (PrintTenuringDistribution || UsePerfData) {

    //列印期望的survivor的大小以及新計算出來的閾值,和設定的最大閾值
    if (PrintTenuringDistribution) {
      gclog_or_tty->cr();
      gclog_or_tty->print_cr("Desired survivor size " SIZE_FORMAT " bytes, new threshold %u (max %u)",
        desired_survivor_size*oopSize, result, (int) MaxTenuringThreshold);
    }

    total = 0;
    age = 1;
    while (age < table_size) {
      total += sizes[age];
      if (sizes[age] > 0) {
        if (PrintTenuringDistribution) {
          gclog_or_tty->print_cr("- age %3u: " SIZE_FORMAT_W(10) " bytes, " SIZE_FORMAT_W(10) " total",
                                        age,    sizes[age]*oopSize,          total*oopSize);
        }
      }
      if (UsePerfData) {
        _perf_sizes[age]->set_value(sizes[age]*oopSize);
      }
      age++;
    }
    if (UsePerfData) {
      SharedHeap* sh = SharedHeap::heap();
      CollectorPolicy* policy = sh->collector_policy();
      GCPolicyCounters* gc_counters = policy->counters();
      gc_counters->tenuring_threshold()->set_value(result);
      gc_counters->desired_survivor_size()->set_value(
        desired_survivor_size*oopSize);
    }
  }

  return result;
}

可以看到 Hotspot 遍歷所有物件時,從所有年齡為 0 的物件佔用的空間開始累加,如果加上年齡等於 n 的所有物件的空間之後,使用 Survivor 區的條件值(TargetSurvivorRatio / 100,TargetSurvivorRatio 預設值為 50)進行判斷,若大於這個值則結束迴圈,將 n 和 MaxTenuringThreshold 比較,若 n 小,則閾值為 n,若 n 大,則只能去設定最大閾值為 MaxTenuringThreshold。動態年齡觸發後導致更多的物件進入了 Old 區,造成資源浪費

4.4.3 策略

知道問題原因後我們就有解決的方向,如果是 Young/Eden 區過小,我們可以在總的 Heap 記憶體不變的情況下適當增大 Young 區,具體怎麼增加?一般情況下 Old 的大小應當為活躍物件的 2~3 倍左右,考慮到浮動垃圾問題最好在 3 倍左右,剩下的都可以分給 Young 區。

拿筆者的一次典型過早晉升優化來看,原配置為 Young 1.2G + Old 2.8G,通過觀察 CMS GC 的情況找到存活物件大概為 300~400M,於是調整 Old 1.5G 左右,剩下 2.5G 分給 Young 區。僅僅調了一個 Young 區大小引數(-Xmn),整個 JVM 一分鐘 Young GC 從 26 次降低到了 11 次,單次時間也沒有增加,總的 GC 時間從 1100ms 降低到了 500ms,CMS GC 次數也從 40 分鐘左右一次降低到了 7 小時 30 分鐘一次。

如果是分配速率過大:

  • 偶發較大:通過記憶體分析工具找到問題程式碼,從業務邏輯上做一些優化。

  • 一直較大:當前的 Collector 已經不滿足 Mutator 的期望了,這種情況要麼擴容 Mutator 的 VM,要麼調整 GC 收集器型別或加大空間。

4.4.4 小結

過早晉升問題一般不會特別明顯,但日積月累之後可能會爆發一波收集器退化之類的問題,所以我們還是要提前避免掉的,可以看看自己系統裡面是否有這些現象,如果比較匹配的話,可以嘗試優化一下。一行程式碼優化的 ROI 還是很高的。

如果在觀察 Old 區前後比例變化的過程中,發現可以回收的比例非常小,如從 80% 只回收到了 60%,說明我們大部分物件都是存活的,Old 區的空間可以適當調大些。

4.4.5 加餐

關於在調整 Young 與 Old 的比例時,如何選取具體的 NewRatio 值,這裡將問題抽象成為一個蓄水池模型,找到以下關鍵衡量指標,大家可以根據自己場景進行推算。

  • NewRatio 的值 r 與 va、vp、vyc、voc、rs 等值存在一定函式相關性(rs 越小 r 越大、r 越小 vp 越小,…,之前嘗試使用 NN 來輔助建模,但目前還沒有完全算出具體的公式,有想法的同學可以在評論區給出你的答案)。

  • 總停頓時間 T 為 Young GC 總時間 Tyc 和 Old GC 總時間 Toc 之和,其中 Tyc 與 vyc 和 vp 相關,Toc 與 voc相關。

  • 忽略掉 GC 時間後,兩次 Young GC 的時間間隔要大於 TP9999 時間,這樣儘量讓物件在 Eden 區就被回收,可以減少很多停頓。

4.5 場景五:CMS Old GC 頻繁*

4.5.1 現象

Old 區頻繁的做 CMS GC,但是每次耗時不是特別長,整體最大 STW 也在可接受範圍內,但由於 GC 太頻繁導致吞吐下降比較多。

4.5.2 原因

這種情況比較常見,基本都是一次 Young GC 完成後,負責處理 CMS GC 的一個後臺執行緒 concurrentMarkSweepThread 會不斷地輪詢,使用 shouldConcurrentCollect() 方法做一次檢測,判斷是否達到了回收條件。如果達到條件,使用 collect_in_background() 啟動一次 Background 模式 GC。輪詢的判斷是使用 sleepBeforeNextCycle() 方法,間隔週期為 -XX:CMSWaitDuration 決定,預設為2s。

具體程式碼在: src/hotspot/share/gc/cms/concurrentMarkSweepThread.cpp。

void ConcurrentMarkSweepThread::run_service() {
  assert(this == cmst(), "just checking");

  if (BindCMSThreadToCPU && !os::bind_to_processor(CPUForCMSThread)) {
    log_warning(gc)("Couldn't bind CMS thread to processor " UINTX_FORMAT, CPUForCMSThread);
  }

  while (!should_terminate()) {
    sleepBeforeNextCycle();
    if (should_terminate()) break;
    GCIdMark gc_id_mark;
    GCCause::Cause cause = _collector->_full_gc_requested ?
      _collector->_full_gc_cause : GCCause::_cms_concurrent_mark;
    _collector->collect_in_background(cause);
  }
  verify_ok_to_terminate();
}

void ConcurrentMarkSweepThread::sleepBeforeNextCycle() {
  while (!should_terminate()) {
    if(CMSWaitDuration >= 0) {
      // Wait until the next synchronous GC, a concurrent full gc
      // request or a timeout, whichever is earlier.
      wait_on_cms_lock_for_scavenge(CMSWaitDuration);
    } else {
      // Wait until any cms_lock event or check interval not to call shouldConcurrentCollect permanently
      wait_on_cms_lock(CMSCheckInterval);
    }
    // Check if we should start a CMS collection cycle
    if (_collector->shouldConcurrentCollect()) {
      return;
    }
    // .. collection criterion not yet met, let's go back
    // and wait some more
  }
}

判斷是否進行回收的程式碼在:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp。

bool CMSCollector::shouldConcurrentCollect() {
  LogTarget(Trace, gc) log;

  if (_full_gc_requested) {
    log.print("CMSCollector: collect because of explicit  gc request (or GCLocker)");
    return true;
  }

  FreelistLocker x(this);
  // ------------------------------------------------------------------
  // Print out lots of information which affects the initiation of
  // a collection.
  if (log.is_enabled() && stats().valid()) {
    log.print("CMSCollector shouldConcurrentCollect: ");

    LogStream out(log);
    stats().print_on(&out);

    log.print("time_until_cms_gen_full %3.7f", stats().time_until_cms_gen_full());
    log.print("free=" SIZE_FORMAT, _cmsGen->free());
    log.print("contiguous_available=" SIZE_FORMAT, _cmsGen->contiguous_available());
    log.print("promotion_rate=%g", stats().promotion_rate());
    log.print("cms_allocation_rate=%g", stats().cms_allocation_rate());
    log.print("occupancy=%3.7f", _cmsGen->occupancy());
    log.print("initiatingOccupancy=%3.7f", _cmsGen->initiating_occupancy());
    log.print("cms_time_since_begin=%3.7f", stats().cms_time_since_begin());
    log.print("cms_time_since_end=%3.7f", stats().cms_time_since_end());
    log.print("metadata initialized %d", MetaspaceGC::should_concurrent_collect());
  }
  // ------------------------------------------------------------------
  if (!UseCMSInitiatingOccupancyOnly) {
    if (stats().valid()) {
      if (stats().time_until_cms_start() == 0.0) {
        return true;
      }
    } else {
  
      if (_cmsGen->occupancy() >= _bootstrap_occupancy) {
        log.print(" CMSCollector: collect for bootstrapping statistics: occupancy = %f, boot occupancy = %f",
                  _cmsGen->occupancy(), _bootstrap_occupancy);
        return true;
      }
    }
  }

  if (_cmsGen->should_concurrent_collect()) {
    log.print("CMS old gen initiated");
    return true;
  }

  // We start a collection if we believe an incremental collection may fail;
  // this is not likely to be productive in practice because it's probably too
  // late anyway.
  CMSHeap* heap = CMSHeap::heap();
  if (heap->incremental_collection_will_fail(true /* consult_young */)) {
    log.print("CMSCollector: collect because incremental collection will fail ");
    return true;
  }

  if (MetaspaceGC::should_concurrent_collect()) {
    log.print("CMSCollector: collect for metadata allocation ");
    return true;
  }

  // CMSTriggerInterval starts a CMS cycle if enough time has passed.
  if (CMSTriggerInterval >= 0) {
    if (CMSTriggerInterval == 0) {
      // Trigger always
      return true;
    }

    // Check the CMS time since begin (we do not check the stats validity
    // as we want to be able to trigger the first CMS cycle as well)
    if (stats().cms_time_since_begin() >= (CMSTriggerInterval / ((double) MILLIUNITS))) {
      if (stats().valid()) {
        log.print("CMSCollector: collect because of trigger interval (time since last begin %3.7f secs)",
                  stats().cms_time_since_begin());
      } else {
        log.print("CMSCollector: collect because of trigger interval (first collection)");
      }
      return true;
    }
  }

  return false;
}

分析其中邏輯判斷是否觸發 GC,分為以下幾種情況:

  • 觸發 CMS GC: 通過呼叫 _collector->collect_in_background() 進行觸發 Background GC 。

    • CMS 預設採用 JVM 執行時的統計資料判斷是否需要觸發 CMS GC,如果需要根據 -XX:CMSInitiatingOccupancyFraction 的值進行判斷,需要設定引數 -XX:+UseCMSInitiatingOccupancyOnly

    • 如果開啟了 -XX:UseCMSInitiatingOccupancyOnly 引數,判斷當前 Old 區使用率是否大於閾值,則觸發 CMS GC,該閾值可以通過引數 -XX:CMSInitiatingOccupancyFraction 進行設定,如果沒有設定,預設為 92%。

    • 如果之前的 Young GC 失敗過,或者下次 Young 區執行 Young GC 可能失敗,這兩種情況下都需要觸發 CMS GC。

    • CMS 預設不會對 MetaSpace 或 Perm 進行垃圾收集,如果希望對這些區域進行垃圾收集,需要設定引數 -XX:+CMSClassUnloadingEnabled

  • 觸發 Full GC: 直接進行 Full GC,這種情況到場景七中展開說明。

    • 如果 _full_gc_requested 為真,說明有明確的需求要進行 GC,比如呼叫 System.gc。

    • 在 Eden 區為物件或 TLAB 分配記憶體失敗,導致一次 Young GC,在 GenCollectorPolicy 類的 satisfy_failed_allocation() 方法中進行判斷。

大家可以看一下原始碼中的日誌列印,通過日誌我們就可以比較清楚地知道具體的原因,然後就可以著手分析了。

4.5.3 策略

我們這裡還是拿最常見的達到回收比例這個場景來說,與過早晉升不同的是這些物件確實存活了一段時間,Survival Time 超過了 TP9999 時間,但是又達不到長期存活,如各種資料庫、網路連結,帶有失效時間的快取等。

處理這種常規記憶體洩漏問題基本是一個思路,主要步驟如下:

Dump Diff 和 Leak Suspects 比較直觀就不介紹了,這裡說下其它幾個關鍵點:

  • 記憶體 Dump: 使用 jmap、arthas 等 dump 堆進行快照時記得摘掉流量,同時分別在 CMS GC 的發生前後分別 dump 一次
  • 分析 Top Component: 要記得按照物件、類、類載入器、包等多個維度觀察 Histogram,同時使用 outgoing 和 incoming 分析關聯的物件,另外就是 Soft Reference 和 Weak Reference、Finalizer 等也要看一下。
  • 分析 Unreachable: 重點看一下這個,關注下 Shallow 和 Retained 的大小。如下圖所示,筆者之前一次 GC 優化,就根據 Unreachable Objects 發現了 Hystrix 的滑動視窗問題。

4.5.4 小結

經過整個流程下來基本就能定位問題了,不過在優化的過程中記得使用控制變數的方法來優化,防止一些會加劇問題的改動被掩蓋。

4.6 場景六:單次 CMS Old GC 耗時長*

4.6.1 現象

CMS GC 單次 STW 最大超過 1000ms,不會頻繁發生,如下圖所示最長達到了 8000ms。某些場景下會引起“雪崩效應”,這種場景非常危險,我們應該儘量避免出現。

4.6.2 原因

CMS 在回收的過程中,STW 的階段主要是 Init Mark 和 Final Remark 這兩個階段,也是導致 CMS Old GC 最多的原因,另外有些情況就是在 STW 前等待 Mutator 的執行緒到達 SafePoint 也會導致時間過長,但這種情況較少,我們在此處主要討論前者。發生收集器退化或者碎片壓縮的場景請看場景七。

想要知道這兩個階段為什麼會耗時,我們需要先看一下這兩個階段都會幹什麼。

核心程式碼都在 /src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp 中,內部有個執行緒 ConcurrentMarkSweepThread 輪詢來校驗,Old 區的垃圾回收相關細節被完全封裝在 CMSCollector 中,呼叫入口就是 ConcurrentMarkSweepThread 呼叫的 CMSCollector::collect_in_background 和 ConcurrentMarkSweepGeneration 呼叫的 CMSCollector::collect 方法,此處我們討論大多數場景的 collect_in_background。整個過程中會 STW 的主要是 initial Mark 和 Final Remark,核心程式碼在 VM_CMS_Initial_Mark / VM_CMS_Final_Remark 中,執行時需要將執行權交由 VMThread 來執行。

  • CMS Init Mark執行步驟,實現在 CMSCollector::checkpointRootsInitialWork() 和 CMSParInitialMarkTask::work 中,整體步驟和程式碼如下:
void CMSCollector::checkpointRootsInitialWork() {
  assert(SafepointSynchronize::is_at_safepoint(), "world should be stopped");
  assert(_collectorState == InitialMarking, "just checking");

  // Already have locks.
  assert_lock_strong(bitMapLock());
  assert(_markBitMap.isAllClear(), "was reset at end of previous cycle");

  // Setup the verification and class unloading state for this
  // CMS collection cycle.
  setup_cms_unloading_and_verification_state();

  GCTraceTime(Trace, gc, phases) ts("checkpointRootsInitialWork", _gc_timer_cm);

  // Reset all the PLAB chunk arrays if necessary.
  if (_survivor_plab_array != NULL && !CMSPLABRecordAlways) {
    reset_survivor_plab_arrays();
  }

  ResourceMark rm;
  HandleMark  hm;

  MarkRefsIntoClosure notOlder(_span, &_markBitMap);
  CMSHeap* heap = CMSHeap::heap();

  verify_work_stacks_empty();
  verify_overflow_empty();

  heap->ensure_parsability(false);  // fill TLABs, but no need to retire them
  // Update the saved marks which may affect the root scans.
  heap->save_marks();

  // weak reference processing has not started yet.
  ref_processor()->set_enqueuing_is_done(false);

  // Need to remember all newly created CLDs,
  // so that we can guarantee that the remark finds them.
  ClassLoaderDataGraph::remember_new_clds(true);

  // Whenever a CLD is found, it will be claimed before proceeding to mark
  // the klasses. The claimed marks need to be cleared before marking starts.
  ClassLoaderDataGraph::clear_claimed_marks();

  print_eden_and_survivor_chunk_arrays();

  {
    if (CMSParallelInitialMarkEnabled) {
      // The parallel version.
      WorkGang* workers = heap->workers();
      assert(workers != NULL, "Need parallel worker threads.");
      uint n_workers = workers->active_workers();

      StrongRootsScope srs(n_workers);

      CMSParInitialMarkTask tsk(this, &srs, n_workers);
      initialize_sequential_subtasks_for_young_gen_rescan(n_workers);
      // If the total workers is greater than 1, then multiple workers
      // may be used at some time and the initialization has been set
      // such that the single threaded path cannot be used.
      if (workers->total_workers() > 1) {
        workers->run_task(&tsk);
      } else {
        tsk.work(0);
      }
    } else {
      // The serial version.
      CLDToOopClosure cld_closure(&notOlder, true);
      heap->rem_set()->prepare_for_younger_refs_iterate(false); // Not parallel.

      StrongRootsScope srs(1);

      heap->cms_process_roots(&srs,
                             true,   // young gen as roots
                             GenCollectedHeap::ScanningOption(roots_scanning_options()),
                             should_unload_classes(),
                             &notOlder,
                             &cld_closure);
    }
  }

  // Clear mod-union table; it will be dirtied in the prologue of
  // CMS generation per each young generation collection.
  assert(_modUnionTable.isAllClear(),
       "Was cleared in most recent final checkpoint phase"
       " or no bits are set in the gc_prologue before the start of the next "
       "subsequent marking phase.");

  assert(_ct->cld_rem_set()->mod_union_is_clear(), "Must be");
  // Save the end of the used_region of the constituent generations
  // to be used to limit the extent of sweep in each generation.
  save_sweep_limits();
  verify_overflow_empty();
}
void CMSParInitialMarkTask::work(uint worker_id) {
  elapsedTimer _timer;
  ResourceMark rm;
  HandleMark   hm;

  // ---------- scan from roots --------------
  _timer.start();
  CMSHeap* heap = CMSHeap::heap();
  ParMarkRefsIntoClosure par_mri_cl(_collector->_span, &(_collector->_markBitMap));

  // ---------- young gen roots --------------
  {
    work_on_young_gen_roots(&par_mri_cl);
    _timer.stop();
    log_trace(gc, task)("Finished young gen initial mark scan work in %dth thread: %3.3f sec", worker_id, _timer.seconds());
  }

  // ---------- remaining roots --------------
  _timer.reset();
  _timer.start();

  CLDToOopClosure cld_closure(&par_mri_cl, true);

  heap->cms_process_roots(_strong_roots_scope,
                          false,     // yg was scanned above
                          GenCollectedHeap::ScanningOption(_collector->CMSCollector::roots_scanning_options()),
                          _collector->should_unload_classes(),
                          &par_mri_cl,
                          &cld_closure,
                          &_par_state_string);

  assert(_collector->should_unload_classes()
         || (_collector->CMSCollector::roots_scanning_options() & GenCollectedHeap::SO_AllCodeCache),
         "if we didn't scan the code cache, we have to be ready to drop nmethods with expired weak oops");
  _timer.stop();
  log_trace(gc, task)("Finished remaining root initial mark scan work in %dth thread: %3.3f sec", worker_id, _timer.seconds());
}

整個過程比較簡單,從 GC Root 出發標記 Old 中的物件,處理完成後藉助 BitMap 處理下 Young 區對 Old 區的引用,整個過程基本都比較快,很少會有較大的停頓。

  • CMS Final Remark 執行步驟,實現在 CMSCollector::checkpointRootsFinalWork() 中,整體程式碼和步驟如下:
void CMSCollector::checkpointRootsFinalWork() {
  GCTraceTime(Trace, gc, phases) tm("checkpointRootsFinalWork", _gc_timer_cm);

  assert(haveFreelistLocks(), "must have free list locks");
  assert_lock_strong(bitMapLock());

  ResourceMark rm;
  HandleMark   hm;

  CMSHeap* heap = CMSHeap::heap();

  if (should_unload_classes()) {
    CodeCache::gc_prologue();
  }
  assert(haveFreelistLocks(), "must have free list locks");
  assert_lock_strong(bitMapLock());

  heap->ensure_parsability(false);  // fill TLAB's, but no need to retire them
  // Update the saved marks which may affect the root scans.
  heap->save_marks();

  print_eden_and_survivor_chunk_arrays();

  {
    if (CMSParallelRemarkEnabled) {
      GCTraceTime(Debug, gc, phases) t("Rescan (parallel)", _gc_timer_cm);
      do_remark_parallel();
    } else {
      GCTraceTime(Debug, gc, phases) t("Rescan (non-parallel)", _gc_timer_cm);
      do_remark_non_parallel();
    }
  }
  verify_work_stacks_empty();
  verify_overflow_empty();

  {
    GCTraceTime(Trace, gc, phases) ts("refProcessingWork", _gc_timer_cm);
    refProcessingWork();
  }
  verify_work_stacks_empty();
  verify_overflow_empty();

  if (should_unload_classes()) {
    CodeCache::gc_epilogue();
  }
  JvmtiExport::gc_epilogue();
  assert(_markStack.isEmpty(), "No grey objects");
  size_t ser_ovflw = _ser_pmc_remark_ovflw + _ser_pmc_preclean_ovflw +
                     _ser_kac_ovflw        + _ser_kac_preclean_ovflw;
  if (ser_ovflw > 0) {
    log_trace(gc)("Marking stack overflow (benign) (pmc_pc=" SIZE_FORMAT ", pmc_rm=" SIZE_FORMAT ", kac=" SIZE_FORMAT ", kac_preclean=" SIZE_FORMAT ")",
                         _ser_pmc_preclean_ovflw, _ser_pmc_remark_ovflw, _ser_kac_ovflw, _ser_kac_preclean_ovflw);
    _markStack.expand();
    _ser_pmc_remark_ovflw = 0;
    _ser_pmc_preclean_ovflw = 0;
    _ser_kac_preclean_ovflw = 0;
    _ser_kac_ovflw = 0;
  }
  if (_par_pmc_remark_ovflw > 0 || _par_kac_ovflw > 0) {
     log_trace(gc)("Work queue overflow (benign) (pmc_rm=" SIZE_FORMAT ", kac=" SIZE_FORMAT ")",
                          _par_pmc_remark_ovflw, _par_kac_ovflw);
     _par_pmc_remark_ovflw = 0;
    _par_kac_ovflw = 0;
  }
   if (_markStack._hit_limit > 0) {
     log_trace(gc)(" (benign) Hit max stack size limit (" SIZE_FORMAT ")",
                          _markStack._hit_limit);
   }
   if (_markStack._failed_double > 0) {
     log_trace(gc)(" (benign) Failed stack doubling (" SIZE_FORMAT "), current capacity " SIZE_FORMAT,
                          _markStack._failed_double, _markStack.capacity());
   }
  _markStack._hit_limit = 0;
  _markStack._failed_double = 0;

  if ((VerifyAfterGC || VerifyDuringGC) &&
      CMSHeap::heap()->total_collections() >= VerifyGCStartAt) {
    verify_after_remark();
  }

  _gc_tracer_cm->report_object_count_after_gc(&_is_alive_closure);

  // Change under the freelistLocks.
  _collectorState = Sweeping;
  // Call isAllClear() under bitMapLock
  assert(_modUnionTable.isAllClear(),
      "Should be clear by end of the final marking");
  assert(_ct->cld_rem_set()->mod_union_is_clear(),
      "Should be clear by end of the final marking");
}

Final Remark 是最終的第二次標記,這種情況只有在 Background GC 執行了 InitialMarking 步驟的情形下才會執行,如果是 Foreground GC 執行的 InitialMarking 步驟則不需要再次執行 FinalRemark。Final Remark 的開始階段與 Init Mark 處理的流程相同,但是後續多了 Card Table 遍歷、Reference 例項的清理並將其加入到 Reference 維護的 pend_list 中,如果要收集後設資料資訊,還要清理 SystemDictionary、CodeCache、SymbolTable、StringTable 等元件中不再使用的資源。

4.6.3 策略

知道了兩個 STW 過程執行流程,我們分析解決就比較簡單了,由於大部分問題都出在 Final Remark 過程,這裡我們也拿這個場景來舉例,主要步驟:

  • 【方向】 觀察詳細 GC 日誌,找到出問題時 Final Remark 日誌,分析下 Reference 處理和後設資料處理 real 耗時是否正常,詳細資訊需要通過 -XX:+PrintReferenceGC 引數開啟。基本在日誌裡面就能定位到大概是哪個方向出了問題,耗時超過 10% 的就需要關注
2019-02-27T19:55:37.920+0800: 516952.915: [GC (CMS Final Remark) 516952.915: [ParNew516952.939: [SoftReference, 0 refs, 0.0003857 secs]516952.939: [WeakReference, 1362 refs, 0.0002415 secs]516952.940: [FinalReference, 146 refs, 0.0001233 secs]516952.940: [PhantomReference, 0 refs, 57 refs, 0.0002369 secs]516952.940: [JNI Weak Reference, 0.0000662 secs]
[class unloading, 0.1770490 secs]516953.329: [scrub symbol table, 0.0442567 secs]516953.373: [scrub string table, 0.0036072 secs][1 CMS-remark: 1638504K(2048000K)] 1667558K(4352000K), 0.5269311 secs] [Times: user=1.20 sys=0.03, real=0.53 secs]
  • 【根因】 有了具體的方向我們就可以進行深入的分析,一般來說最容易出問題的地方就是 Reference 中的 FinalReference 和後設資料資訊處理中的 scrub symbol table 兩個階段,想要找到具體問題程式碼就需要記憶體分析工具 MAT 或 JProfiler 了,注意要 dump 即將開始 CMS GC 的堆。在用 MAT 等工具前也可以先用命令列看下物件 Histogram,有可能直接就能定位問題。

    • 對 FinalReference 的分析主要觀察 java.lang.ref.Finalizer 物件的 dominator tree,找到洩漏的來源。經常會出現問題的幾個點有 Socket 的 SocksSocketImpl 、Jersey 的 ClientRuntime、MySQL 的 ConnectionImpl 等等。

    • scrub symbol table 表示清理後設資料符號引用耗時,符號引用是 Java 程式碼被編譯成位元組碼時,方法在 JVM 中的表現形式,生命週期一般與 Class 一致,當 _should_unload_classes 被設定為 true 時在 CMSCollector::refProcessingWork() 中與 Class Unload、String Table 一起被處理。

if (should_unload_classes()) {
    {
      GCTraceTime(Debug, gc, phases) t("Class Unloading", _gc_timer_cm);

      // Unload classes and purge the SystemDictionary.
      bool purged_class = SystemDictionary::do_unloading(_gc_timer_cm);

      // Unload nmethods.
      CodeCache::do_unloading(&_is_alive_closure, purged_class);

      // Prune dead klasses from subklass/sibling/implementor lists.
      Klass::clean_weak_klass_links(purged_class);
    }

    {
      GCTraceTime(Debug, gc, phases) t("Scrub Symbol Table", _gc_timer_cm);
      // Clean up unreferenced symbols in symbol table.
      SymbolTable::unlink();
    }

    {
      GCTraceTime(Debug, gc, phases) t("Scrub String Table", _gc_timer_cm);
      // Delete entries for dead interned strings.
      StringTable::unlink(&_is_alive_closure);
    }
  }
  • 【策略】 知道 GC 耗時的根因就比較好處理了,這種問題不會大面積同時爆發,不過有很多時候單臺 STW 的時間會比較長,如果業務影響比較大,及時摘掉流量,具體後續優化策略如下:

    • FinalReference:找到記憶體來源後通過優化程式碼的方式來解決,如果短時間無法定位可以增加 -XX:+ParallelRefProcEnabled 對 Reference 進行並行處理。

    • symbol table:觀察 MetaSpace 區的歷史使用峰值,以及每次 GC 前後的回收情況,一般沒有使用動態類載入或者 DSL 處理等,MetaSpace 的使用率上不會有什麼變化,這種情況可以通過 -XX:-CMSClassUnloadingEnabled 來避免 MetaSpace 的處理,JDK8 會預設開啟 CMSClassUnloadingEnabled,這會使得 CMS 在 CMS-Remark 階段嘗試進行類的解除安裝。

4.6.4 小結

正常情況進行的 Background CMS GC,出現問題基本都集中在 Reference 和 Class 等後設資料處理上,在 Reference 類的問題處理方面,不管是 FinalReference,還是 SoftReference、WeakReference 核心的手段就是找準時機 dump 快照,然後用記憶體分析工具來分析。Class 處理方面目前除了關閉類解除安裝開關,沒有太好的方法。

在 G1 中同樣有 Reference 的問題,可以觀察日誌中的 Ref Proc,處理方法與 CMS 類似。

4.7 場景七:記憶體碎片&收集器退化

4.7.1 現象

併發的 CMS GC 演算法,退化為 Foreground 單執行緒序列 GC 模式,STW 時間超長,有時會長達十幾秒。其中 CMS 收集器退化後單執行緒序列 GC 演算法有兩種:

  • 帶壓縮動作的演算法,稱為 MSC,上面我們介紹過,使用標記-清理-壓縮,單執行緒全暫停的方式,對整個堆進行垃圾收集,也就是真正意義上的 Full GC,暫停時間要長於普通 CMS。
  • 不帶壓縮動作的演算法,收集 Old 區,和普通的 CMS 演算法比較相似,暫停時間相對 MSC 演算法短一些。

4.7.2 原因

CMS 發生收集器退化主要有以下幾種情況:

晉升失敗(Promotion Failed)

顧名思義,晉升失敗就是指在進行 Young GC 時,Survivor 放不下,物件只能放入 Old,但此時 Old 也放不下。直覺上乍一看這種情況可能會經常發生,但其實因為有 concurrentMarkSweepThread 和擔保機制的存在,發生的條件是很苛刻的,除非是短時間將 Old 區的剩餘空間迅速填滿,例如上文中說的動態年齡判斷導致的過早晉升(見下文的增量收集擔保失敗)。另外還有一種情況就是記憶體碎片導致的 Promotion Failed,Young GC 以為 Old 有足夠的空間,結果到分配時,晉級的大物件找不到連續的空間存放。

使用 CMS 作為 GC 收集器時,執行過一段時間的 Old 區如下圖所示,清除演算法導致記憶體出現多段的不連續,出現大量的記憶體碎片。

碎片帶來了兩個問題:

  • 空間分配效率較低:上文已經提到過,如果是連續的空間 JVM 可以通過使用 pointer bumping 的方式來分配,而對於這種有大量碎片的空閒連結串列則需要逐個訪問 freelist 中的項來訪問,查詢可以存放新建物件的地址。
  • 空間利用效率變低:Young 區晉升的物件大小大於了連續空間的大小,那麼將會觸發 Promotion Failed ,即使整個 Old 區的容量是足夠的,但由於其不連續,也無法存放新物件,也就是本文所說的問題。

增量收集擔保失敗

分配記憶體失敗後,會判斷統計得到的 Young GC 晉升到 Old 的平均大小,以及當前 Young 區已使用的大小也就是最大可能晉升的物件大小,是否大於 Old 區的剩餘空間。只要 CMS 的剩餘空間比前兩者的任意一者大,CMS 就認為晉升還是安全的,反之,則代表不安全,不進行Young GC,直接觸發Full GC。

顯式 GC

這種情況參見場景二。

併發模式失敗(Concurrent Mode Failure)

最後一種情況,也是發生概率較高的一種,在 GC 日誌中經常能看到 Concurrent Mode Failure 關鍵字。這種是由於併發 Background CMS GC 正在執行,同時又有 Young GC 晉升的物件要放入到了 Old 區中,而此時 Old 區空間不足造成的。

為什麼 CMS GC 正在執行還會導致收集器退化呢?主要是由於 CMS 無法處理浮動垃圾(Floating Garbage)引起的。CMS 的併發清理階段,Mutator 還在執行,因此不斷有新的垃圾產生,而這些垃圾不在這次清理標記的範疇裡,無法在本次 GC 被清除掉,這些就是浮動垃圾,除此之外在 Remark 之前那些斷開引用脫離了讀寫屏障控制的物件也算浮動垃圾。所以 Old 區回收的閾值不能太高,否則預留的記憶體空間很可能不夠,從而導致 Concurrent Mode Failure 發生。

4.7.3 策略

分析到具體原因後,我們就可以針對性解決了,具體思路還是從根因出發,具體解決策略:

  • 記憶體碎片: 通過配置 -XX:UseCMSCompactAtFullCollection=true 來控制 Full GC的過程中是否進行空間的整理(預設開啟,注意是Full GC,不是普通CMS GC),以及 -XX: CMSFullGCsBeforeCompaction=n 來控制多少次 Full GC 後進行一次壓縮。

  • 增量收集: 降低觸發 CMS GC 的閾值,即引數 -XX:CMSInitiatingOccupancyFraction 的值,讓 CMS GC 儘早執行,以保證有足夠的連續空間,也減少 Old 區空間的使用大小,另外需要使用 -XX:+UseCMSInitiatingOccupancyOnly 來配合使用,不然 JVM 僅在第一次使用設定值,後續則自動調整。

  • 浮動垃圾: 視情況控制每次晉升物件的大小,或者縮短每次 CMS GC 的時間,必要時可調節 NewRatio 的值。另外就是使用 -XX:+CMSScavengeBeforeRemark 在過程中提前觸發一次 Young GC,防止後續晉升過多物件。

4.7.4 小結

正常情況下觸發併發模式的 CMS GC,停頓非常短,對業務影響很小,但 CMS GC 退化後,影響會非常大,建議發現一次後就徹底根治。只要能定位到記憶體碎片、浮動垃圾、增量收集相關等具體產生原因,還是比較好解決的,關於記憶體碎片這塊,如果 -XX:CMSFullGCsBeforeCompaction 的值不好選取的話,可以使用 -XX:PrintFLSStatistics 來觀察記憶體碎片率情況,然後再設定具體的值。

最後就是在編碼的時候也要避免需要連續地址空間的大物件的產生,如過長的字串,用於存放附件、序列化或反序列化的 byte 陣列等,還有就是過早晉升問題儘量在爆發問題前就避免掉。

4.8 場景八:堆外記憶體 OOM

4.8.1 現象

記憶體使用率不斷上升,甚至開始使用 SWAP 記憶體,同時可能出現 GC 時間飆升,執行緒被 Block 等現象,通過 top 命令發現 Java 程式的 RES 甚至超過了 -Xmx 的大小。出現這些現象時,基本可以確定是出現了堆外記憶體洩漏。

4.8.2 原因

JVM 的堆外記憶體洩漏,主要有兩種的原因:

  • 通過 UnSafe#allocateMemoryByteBuffer#allocateDirect 主動申請了堆外記憶體而沒有釋放,常見於 NIO、Netty 等相關元件。
  • 程式碼中有通過 JNI 呼叫 Native Code 申請的記憶體沒有釋放。

4.8.3 策略

哪種原因造成的堆外記憶體洩漏?

首先,我們需要確定是哪種原因導致的堆外記憶體洩漏。這裡可以使用 NMT(NativeMemoryTracking) 進行分析。在專案中新增 -XX:NativeMemoryTracking=detail JVM引數後重啟專案(需要注意的是,開啟 NMT 會帶來 5%~10% 的效能損耗)。使用命令 jcmd pid VM.native_memory detail 檢視記憶體分佈。重點觀察 total 中的 committed,因為 jcmd 命令顯示的記憶體包含堆內記憶體、Code 區域、通過 Unsafe.allocateMemory 和 DirectByteBuffer 申請的記憶體,但是不包含其他 Native Code(C 程式碼)申請的堆外記憶體。

如果 total 中的 committed 和 top 中的 RES 相差不大,則應為主動申請的堆外記憶體未釋放造成的,如果相差較大,則基本可以確定是 JNI 呼叫造成的。

原因一:主動申請未釋放

JVM 使用 -XX:MaxDirectMemorySize=size 引數來控制可申請的堆外記憶體的最大值。在 Java8 中,如果未配置該引數,預設和 -Xmx 相等。

NIO 和 Netty 都會取 -XX:MaxDirectMemorySize 配置的值,來限制申請的堆外記憶體的大小。NIO 和 Netty 中還有一個計數器欄位,用來計算當前已申請的堆外記憶體大小,NIO 中是 java.nio.Bits#totalCapacity、Netty 中 io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER

當申請堆外記憶體時,NIO 和 Netty 會比較計數器欄位和最大值的大小,如果計數器的值超過了最大值的限制,會丟擲 OOM 的異常。

NIO 中是:OutOfMemoryError: Direct buffer memory

Netty 中是:OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )

我們可以檢查程式碼中是如何使用堆外記憶體的,NIO 或者是 Netty,通過反射,獲取到對應元件中的計數器欄位,並在專案中對該欄位的數值進行打點,即可準確地監控到這部分堆外記憶體的使用情況。

此時,可以通過 Debug 的方式確定使用堆外記憶體的地方是否正確執行了釋放記憶體的程式碼。另外,需要檢查 JVM 的引數是否有 -XX:+DisableExplicitGC 選項,如果有就去掉,因為該引數會使 System.gc 失效。(場景二:顯式 GC 的去與留)

原因二:通過 JNI 呼叫的 Native Code 申請的記憶體未釋放

這種情況排查起來比較困難,我們可以通過 Google perftools + Btrace 等工具,幫助我們分析出問題的程式碼在哪裡。

gperftools 是 Google 開發的一款非常實用的工具集,它的原理是在 Java 應用程式執行時,當呼叫 malloc 時換用它的 libtcmalloc.so,這樣就能對記憶體分配情況做一些統計。我們使用 gperftools 來追蹤分配記憶體的命令。如下圖所示,通過 gperftools 發現 Java_java_util_zip_Inflater_init 比較可疑。

接下來可以使用 Btrace,嘗試定位具體的呼叫棧。Btrace 是 Sun 推出的一款 Java 追蹤、監控工具,可以在不停機的情況下對線上的 Java 程式進行監控。如下圖所示,通過 Btrace 定位出專案中的 ZipHelper 在頻繁呼叫 GZIPInputStream ,在堆外記憶體分配物件。

最終定位到是,專案中對 GIPInputStream 的使用錯誤,沒有正確的 close()。

除了專案本身的原因,還可能有外部依賴導致的洩漏,如 Netty 和 Spring Boot,詳細情況可以學習下這兩篇文章,Spring Boot引起的“堆外記憶體洩漏”排查及經驗總結Netty堆外記憶體洩露排查盛宴

4.8.4 小結

首先可以使用 NMT + jcmd 分析洩漏的堆外記憶體是哪裡申請,確定原因後,使用不同的手段,進行原因定位。

4.9 場景九:JNI 引發的 GC 問題

4.9.1 現象

在 GC 日誌中,出現 GC Cause 為 GCLocker Initiated GC。

2020-09-23T16:49:09.727+0800: 504426.742: [GC (GCLocker Initiated GC) 504426.742: [ParNew (promotion failed): 209716K->6042K(1887488K), 0.0843330 secs] 1449487K->1347626K(3984640K), 0.0848963 secs] [Times: user=0.19 sys=0.00, real=0.09 secs]
2020-09-23T16:49:09.812+0800: 504426.827: [Full GC (GCLocker Initiated GC) 504426.827: [CMS: 1341583K->419699K(2097152K), 1.8482275 secs] 1347626K->419699K(3984640K), [Metaspace: 297780K->297780K(1329152K)], 1.8490564 secs] [Times: user=1.62 sys=0.20, real=1.85 secs]

4.9.2 原因

JNI(Java Native Interface)意為 Java 本地呼叫,它允許 Java 程式碼和其他語言寫的 Native 程式碼進行互動。

JNI 如果需要獲取 JVM 中的 String 或者陣列,有兩種方式:

  • 拷貝傳遞。
  • 共享引用(指標),效能更高。

由於 Native 程式碼直接使用了 JVM 堆區的指標,如果這時發生 GC,就會導致資料錯誤。因此,在發生此類 JNI 呼叫時,禁止 GC 的發生,同時阻止其他執行緒進入 JNI 臨界區,直到最後一個執行緒退出臨界區時觸發一次 GC。

GC Locker 實驗:

public class GCLockerTest {

  static final int ITERS = 100;
  static final int ARR_SIZE =  10000;
  static final int WINDOW = 10000000;

  static native void acquire(int[] arr);
  static native void release(int[] arr);

  static final Object[] window = new Object[WINDOW];

  public static void main(String... args) throws Throwable {
    System.loadLibrary("GCLockerTest");
    int[] arr = new int[ARR_SIZE];

    for (int i = 0; i < ITERS; i++) {
      acquire(arr);
      System.out.println("Acquired");
      try {
        for (int c = 0; c < WINDOW; c++) {
          window[c] = new Object();
        }
      } catch (Throwable t) {
        // omit
      } finally {
        System.out.println("Releasing");
        release(arr);
      }
    }
  }
}

#include <jni.h>
#include "GCLockerTest.h"

static jbyte* sink;

JNIEXPORT void JNICALL Java_GCLockerTest_acquire(JNIEnv* env, jclass klass, jintArray arr) {
sink = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
}

JNIEXPORT void JNICALL Java_GCLockerTest_release(JNIEnv* env, jclass klass, jintArray arr) {
(*env)->ReleasePrimitiveArrayCritical(env, arr, sink, 0);
}

執行該 JNI 程式,可以看到發生的 GC 都是 GCLocker Initiated GC,並且注意在 “Acquired” 和 “Released” 時不可能發生 GC。

GC Locker 可能導致的不良後果有:

  • 如果此時是 Young 區不夠 Allocation Failure 導致的 GC,由於無法進行 Young GC,會將物件直接分配至 Old 區。

  • 如果 Old 區也沒有空間了,則會等待鎖釋放,導致執行緒阻塞。

  • 可能觸發額外不必要的 Young GC,JDK 有一個 Bug,有一定的機率,本來只該觸發一次 GCLocker Initiated GC 的 Young GC,實際發生了一次 Allocation Failure GC 又緊接著一次 GCLocker Initiated GC。是因為 GCLocker Initiated GC 的屬性被設為 full,導致兩次 GC 不能收斂。

4.9.3 策略

  • 新增 -XX+PrintJNIGCStalls 引數,可以列印出發生 JNI 呼叫時的執行緒,進一步分析,找到引發問題的 JNI 呼叫。

  • JNI 呼叫需要謹慎,不一定可以提升效能,反而可能造成 GC 問題。

  • 升級 JDK 版本到 14,避免 JDK-8048556 導致的重複 GC。

4.9.4 小結

JNI 產生的 GC 問題較難排查,需要謹慎使用。

5. 總結

在這裡,我們把整個文章內容總結一下,方便大家整體地理解回顧。

5.1 處理流程(SOP)

下圖為整體 GC 問題普適的處理流程,重點的地方下面會單獨標註,其他的基本都是標準處理流程,此處不再贅述,最後在整個問題都處理完之後有條件的話建議做一下覆盤。

  • 制定標準: 這塊內容其實非常重要,但大部分系統都是缺失的,筆者過往面試的同學中只有不到一成的同學能給出自己的系統 GC 標準到底什麼樣,其他的都是用的統一指標模板,缺少預見性,具體指標制定可以參考 3.1 中的內容,需要結合應用系統的 TP9999 時間和延遲、吞吐量等設定具體的指標,而不是被問題驅動。

  • 保留現場: 目前線上服務基本都是分散式服務,某個節點發生問題後,如果條件允許一定不要直接操作重啟、回滾等動作恢復,優先通過摘掉流量的方式來恢復,這樣我們可以將堆、棧、GC 日誌等關鍵資訊保留下來,不然錯過了定位根因的時機,後續解決難度將大大增加。當然除了這些,應用日誌、中介軟體日誌、核心日誌、各種 Metrics 指標等對問題分析也有很大幫助。

  • 因果分析: 判斷 GC 異常與其他系統指標異常的因果關係,可以參考筆者在 3.2 中介紹的時序分析、概率分析、實驗分析、反證分析等 4 種因果分析法,避免在排查過程中走入誤區。

  • 根因分析: 確實是 GC 的問題後,可以藉助上文提到的工具並通過 5 why 根因分析法以及跟第三節中的九種常見的場景進行逐一匹配,或者直接參考下文的根因魚骨圖,找出問題發生根因,最後再選擇優化手段。

5.2 根因魚骨圖

送上一張問題根因魚骨圖,一般情況下我們在處理一個 GC 問題時,只要能定位到問題的“病灶”,有的放矢,其實就相當於解決了 80%,如果在某些場景下不太好定位,大家可以藉助這種根因分析圖通過排除法去定位。

5.3 調優建議

  • Trade Off: 與 CAP 註定要缺一角一樣,GC 優化要在延遲(Latency)、吞吐量(Throughput)、容量(Capacity)三者之間進行權衡。

  • 最終手段: GC 發生問題不是一定要對 JVM 的 GC 引數進行調優,大部分情況下是通過 GC 的情況找出一些業務問題,切記上來就對 GC 引數進行調整,當然有明確配置錯誤的場景除外。

  • 控制變數: 控制變數法是在蒙特卡洛(Monte Carlo)方法中用於減少方差的一種技術方法,我們調優的時候儘量也要使用,每次調優過程儘可能只調整一個變數。

  • 善用搜尋: 理論上 99.99% 的 GC 問題基本都被遇到了,我們要學會使用搜尋引擎的高階技巧,重點關注 StackOverFlow、Github 上的 Issue、以及各種論壇部落格,先看看其他人是怎麼解決的,會讓解決問題事半功倍。能看到這篇文章,你的搜尋能力基本過關了~

  • 調優重點: 總體上來講,我們開發的過程中遇到的問題型別也基本都符合正態分佈,太簡單或太複雜的基本遇到的概率很低,筆者這裡將中間最重要的三個場景新增了“*”標識,希望閱讀完本文之後可以觀察下自己負責的系統,是否存在上述問題。

  • GC 引數: 如果堆、棧確實無法第一時間保留,一定要保留 GC 日誌,這樣我們最起碼可以看到 GC Cause,有一個大概的排查方向。關於 GC 日誌相關引數,最基本的 -XX:+HeapDumpOnOutOfMemoryError 等一些引數就不再提了,筆者建議新增以下引數,可以提高我們分析問題的效率。

  • 其他建議: 上文場景中沒有提到,但是對 GC 效能也有提升的一些建議。

    • 主動式 GC: 也有另開生面的做法,通過監控手段監控觀測 Old 區的使用情況,即將到達閾值時將應用服務摘掉流量,手動觸發一次 Major GC,減少 CMS GC 帶來的停頓,但隨之系統的健壯性也會減少,如非必要不建議引入。

    • 禁用偏向鎖: 偏向鎖在只有一個執行緒使用到該鎖的時候效率很高,但是在競爭激烈情況會升級成輕量級鎖,此時就需要先消除偏向鎖,這個過程是 STW 的。如果每個同步資源都走這個升級過程,開銷會非常大,所以在已知併發激烈的前提下,一般會禁用偏向鎖 -XX:-UseBiasedLocking 來提高效能。

    • 虛擬記憶體: 啟動初期有些作業系統(例如 Linux)並沒有真正分配實體記憶體給 JVM ,而是在虛擬記憶體中分配,使用的時候才會在實體記憶體中分配記憶體頁,這樣也會導致 GC 時間較長。這種情況可以新增 -XX:+AlwaysPreTouch 引數,讓 VM 在 commit 記憶體時跑個迴圈來強制保證申請的記憶體真的 commit,避免執行時觸發缺頁異常。在一些大記憶體的場景下,有時候能將前幾次的 GC 時間降一個數量級,但是新增這個引數後,啟動的過程可能會變慢。

6. 寫在最後

最後,再說筆者個人的一些小建議,遇到一些 GC 問題,如果有精力,一定要探本窮源,找出最深層次的原因。另外,在這個資訊氾濫的時代,有一些被“奉為圭臬”的經驗可能都是錯誤的,儘量養成看原始碼的習慣,有一句話說到“原始碼面前,了無祕密”,也就意味著遇到搞不懂的問題,我們可以從原始碼中一窺究竟,某些場景下確有奇效。但也不是隻靠讀原始碼來學習,如果硬啃原始碼但不理會其背後可能蘊含的理論基礎,那很容易“撿芝麻丟西瓜”,“只見樹木,不見森林”,讓“了無祕密”變成了一句空話,我們還是要結合一些實際的業務場景去針對性地學習。

你的時間在哪裡,你的成就就會在哪裡。筆者也是在前兩年才開始逐步地在 GC 方向上不斷深入,查問題、看原始碼、做總結,每個 Case 形成一個小的閉環,目前初步摸到了 GC 問題處理的一些門道,同時將經驗總結應用於生產環境實踐,慢慢地形成一個良性迴圈。

本篇文章主要是介紹了 CMS GC 的一些常見場景分析,另外一些,如 CodeCache 問題導致 JIT 失效、SafePoint 就緒時間長、Card Table 掃描耗時等問題不太常見就沒有花太多篇幅去講解。Java GC 是在“分代”的思想下內捲了很多年才突破到了“分割槽”,目前在美團也已經開始使用 G1 來替換使用了多年的 CMS,雖然在小的堆方面 G1 還略遜色於 CMS,但這是一個趨勢,短時間無法升級到 ZGC,所以未來遇到的 G1 的問題可能會逐漸增多。目前已經收集到 Remember Set 粗化、Humongous 分配、Ergonomics 異常、Mixed GC 中 Evacuation Failure 等問題,除此之外也會給出 CMS 升級到 G1 的一些建議,接下來筆者將繼續完成這部分文章整理,敬請期待。

“防火”永遠要勝於“救火”,不放過任何一個異常的小指標(一般來說,任何不平滑的曲線都是值得懷疑的) ,就有可能避免一次故障的發生。作為 Java 程式設計師基本都會遇到一些 GC 的問題,獨立解決 GC 問題是我們必須邁過的一道坎。開篇中也提到過 GC 作為經典的技術,非常值得我們學習,一些 GC 的學習材料,如《The Garbage Collection Handbook》《深入理解Java虛擬機器》等也是常讀常新,趕緊動起來,苦練 GC 基本功吧。

最後的最後,再多囉嗦一句,目前所有 GC 調優相關的文章,第一句講的就是“不要過早優化”,使得很多同學對 GC 優化望而卻步。在這裡筆者提出不一樣的觀點,熵增定律(在一個孤立系統裡,如果沒有外力做功,其總混亂度(即熵)會不斷增大)在計算機系統同樣適用,如果不主動做功使熵減,系統終究會脫離你的掌控,在我們對業務系統和 GC 原理掌握得足夠深的時候,可以放心大膽地做優化,因為我們基本可以預測到每一個操作的結果,放手一搏吧,少年!

7. 參考資料

相關文章