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


原文地址:https://tech.meituan.com/2020/11/12/java-9-cms-gc.html

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. 參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM