京東面試:什么是gc尖刺? 怎么解決由于 gc 導(dǎo)致的尖刺?
尼恩說在前面
在40歲老架構(gòu)師 尼恩的讀者交流群(50+)中,最近有小伙伴拿到了一線互聯(lián)網(wǎng)企業(yè)如得物、阿里、滴滴、極兔、有贊、希音、百度、網(wǎng)易、美團的面試資格,遇到很多很重要的面試題:
- 什么是gc尖刺? 怎么 解決由于 gc 導(dǎo)致的 尖刺?
- GC 毛刺見過嗎, 如何排查?
最近有小伙伴在面試京東、 阿里、希音等大廠,又遇到了相關(guān)的面試題。
小伙伴 沒系統(tǒng)梳理, 支支吾吾的說了幾句,面試官不滿意, 掛了。
特別說明:
GC 毛刺 很多的場景。
下面的大廠 案例, 介紹的是 一個場景,這個場景是: 大規(guī)模的長命小對象 , 在 新生代 ygc 頻繁復(fù)制,導(dǎo)致的GC 毛刺。
特別說明:
其他的GC 毛刺 場景 后面尼恩再找一些案例來進行展示。
本文的案例 ,以及其他的gc 毛刺案例, 都來自互聯(lián)網(wǎng),不是尼恩原創(chuàng) 案例。
如果原作者不愿意 尼恩用來作為 學(xué)習(xí)材料 放在公眾號, 可以找尼恩反饋,尼恩立即從本公眾號撤下來。
一、什么是gc尖刺?
GC尖刺(Garbage Collection Spike) ,有時也被稱為GC毛刺或GC突刺 , 并不是某個官方術(shù)語,而是線上運維的“體感”說法。
大概意思是: 在一條本來平穩(wěn)的 RT(響應(yīng)時間)或 CPU 曲線上,突然豎起一根像刺一樣的尖峰,持續(xù)時間從幾十毫秒到幾秒不等,看上去很多 突刺。
gc尖刺 根因 是: 垃圾回收器在某一刻發(fā)生了長時間停頓(Stop-The-World,簡稱 STW)。
由于Stop-The-World(STW) 暫停,導(dǎo)致應(yīng)用程序 RT(響應(yīng)時間)或 CPU 曲線上 出現(xiàn)的突然而顯著的峰值。
簡單來說,就是GC過程中,JVM會暫停所有應(yīng)用線程來執(zhí)行垃圾回收,如果這次暫停時間過長,就會像路上的突然堵車一樣,導(dǎo)致系統(tǒng)性能出現(xiàn)瞬間的“卡頓”。
GC尖刺的背后,往往是內(nèi)存管理不當(dāng)或垃圾回收器配置不佳。
一些典型GC尖刺誘因:
1、內(nèi)存分配問題
- 短命大對象:在循環(huán)或高頻方法中持續(xù)創(chuàng)建大對象(如大的數(shù)組、集合),這些對象可能迅速占滿新生代,導(dǎo)致Minor GC頻繁,且每次回收耗時增加。更糟的是,如果大對象過早晉升到老年代,還會引發(fā)不必要的Full GC,導(dǎo)致gc 尖刺。
- 內(nèi)存泄漏:由于代碼缺陷(如未清理的靜態(tài)集合、未關(guān)閉的資源、
ThreadLocal使用不當(dāng)),導(dǎo)致對象無法被回收。老年代內(nèi)存被無效對象逐漸填滿,最終觸發(fā)長時間停頓的Full GC,但回收效果甚微,內(nèi)存使用率居高不下,導(dǎo)致gc 尖刺。 - 大規(guī)模的長命小對象在年輕代復(fù)制: 本文的例子中,出現(xiàn)了 大規(guī)模長命小對象(約 500MB),在年輕代的 eden和 幸存者區(qū)來回復(fù)制,導(dǎo)致gc 尖刺。
2、垃圾回收器配置與選擇
- 堆內(nèi)存設(shè)置不合理:堆內(nèi)存過小會導(dǎo)致GC頻繁發(fā)生;堆內(nèi)存過大則會使單次GC需要處理的數(shù)據(jù)量增多,可能導(dǎo)致STW時間變長。
- GC參數(shù)不匹配:例如,G1垃圾回收器的
MaxGCPauseMillis(預(yù)期最大停頓時間)設(shè)置過小,可能會迫使GC更頻繁地工作以試圖達(dá)到目標(biāo),反而影響整體吞吐量并可能引發(fā)問題。 - GC器選擇不當(dāng):像ZGC和ShenandoahGC這類低停頓回收器,雖然STW時間極短,但在高吞吐量計算密集型場景下,其并發(fā)執(zhí)行會與業(yè)務(wù)線程競爭CPU資源,可能導(dǎo)致整體響應(yīng)時間上升和周期性尖刺。
3、其他問題:如系統(tǒng)資源與外部因素
- 日志打印過量:大量同步日志寫入會爭搶磁盤I/O鎖,導(dǎo)致線程阻塞。同時,日志文件快速增大觸發(fā)的滾動清理操作也會消耗大量CPU和I/O資源,間接引發(fā)或加劇GC壓力。
- 定時任務(wù)處理大數(shù)據(jù)集:定時任務(wù)一次性加載和處理大量數(shù)據(jù)(如從數(shù)據(jù)庫撈出數(shù)十萬條記錄),會在短時間內(nèi)產(chǎn)生海量對象,給GC帶來巨大壓力。
GC尖刺的危害:
GC尖刺的危害是直接且嚴(yán)重的,尤其在高并發(fā)、低延遲要求的系統(tǒng)中:
- 接口響應(yīng)時間劇烈抖動:最直接的表現(xiàn)就是應(yīng)用服務(wù)的P99、P999延遲(如99%或99.9%請求的響應(yīng)時間)出現(xiàn)周期性或突發(fā)性的尖峰,導(dǎo)致用戶體驗下降。
- 系統(tǒng)吞吐量下降:頻繁且長時間的GC會占用大量系統(tǒng)資源(CPU資源被大量用于垃圾回收而非業(yè)務(wù)處理),導(dǎo)致系統(tǒng)整體處理能力(QPS/TPS)降低。
- 上游調(diào)用超時與故障擴散:若GC導(dǎo)致服務(wù)響應(yīng)超時,可能引起上游調(diào)用方(如網(wǎng)關(guān)、其他微服務(wù))連鎖超時失敗,在分布式系統(tǒng)中可能引發(fā)雪崩效應(yīng)。
二、問題復(fù)盤
在高并發(fā)、低延遲的服務(wù)中,GC 的行為會直接影響服務(wù)的響應(yīng)時間和穩(wěn)定性。
本文場景 討論的場景, 是 源于一個真實的大廠 高并發(fā)系統(tǒng)(系統(tǒng)A),該系統(tǒng)的 QPS 日常在十萬級別,大促期間甚至?xí)^ 40W,且對響應(yīng)時間有毫秒級的嚴(yán)格要求。
任何由于GC 垃圾回收引起的停頓, 都可能導(dǎo)致超時和業(yè)務(wù)成功率下降。
image-20251011101749451
在大促期間(QPS 40W),的巡檢監(jiān)控中,發(fā)現(xiàn)上游調(diào)用方出現(xiàn)零星超時告警。
通過監(jiān)控系統(tǒng)定位到系統(tǒng)A在特定時段出現(xiàn)了周期性響應(yīng)時間毛刺(如下圖所示),這些毛刺與GC日志中的Full GC時間點高度吻合,初步判斷是GC停頓引發(fā)了服務(wù)抖動。
圖片
問題根因:大規(guī)模 長生命小對象引發(fā)新生代復(fù)制風(fēng)暴。
先說結(jié)論:我們發(fā)現(xiàn)系統(tǒng)A中緩存了一批業(yè)務(wù)索引數(shù)據(jù),這些數(shù)據(jù)具有以下特點。
- 大規(guī)模 小對象: 體積小(每個對象幾KB至幾十KB)、總量大(約500MB)
- 長生命對象 :一旦加載,長時間存活(通常貫穿整個服務(wù)生命周期)
- 在業(yè)務(wù)邏輯中頻繁被使用
默認(rèn)情況下,這些對象會在新生代的 Eden 區(qū)創(chuàng)建,由于存活時間長,它們會在 Survivor 區(qū)來回復(fù)制,直至年齡達(dá)到閾值后才被晉升到老年代。
在這個過程中,會產(chǎn)生兩方面開銷:
(1) 復(fù)制開銷:大規(guī)模對象在 Survivor 區(qū)之間來回復(fù)制,CPU 消耗顯著;
(2) 晉升開銷:對象年齡達(dá)到閾值時,批量復(fù)制到老年代,容易引發(fā)停頓。
尤其是在 Survivor 區(qū)空間不足或?qū)ο髲?fù)制頻率較高時,Young GC 耗時明顯增加,嚴(yán)重時甚至?xí)|發(fā)提前晉升或直接進入老年代,引發(fā)了GC尖刺問題。
解決這類問題,大致分為以下三步處理:
- 排查問題:定位根因
- 分析問題:找到解決方案
- 優(yōu)化過程:解決問題
優(yōu)化的思路: 盡早晉升,也就是 讓 大規(guī)模的長命小對象(業(yè)務(wù)索引數(shù)據(jù))盡早晉升到老年代, 或者 讓索引直接分配到老年代,從而加速 加速索引復(fù)制。 當(dāng)然, 也會考慮 升級 GC , 升級通過 斷流發(fā)布 +主動預(yù)熱 規(guī)避GC。 接下來和大家一一介紹。
在不加一臺機器、不改變流量大小的前提下,系統(tǒng)成功率(抖動時)逐步優(yōu)化效果為:95% => 98% => 99.5% => 99.995%,保障系統(tǒng)高可用。
下面將詳細(xì)介紹整個排查和優(yōu)化過程。
三、排查過程
1、初步常規(guī)分析
首先從上游業(yè)務(wù)報警入手,發(fā)現(xiàn)報錯均為同步調(diào)用超時(TimeoutException),因此聚焦系統(tǒng)A自身狀態(tài),開展第一輪排查:
- 對比故障時間點前后流量監(jiān)控,未見明顯峰值,CPU使用率和系統(tǒng)負(fù)載均處于正常水位,可排除流量激增導(dǎo)致過載的可能。
- 系統(tǒng)A為純內(nèi)存計算型服務(wù),無數(shù)據(jù)庫、緩存或RPC調(diào)用,不存在外部組件拖慢整體響應(yīng)的因素。
- 系統(tǒng)雖高并發(fā),但請求間無同步互斥邏輯,不存在分布式鎖或線程鎖競爭導(dǎo)致的阻塞超時。
經(jīng)過首輪排查,已排除流量激增、外部服務(wù)有瓶頸、并發(fā)鎖等可能影響因素,但并未定位到根因,需進一步向內(nèi)挖掘系統(tǒng)自身狀態(tài)。
2、定位根因
在排除常規(guī)疑點后,我們開始查看系統(tǒng)內(nèi)部日志與監(jiān)控,發(fā)現(xiàn)關(guān)鍵日志證據(jù):發(fā)現(xiàn)系統(tǒng)在抖動發(fā)生前執(zhí)行了一次索引發(fā)布。(熱更新),如下圖所示:
圖片
說明:該系統(tǒng)每隔15分鐘會全量替換內(nèi)存中的業(yè)務(wù)索引(一個約500MB的復(fù)雜Map結(jié)構(gòu)),此過程瞬間產(chǎn)生大量新對象。
檢查對應(yīng)時間點的GC日志,發(fā)現(xiàn)Young GC耗時異常,其中Object Copy階段耗時超過200ms,如下圖:
圖片
Object Copy是YGC的關(guān)鍵階段:存活對象會從Eden區(qū)復(fù)制到Survivor區(qū)(或晉升老年代)。大量存活對象導(dǎo)致復(fù)制開銷陡增,引發(fā)GC尖刺。
定位根因:系統(tǒng)在索引發(fā)布后,新生成的索引對象在Young GC中反復(fù)復(fù)制,且由于對象數(shù)量大、存活時間長,導(dǎo)致Copy階段STW過長,業(yè)務(wù)線程暫停,上游超時增多。
也就是:大規(guī)模長生命對象在新生代頻繁復(fù)制,引起GC停頓放大,最終導(dǎo)致服務(wù)超時。
四、問題的定位與分析
1、常規(guī)優(yōu)化思路分析
面對GC暫停時間過長的問題,通常有以下幾種優(yōu)化思路:
image-20251011164004333
然而,在本次場景中,上述常規(guī)方案大多難以直接應(yīng)用或效果有限。
原因如下:
- 首先,經(jīng)過細(xì)致排查,代碼層面并未發(fā)現(xiàn)明顯缺陷,索引結(jié)構(gòu)也已高度壓縮,沒有進一步優(yōu)化的空間。同時,受限于業(yè)務(wù)特性,索引更新機制必須采用全量替換,無法實現(xiàn)增量更新。
- 其次,單純增加機器數(shù)量雖然可以通過分流請求來減少單機在STW期間影響的請求量,但這本質(zhì)上是一種規(guī)避而非解決,不僅無法從根本上消除GC停頓,還會造成資源利用率下降和成本上升。
- 此外,堆外內(nèi)存方案雖然能規(guī)避GC管理,但需要頻繁的序列化和反序列化操作。在高并發(fā)訪問的場景下,這部分額外開銷對延遲的影響無法忽視,與系統(tǒng)所需的毫秒級響應(yīng)目標(biāo)相悖。
因此,綜合評估后,我們決定將優(yōu)化重點放在JVM參數(shù)調(diào)優(yōu)上:通過精細(xì)調(diào)整垃圾回收器的行為模式,優(yōu)化內(nèi)存分配和晉升策略,盡可能降低大規(guī)模對象復(fù)制帶來的負(fù)面影響,從而在現(xiàn)有架構(gòu)下保障服務(wù)的高可用性。
2、GC日志深度解析與根因推演
基于前期分析,問題的核心在于YGC的Object Copy階段:大規(guī)模索引對象的復(fù)制操作耗時過長,導(dǎo)致STW時間增加,進而引發(fā)上游請求超時。本節(jié)將通過詳細(xì)分析GC日志,還原完整的GC行為模式。
當(dāng)前JVM核心參數(shù)配置如下:
-Xms12g -Xmx12g # 堆內(nèi)存固定為12GB
-XX:MetaspaceSize=512m # 元空間初始大小512MB
-XX:MaxMetaspaceSize=512m # 元空間最大限制512MB
-XX:+UseG1GC # 使用G1垃圾回收器
-XX:G1HeapReginotallow=16M # 設(shè)置Region大小為16MB
-XX:MaxGCPauseMillis=100 # 目標(biāo)最大暫停時間100ms
-XX:InitiatingHeapOccupancyPercent=45 # 老年代占用45%時啟動混合GC
-XX:+HeapDumpOnOutOfMemoryError # OOM時生成堆轉(zhuǎn)儲
-XX:MaxDirectMemorySize=1g # 最大直接內(nèi)存限制1GB通過內(nèi)部監(jiān)控平臺ATP對GC日志進行可視化分析,下圖中標(biāo)出了各 GC 事件的時間點和變化曲線:
圖片
圖中清晰展示了以下關(guān)鍵信息:
① 藍(lán)色圓點(YGC事件):每個圓點代表一次Young GC事件。圖中可見大量密集分布的藍(lán)點,表明YGC發(fā)生頻率高且耗時極短(毫秒級),能夠迅速完成年輕代垃圾回收——這是高吞吐、低延遲系統(tǒng)的理想表現(xiàn),符合預(yù)期。
② 粉色折線(堆內(nèi)存占用):該折線反映堆內(nèi)存使用量的動態(tài)變化,呈現(xiàn)規(guī)律的鋸齒形態(tài)——快速上升后驟降。這種模式符合預(yù)期:由于系統(tǒng)流量大,請求處理過程中會持續(xù)產(chǎn)生大量短期存活的臨時對象,使內(nèi)存占用快速上升;當(dāng)Eden區(qū)空間不足時觸發(fā)YGC,迅速回收這些對象,使內(nèi)存占用回落至低點。
③ 異常藍(lán)點(長耗時YGC):部分藍(lán)點明顯遠(yuǎn)離橫軸,表示這些YGC的耗時顯著高于正常水平。這些點與之前在日志中手動識別出的長耗時記錄相符,是導(dǎo)致服務(wù)抖動的直接原因,需要重點關(guān)注。
④ 紫色折線(老年代占用):該曲線反映老年代內(nèi)存使用情況。正常情況下,因絕大多數(shù)對象在年輕代就被回收,老年代占用率增長緩慢。但值得關(guān)注的是,每次長耗時YGC出現(xiàn)時,紫色折線都呈現(xiàn)明顯的階梯式躍升,表明此時有大量對象晉升至老年代,這一現(xiàn)象需要重點關(guān)注。
進一步觀察發(fā)現(xiàn),長耗時YGC總是成對出現(xiàn),且具有“第一次晉升量少、第二次晉升量多”的規(guī)律,如下圖所示:
圖片
綜合所有線索,可以完整推演出問題發(fā)生的過程:
階段一:系統(tǒng)創(chuàng)建新索引對象(約500MB),這些對象被分配在Eden區(qū)
階段二:Eden區(qū)空間不足觸發(fā)第一次YGC,新索引作為存活對象被復(fù)制到Survivor區(qū),大量對象復(fù)制導(dǎo)致STW長達(dá)200ms+
階段三:業(yè)務(wù)代碼完成索引切換,將GcRoot指向新索引,同時斷開舊索引引用
階段四:再次觸發(fā)YGC,新索引從Survivor區(qū)晉升到老年代,舊索引被回收,再次產(chǎn)生200ms+ STW
階段五:新索引穩(wěn)定存在于老年代,后續(xù)YGC只需處理小對象,恢復(fù)毫秒級響應(yīng)
通過這一分析,我們準(zhǔn)確定位了GC尖刺的根本原因:大規(guī)模長生命周期對象在年輕代經(jīng)歷了兩次完整的復(fù)制過程(Survivor區(qū)復(fù)制和老年代晉升),導(dǎo)致雙倍的STW停頓時間。
image-20250929131905130
五、優(yōu)化過程
在明確了問題根源——每次新建的大索引對象在年輕代中經(jīng)歷多次復(fù)制,引發(fā)長時間 Young GC 停頓——之后,
我們圍繞減少復(fù)制次數(shù)、降低暫停時間的目標(biāo),設(shè)計了如下優(yōu)化方案。
image-20251011171830395
1.策略一:讓索引盡早晉升至老年代
默認(rèn)情況下,新創(chuàng)建的對象在經(jīng)歷一定次數(shù)的 Young GC 后才會晉升到老年代。我們的核心思路是改寫這個流程,讓大索引以最快路徑進入老年代,避免在年輕代中反復(fù)復(fù)制。
1.1 調(diào)整晉升閾值:MaxTenuringThreshold
MaxTenuringThreshold參數(shù)用于設(shè)定對象在晉升至老年代前,能在年輕代中經(jīng)歷的最大 GC 次數(shù)。默認(rèn)值通常為 15,意味著對象需要在 Survivor 區(qū)之間來回拷貝多次,才有可能晉升。
通過分析線上 GC 日志,我們發(fā)現(xiàn) G1GC 對大型索引對象進行了優(yōu)化(Direct Tenuring)。該索引的實際流轉(zhuǎn)路徑為:Eden → S0 → Old,僅經(jīng)歷了 2 次復(fù)制,而非默認(rèn)的 15 次。這相當(dāng)于 JVM 自動將 MaxTenuringThreshold動態(tài)調(diào)整為了 1,其過程如下:
階段一:新索引在 Eden 區(qū)創(chuàng)建,年齡(Age)為 0。
階段二:發(fā)生第一次 Young GC,索引存活。由于當(dāng)前年齡(0)小于閾值(1),索引從 Eden 被復(fù)制到 S0 區(qū),年齡增長為 1。
階段三:發(fā)生第二次 Young GC,索引依然存活。此時年齡(1)等于閾值(1),索引被復(fù)制到 Old 區(qū),完成晉升。
我們嘗試手動設(shè)置 -XX:MaxTenuringThreshold=1進行驗證,GC 日志證實索引的流轉(zhuǎn)路徑仍是 Eden → S0 → Old。

能不能進一步的優(yōu)化?
很容易想到,能否將閾值設(shè)置為 0,讓索引在第一次 GC 時就直接從 Eden 晉升到 Old,完全跳過 Survivor 區(qū)?
這樣,復(fù)制次數(shù)將從 2 次降為 1 次,預(yù)計暫停時間可減少近一半。
將參數(shù)修改為 -XX:MaxTenuringThreshold=0后,流程變?yōu)椋?/span>
階段一:新索引在 Eden 區(qū)創(chuàng)建,年齡為 0。
階段二:發(fā)生 Young GC,索引存活。由于當(dāng)前年齡(0)已等于閾值(0),索引被直接復(fù)制到 Old 區(qū)。
實驗結(jié)果的 GC 日志顯示,Young GC 后年輕代的使用量驟降至接近零,證明大型索引已被直接晉升到老年代,優(yōu)化生效。
圖片
優(yōu)化效果總結(jié):
此優(yōu)化在不修改任何業(yè)務(wù)代碼、不增加硬件成本的前提下,通過調(diào)整一個 JVM 參數(shù),便將因索引切換導(dǎo)致的長暫停 GC 次數(shù)減半。
從系統(tǒng)監(jiān)控來看,索引切換期間的報錯量顯著減少,服務(wù)成功率從 95% 提升至 98%。
image-20251011172819461
1.2 其他相關(guān)參數(shù)實驗
在通過 MaxTenuringThreshold成功優(yōu)化后,我們進一步探索了其他能控制對象晉升策略的參數(shù),以尋求更優(yōu)解或替代方案。
1) InitialTenuringThreshold
此參數(shù)與 MaxTenuringThreshold作用類似,用于設(shè)定對象晉升的初始年齡閾值。
實測表明,設(shè)置 -XX:InitialTenuringThreshold=1同樣可以將索引的復(fù)制次數(shù)從 2 次降為 1 次,優(yōu)化效果與 MaxTenuringThreshold=0類似,都能有效提升系統(tǒng)穩(wěn)定性。
圖片
2) AlwaysTenure
這是一個更為極端的參數(shù),其字面含義是“總是晉升”。
開啟后,所有在 Young GC 中存活的對象都會直接晉升到老年代,完全跳過 Survivor 區(qū)。
實測設(shè)置 -XX:+AlwaysTenure后,同樣達(dá)到了減少一次復(fù)制的效果。其對象流轉(zhuǎn)路徑可概括為:
flowchart LR
A[Eden] -- YGC / 存活 --> B[Old]
關(guān)于 AlwaysTenure 的說明:
- 多次 GC 現(xiàn)象:因為索引對象龐大,而 Eden 區(qū)剩余空間有限,其構(gòu)建過程可能橫跨多次 Young GC。每次 GC 都會將已構(gòu)建完成的部分索引直接晉升至老年代。因此,圖中顯示經(jīng)歷了 3 次 YGC 才將完整索引搬到老年代,這與“減少單次晉升的復(fù)制次數(shù)”的結(jié)論并不矛盾。它只是將原本需要在 2 次 GC 中完成的兩次復(fù)制,變成了在 3 次 GC 中完成的三次直接晉升(每次復(fù)制一次)。
- 設(shè)計思想:
AlwaysTenure的設(shè)計理念是禁用 Survivor 區(qū),僅使用 Eden 和 Old 區(qū)。與之相反的參數(shù)是-XX:+NeverTenure,它會試圖讓對象永遠(yuǎn)留在年輕代,禁用 Old 區(qū)。兩者都是非常極端的策略,僅適用于特定的業(yè)務(wù)場景。 - 對老年代的影響:通常,降低晉升閾值會讓更多短期存活的對象進入老年代,從而增加 Full GC 的風(fēng)險。但我們的業(yè)務(wù)場景特殊性在于對象存活時間兩極分化:RPC 請求產(chǎn)生的臨時對象生命周期極短(毫秒級),而索引對象生命周期極長(數(shù)十分鐘以上)。因此,在 Young GC 時,臨時對象早已被回收,而索引對象是唯一需要被晉升的。修改這些參數(shù)并不會導(dǎo)致大量本應(yīng)被回收的短命對象進入老年代,故不會增加 Full GC 的負(fù)擔(dān)。
2.嘗試讓索引直接分配至老年代
通過上面調(diào)整晉升策略,我們成功地將索引的復(fù)制次數(shù)從 2 次減少到 1 次。一個更極端的想法隨之產(chǎn)生:能否讓索引在創(chuàng)建時就直接分配在老年代,實現(xiàn) 0 次復(fù)制,從而從根本上避免由復(fù)制引起的停頓?
這個理想的分配路徑如下圖所示:
image-20251011173847017
圍繞此目標(biāo),我們進行了以下兩種嘗試,但均未取得預(yù)期效果。
2.1 嘗試一:PretenureSizeThreshold
PretenureSizeThreshold是一個經(jīng)典的 JVM 參數(shù),旨在讓大于指定大小的對象直接在老年代分配,以避免在年輕代發(fā)生昂貴的復(fù)制操作。
遺憾的是,該參數(shù)在 G1 垃圾收集器下是不生效的。調(diào)整此參數(shù)后,通過監(jiān)控發(fā)現(xiàn)系統(tǒng)穩(wěn)定性指標(biāo)并無改善,GC 日志也顯示索引依然在年輕代中創(chuàng)建和流轉(zhuǎn)。
2.2 嘗試二:G1HeapRegionSize 與 Humongous Object
G1GC 有一個內(nèi)置機制用于處理大對象(Humongous Object)。它將堆劃分為多個大小相等的 Region(區(qū)域)。當(dāng)一個對象的大小超過單個 Region 容量的一半時,它就會被視為 Humongous Object,并被直接分配在老年代的特殊區(qū)域中。
理論上,通過調(diào)整 -XX:G1HeapRegionSize可以控制 Region 的大小,從而讓我們的索引滿足 Humongous Object 的條件。
我們增大了 G1HeapRegionSize以確保索引整體大小超過其一半,但優(yōu)化后系統(tǒng)在索引切換時依然出現(xiàn)抖動。分析 GC 日志,索引的分配路徑依然是 Eden → Survivor → Old,并未被識別為 Humongous Object。
根本原因在于索引對象的物理結(jié)構(gòu)與邏輯結(jié)構(gòu)上
- 邏輯結(jié)構(gòu):從業(yè)務(wù)視角看,我們有一個約 500MB 的“大索引對象”。
- 物理結(jié)構(gòu):但從 JVM 的內(nèi)存分配視角看,這個“大索引”實際上是由上百萬個獨立的小對象(如 Map Entry、自定義數(shù)據(jù)結(jié)構(gòu)等)在程序運行過程中逐個構(gòu)建而成的。JVM 每次通過
new關(guān)鍵字分配的是這些小型個體對象,它們的大小遠(yuǎn)小于G1HeapRegionSize的一半,因此完全符合在 Eden 區(qū)分配的條件。
除非是像 int[] arr = new int[1000000000];這樣,在代碼層面明確聲明分配的、單一的、巨大的連續(xù)數(shù)組,JVM 才能在一次分配中就識別出其大小并將其直接作為 Humongous Object 處理。
對于由海量小對象聚合而成的邏輯大對象,無法通過調(diào)整標(biāo)準(zhǔn) JVM 參數(shù)讓其直接在老年代分配。此優(yōu)化路徑在當(dāng)前業(yè)務(wù)代碼結(jié)構(gòu)下不可行。
3.策略三:加速索引的復(fù)制過程
在不改變復(fù)制次數(shù)的情況下,我們嘗試通過調(diào)整 GC 相關(guān)參數(shù)來提升復(fù)制速度。
參數(shù)名 | 作用 | 實測效果 |
| 設(shè)置 G1 的目標(biāo)最大停頓時間 | 效果不明顯。目標(biāo)停頓僅是期望,無法突破物理限制 |
| 設(shè)置 STW 階段并行 GC 的線程數(shù) | 效果不明顯。默認(rèn)值已接近核心數(shù),優(yōu)化空間小 |
| 設(shè)置并發(fā)標(biāo)記階段的線程數(shù) | 對本問題中的 Young GC 暫停時間無直接影響 |
由于索引復(fù)制本身是一個內(nèi)存密集型操作,受限于硬件和內(nèi)存帶寬,單純調(diào)整線程數(shù)或目標(biāo)停頓時間收效甚微。
4.策略四:低停頓收集器 ZGC
此前基于 G1GC 的優(yōu)化都是在傳統(tǒng)垃圾回收器的框架內(nèi)進行修補。無論是 G1 還是更早的 CMS,其核心停頓(STW)根源在于對象移動階段必須暫停所有應(yīng)用線程。對于需要移動數(shù)百MB存活數(shù)據(jù)的大索引場景,這種停頓幾乎是不可避免的。
JDK 11 引入的 ZGC 旨在從根本上解決這一問題。其核心突破在于引入了著色指針(Colored Pointers) 和讀屏障(Load Barriers) 機制,實現(xiàn)了并發(fā)轉(zhuǎn)移。這意味著 ZGC 可以在應(yīng)用程序線程正常運行的同時,在后臺移動和整理內(nèi)存中的對象。
其工作原理可簡要概括為:
(1) 當(dāng) ZGC 需要移動一個對象時,它開始復(fù)制數(shù)據(jù),但舊地址依然暫時有效。
(2) 任何應(yīng)用程序線程在訪問對象時都會觸發(fā)“讀屏障”。
(3) 讀屏障會檢查該對象是否正在被移動。如果是,它會自動將指針“轉(zhuǎn)發(fā)”到對象的新地址(也就是指針自愈),確保應(yīng)用程序總是訪問到正確的數(shù)據(jù)。
flowchart TD
A[應(yīng)用線程訪問對象] --> B[觸發(fā)讀屏障]
B --> C{對象是否正在移動?}
C -- 是 --> D[由讀屏障處理<br>等待完成或轉(zhuǎn)發(fā)至新地址]
C -- 否 --> E[直接訪問]
D --> F[正常讀取數(shù)據(jù)]
E --> F這與 G1 必須“停止世界→移動對象→更新所有指針→恢復(fù)世界”的串行化流程形成了鮮明對比,理論上的停頓時間優(yōu)勢巨大。
將應(yīng)用升級至 JDK 11 并啟用 ZGC (-XX:+UseZGC) 后,效果立竿見影。服務(wù)成功率進一步提升至 99.5%。
然而,在索引切換的極短時間內(nèi),監(jiān)控系統(tǒng)依然捕捉到了輕微的響應(yīng)時間毛刺(RT尖刺)。分析 ZGC 日志,我們發(fā)現(xiàn)其根源并非長時間的 STW,而是一種稱為 “分配停滯(Allocation Stall)” 的現(xiàn)象。
Allocation Stall:當(dāng)應(yīng)用程序線程試圖分配新對象(如執(zhí)行
new語句),但當(dāng)前堆內(nèi)存中已無足夠的可用空間時,該線程會被迫暫停(“停滯”),直到 ZGC 的垃圾回收周期完成并釋放出足夠的內(nèi)存后,才能繼續(xù)執(zhí)行分配操作。可以通俗地理解為:“線程急著要內(nèi)存,但內(nèi)存沒了,只能停下來等GC打掃完房間再繼續(xù)”。
結(jié)合系統(tǒng)監(jiān)控可以發(fā)現(xiàn),每次索引切換構(gòu)建約 500MB 新對象時,都會引發(fā)一次內(nèi)存占用的瞬時尖峰,而每一次尖峰都精確對應(yīng)了一次服務(wù)的 RT 毛刺。如下圖所示:
ZGC 成功地解決了由對象復(fù)制引發(fā)的長時間 STW 停頓,這是本次優(yōu)化中最顯著的進步。然而,由于索引構(gòu)建會產(chǎn)生瞬時巨大的內(nèi)存分配需求,超出了 ZGC 即時回收的吞吐能力,從而引發(fā)了短暫的 Allocation Stall(分配停滯)。這成為了系統(tǒng)在極致性能追求下,剩余的一個微小但可感知的抖動來源。
六、追求極致:實現(xiàn)索引無感切換的終極方案
經(jīng)過一系列 JVM 層面的調(diào)優(yōu),我們將服務(wù)成功率從最初的 95% 提升至 99.5%,成效顯著。
- MaxTenuringThreshold=0:提升至 95%
- 升級ZGC :提升至99.5%
然而,對于追求極致穩(wěn)定性的系統(tǒng)而言,剩余的 0.5% 的輕微抖動依然是亟待解決的問題。
究其根本,只要大索引的復(fù)制發(fā)生在服務(wù)接流期間,就存在引發(fā)延遲尖刺的風(fēng)險。最終的解決思路不再是“優(yōu)化復(fù)制過程”,而是“讓復(fù)制在無人感知時發(fā)生”。
1.思路轉(zhuǎn)變:從優(yōu)化GC到規(guī)避GC
既然 JVM 層面始終避免不了 1 次大索引復(fù)制,那能否避其鋒芒,新的方案是:進行服務(wù)斷流,在斷流期間主動觸發(fā)并完成索引的復(fù)制晉升過程。待服務(wù)重新接流時,年輕代中已無大對象,后續(xù)所有 Young GC 都將是毫秒級的快速回收。這樣可以根治GC尖刺
運維平臺提供的灰度斷流發(fā)布模式為此方案提供了基礎(chǔ):每次只發(fā)布一批機器,并在其索引加載和切換期間切斷流量,切換完成后再重新接入流量。
然而,僅依靠斷流發(fā)布并不足夠。因為索引的分配不一定會立即觸發(fā) YGC——只有在 Eden 區(qū)空間不足時才會觸發(fā)。
為了更清晰地說明單純依賴“灰度斷流”發(fā)布策略的局限性,我們以一個具體的環(huán)境配置為例進行分析:假設(shè) Eden 區(qū)大小為 3GB,待加載的新索引約為 1GB。索引切換前 Eden 區(qū)的初始占用情況,將直接決定發(fā)布時是否會遇到問題。
Case 1:初始占用低,隱患潛伏
索引切換前,Eden 區(qū)僅占用了 1GB,剩余空間充足。
加載 1GB 新索引后,Eden 區(qū)總占用上升至 2GB,仍未達(dá)到 3GB 的容量上限。因此,整個過程不會觸發(fā) Young GC。
當(dāng)服務(wù)重新接流后,業(yè)務(wù)請求產(chǎn)生的對象會迅速占滿 Eden 區(qū)剩余空間,此時 200ms 長暫停勢必導(dǎo)致業(yè)務(wù)請求超時報錯。如下圖:
image-20251011181411847
在此場景下,斷流發(fā)布沒有起到任何規(guī)避風(fēng)險的作用,系統(tǒng)抖動依然會發(fā)生。
Case 2:初始占用高,部分緩解
索引切換前,Eden 區(qū)已占用較高空間,例如 2.5GB。
在加載 1GB 新索引的過程中,Eden 區(qū)空間很快被耗盡,從而提前觸發(fā)了一次 Young GC。這次 GC 會將新索引的一部分(例如 500MB)復(fù)制到老年代。
這相當(dāng)于將一次大的復(fù)制操作拆分成兩次較小的操作,一定程度上緩解了后續(xù) GC 的停頓時間。但其效果依然不理想:
image-20251011181552791
此場景下,斷流發(fā)布僅能部分緩解問題。復(fù)制操作雖被拆分但未消除,服務(wù)接流后可能仍會有一次較大的停頓。
通過以上分析可見,單純依賴斷流發(fā)布,其效果具有極大的偶然性,嚴(yán)重依賴于發(fā)布時 Eden 區(qū)的初始狀態(tài)。我們將“緩解程度”定義為在斷流期間能提前晉升到老年代的索引比例,那么它與 Eden 區(qū)初始占用的關(guān)系如下圖所示:
image-20251011181750127
如圖所示,只有當(dāng)一個批次的 Eden 初始占用較高(落入右側(cè)紅色區(qū)域)時,該批次的發(fā)布才能獲得部分緩解。這意味著整個發(fā)布過程的效果是不均勻、不可控的。要實現(xiàn)徹底的、確定的“無感切換”,必須引入更主動的干預(yù)手段。
2.終極方案:斷流發(fā)布 + 主動預(yù)熱
前述分析表明,依賴系統(tǒng)自然狀態(tài)是不可靠的。要實現(xiàn)確定的“無感切換”,必須采取主動干預(yù)策略。我們的核心思路是:在可控的斷流窗口期內(nèi),主動制造一次“可控的危機”,強制觸發(fā)一次 Young GC,確保新索引100%在此刻被復(fù)制到老年代。
類似“預(yù)熱”的思路,每次新索引切換后、重新接流前,主動、快速地在 Eden 區(qū)創(chuàng)建大量臨時的、短命的“預(yù)熱對象”,瞬間耗盡 Eden 區(qū)的剩余空間。此舉必然會立即觸發(fā)一次 Young GC。
這次 GC 會帶來兩個確定的結(jié)果:
(1) 回收所有無用的“預(yù)熱對象”。
(2) 將唯一存活的大型對象——新索引,復(fù)制到老年代。
當(dāng)服務(wù)重新接流時,年輕代(Eden 和 Survivor 區(qū))已是“空城”,只剩下即將被快速回收的業(yè)務(wù)小對象,從此再無長停頓之憂。
詳細(xì)流程如下圖所示:
image-20251011182241975
image-20251011182302673
該方案的優(yōu)勢在于,其核心邏輯僅需在關(guān)鍵的索引切換方法中增加一個簡短的循環(huán)即可實現(xiàn),無需復(fù)雜架構(gòu)改造。代碼如下:
public boolean switchIndex(String indexPath){
try {
// 1.【斷流】加載新索引
MyIndex newIndex = loadIndex(indexPath);
// 2.【斷流】索引切換
this.index = newIndex;
// 3.【斷流】Eden 區(qū)預(yù)熱
for (int i = 0; i < 10000; i++) {
char[] tempArr = newchar[524288];
}
// 4.【斷流】通知上層索引切換完成
return true;
// 5.【接流】重新接流,此后 YGC 都會很快
} catch (Exception e) {
return false;
}
}實現(xiàn)注意:預(yù)熱對象的大小需精心設(shè)計。單個對象應(yīng)顯著大于通常的業(yè)務(wù)對象,以快速消耗內(nèi)存,但又必須小于
G1HeapRegionSize的一半(通常為 1MB),以避免被 G1GC 直接當(dāng)作大對象(Humongous Object)分配至老年代,從而繞過年輕代,使預(yù)熱機制失效。
3.效果驗證
部署并啟用“主動預(yù)熱”方案后,我們通過以下三個維度對優(yōu)化效果進行了全面驗證,結(jié)果令人振奮。
1)GC 日志分析:復(fù)制操作被成功前置
對比優(yōu)化前后的 GC 日志,變化一目了然。下圖所示的優(yōu)化后日志清晰記錄了斷流期間發(fā)生的完整過程:
圖片
關(guān)鍵節(jié)點解讀:
① 索引加載:新索引被創(chuàng)建,占據(jù) Eden 區(qū)大部分空間。
② 主動預(yù)熱觸發(fā) YGC:預(yù)熱代碼循環(huán)創(chuàng)建大量臨時對象,迅速耗盡 Eden 區(qū)剩余空間,迫使 JVM 立即觸發(fā)一次 Young GC。
③ 索引晉升:本次 YGC 將新索引(存活對象)完整地復(fù)制到老年代,同時回收所有臨時預(yù)熱對象。
④ 接流后常態(tài):服務(wù)重新接流后,所有的 Young GC 都只用于回收業(yè)務(wù)請求產(chǎn)生的瞬時小對象,耗時均下降至毫秒級,變得快速而平穩(wěn)。
2)系統(tǒng)監(jiān)控:消失的抖動曲線
監(jiān)控系統(tǒng)是最直觀的成效證明。下圖展示了優(yōu)化后一段時間內(nèi)的服務(wù)響應(yīng)時間(RT)監(jiān)控曲線,紅色箭頭標(biāo)注了數(shù)次索引切換事件的發(fā)生時刻。
可以觀察到,在索引切換時,RT 曲線依然平整,幾乎沒有出現(xiàn)任何毛刺或尖峰。這表明索引切換帶來的延遲影響已經(jīng)被完全控制在斷流期內(nèi),對線上業(yè)務(wù)流量做到了真正的“無感”。
3)業(yè)務(wù)指標(biāo):達(dá)到極致穩(wěn)定
最終,一切優(yōu)化都體現(xiàn)在業(yè)務(wù)結(jié)果上。如下圖所示,經(jīng)過本輪徹底優(yōu)化,服務(wù)的日常成功率穩(wěn)定在 99.995% 以上,剩余極少數(shù)的失敗通常源于網(wǎng)絡(luò)抖動等外部因素,GC 引發(fā)的服務(wù)抖動問題已被完全根治。
歷經(jīng)從 JVM 參數(shù)調(diào)優(yōu)到垃圾收集器升級,最終到“發(fā)布策略+主動預(yù)熱”的架構(gòu)與流程優(yōu)化,我們成功地將一個因固有業(yè)務(wù)模式(定期加載大索引)而引發(fā)的性能瓶頸徹底化解,實現(xiàn)了技術(shù)上的極致追求。
七、總結(jié)
本文針對一個高并發(fā)(QPS 10W+)、低延遲(要求毫秒級響應(yīng))、高內(nèi)存壓力(每15分鐘需切換GB級索引)的服務(wù)系統(tǒng),因其頻繁的索引切換導(dǎo)致的GC尖刺和系統(tǒng)抖動問題,進行了一系列從JVM層到架構(gòu)層的深度優(yōu)化實踐。
我們系統(tǒng)地探索了多種解決方案,其效果對比如下:
優(yōu)化手段 | 服務(wù)可用率(抖動時) |
G1GC + 默認(rèn)參數(shù) | 95%(Baseline) |
-XX:MaxTenuringThreshold=0 | 98% |
-XX:InitialTenuringThreshold=1 | 98% |
-XX:+AlwaysTenure | 98% |
ZGC + 默認(rèn)參數(shù) | 99.5% |
G1GC + 灰度斷流 + Eden預(yù)熱 | 99.995% |
至此,未來無論系統(tǒng) QPS 漲到多高、索引體積膨脹到多大、索引切換多么頻繁,系統(tǒng)都能無感切換索引,穩(wěn)定性不再受到任何影響。




























