題幹與適用場景
一個延遲敏感的 Java API 執行於 HotSpot JDK 25,使用 G1,堆積參數為 -Xms12g -Xmx12g。 一次快取功能發布後,請求 p99 每隔 3 到 5 分鐘從 120 毫秒升到 2 至 4 秒。尖峰期間主機 CPU 沒有滿載。監控還顯示:舊生代回收後的占用在兩小時內從 6.1 GiB 升到 9.2 GiB,應用的物件配置率約從 600 MiB/s 升到 1.4 GiB/s。團隊因此把事故稱為「GC 問題」,並準備先把堆積加到 24 GiB。
題目中的數字都是面試假設。候選人需要說明如何證明延遲尖峰是否真的對應 JVM 停頓,怎樣用統一 GC 日誌、Java Flight Recorder(JFR)和有邊界的堆積診斷區分高物件配置率、存活集增長、G1 超大物件、顯式 System.gc() 與非 GC 安全點,並提出可回滾的修復和驗證計畫。重點不是背誦收集器參數,而是建立從 現象、證據、假設、實驗到 SLO 的完整因果鏈。
公開的 Java GC 面試資料把 stop-the-world 與 JVM pause time 列為核心問題;Oracle 的 HotSpot 長停頓 疑難排解資料則直接討論堆積不足、碎片、作業系統活動和顯式 GC。本題據此作為 JVM 效能排障的代表題。 公開來源沒有可靠公司歸屬,因此 companyName 留空。
面試官考察點
第一項訊號是候選人會不會先校準時間軸。請求 p99、JVM GC 日誌、JFR 事件、容器 CPU throttling、磁碟 和宿主機時間都要在同一時間基準上對齊。堆積使用量呈鋸齒形只說明發生過回收,不能證明某一次 3 秒延遲 就是 GC;反過來,G1 有大量並行工作,整個 GC 週期很長也不等於應用被暫停了同樣久。
第二項訊號是能否區分「單次停頓太長」和「總停頓占比太高」。前者要檢查 pause 類型及其階段、回收前後 存活資料、物件複製量和 OS 時間;後者常與物件配置率和回收頻率有關。只看平均 GC 時長會同時掩蓋長尾停頓 和大量短停頓。
第三項訊號是能否從證據選擇修復方向。物件配置壓力應先定位配置熱點;回收後舊生代基線持續上升,要驗證 是否有不再需要但仍可達的物件;Humongous regions 增長要追蹤超過 G1 半個 region 的物件;日誌若標明 System.gc(),要找到呼叫者。給所有現象都套用「加堆積、改停頓目標、換 ZGC」不是診斷。
最後看生產風險意識。JFR 的 heap statistics 會觸發額外 old GC,jcmd GC.class_histogram 和 heap dump 的官方說明也標為高影響,預設 heap dump 還會請求 Full GC。強回答會先收集低開銷證據,在副本、離峰期 或可控環境取得重型證據,並透過同流量 canary 證明修復沒有把停頓換成吞吐、CPU 或記憶體風險。
回答前需要澄清的問題
- 「停頓」具體指什麼? 是伺服器端 handler 延遲、客戶端端到端延遲、執行緒完全不執行,還是只有
部分請求排隊。還要確認尖峰是否集中在同一實例,以及負載平衡、下游和網路是否同時異常。
- 日誌與指標能否精確關聯? 需要同一實例 ID、UTC 時間、JVM 啟動時間和發布版本。若日誌只保留
分鐘級時間,先完善觀測,不能憑兩條曲線「看起來差不多」下結論。
- G1 日誌記錄了什麼? 至少需要 GC ID、cause、pause 類型、回收前後堆積占用、duration,並按需開啟
gc+heap、gc+phases、gc+cpu。不能只有程序 RSS 或堆積百分比。
- 回收後的舊生代基線是否隨相同負載持續上升? 若在快取預熱後穩定,它可能是預期 live set;若繼續
上升且每次回收釋放越來越少,才更像保留或洩漏問題。
- 容器和宿主機是否有限制? 檢查 CPU quota/throttling、swap、page fault、記憶體壓力、磁碟日誌阻塞
和 noisy neighbor。GC 的 wall time 很長而 CPU time 很短,可能是 JVM 沒拿到 CPU 或頁面被換出。
- 發布改變了哪些物件配置與引用關係? 快取容量、TTL、鍵值大小、序列化緩衝區、批次大小、並行度,以及
物件是否被 ThreadLocal、listener、佇列或靜態集合持有。
- 延遲與吞吐目標是什麼? 需要明確 pause p99/max、請求 p99、吞吐、錯誤率、CPU 和記憶體上限。
-XX:MaxGCPauseMillis 是 G1 的目標提示,不是每次停頓的硬保證。
- 能否安全採集重型證據? 若只有一個生產實例,先擴容或導流。不得在事故高峰對 12 GiB 堆積直接執行
heap dump,再把診斷導致的 Full GC 當成原故障。
30 秒回答框架
「我不會先改堆積參數。先按實例和時間戳把請求尖峰與 -Xlog:gc*、JFR 的 jdk.GCPhasePause、CPU throttling、page fault 和下游延遲對齊,證明應用實際暫停了多久。接著把問題拆成三組指標:單次 pause 的類型與階段、單位時間總停頓占比,以及 allocation rate、promotion rate、old-after-GC 和 humongous regions 的趨勢。
如果新快取把物件配置率推高但回收後基線穩定,我會用 JFR 找配置熱點並減少暫存物件;如果 old-after-GC 持續上升,就比較類別直方圖並在受控副本取得 heap dump,找 dominator 和保留路徑;如果 Full GC 前出現 evacuation failure 或大量 humongous region,就檢查並拆分大型陣列、緩衝區和批次;如果 cause 是 System.gc(),就定位呼叫者。每個修復都用相同流量 canary 驗證請求 p99、pause p99/max、總停頓占比、 物件配置率、回收後存活集、CPU、吞吐和錯誤率,符合門檻後再擴大。」
分步驟深入解答
第一步:建立可證偽的時間軸,先證明是不是 GC。
為每個延遲尖峰記錄實例、請求窗口、發布版本和 UTC 時間。把它與統一日誌和 JFR 的 pause 事件按 GC ID 關聯。基礎啟動配置可以記錄帶時間、級別和標籤的 GC 資訊,並保留滾動檔案:
-Xlog:gc*,safepoint:file=/var/log/app/gc-%p.log:time,uptime,level,tags:filecount=10,filesize=100m這是診斷範例,實際路徑、保留期和磁碟預算要按環境調整。JFR 中重點看 jdk.GCPhasePause 的 duration, 同時查看 CPU load、執行緒、socket/file I/O 與 allocation 事件。Oracle 文件強調,對並行收集器而言,GC 週期持續多久不如應用真正停頓的總時長有意義。
做三個結果分支:尖峰與 JVM pause 一一重合,進入 GC 根因分析;JVM pause 很短但 safepoint 很長,檢查 安全點原因和到達安全點前的阻塞;兩者都不重合,轉查 CPU throttling、鎖、I/O、網路和下游。這樣「GC 嫌疑」可以被推翻,而不是成為所有延遲的預設解釋。
第二步:用一組指標描述 GC 症狀,不要只看一條曲線。
至少保留以下時間序列,並按同一負載窗口比較發布前後:
請求:p50 / p95 / p99 / max、吞吐、逾時、錯誤率
停頓:pause p50 / p95 / p99 / max、每分鐘總停頓時間、pause cause
堆積:young / old 使用量、old-after-GC、回收量、promotion rate
配置:bytes/s、按類別與執行緒的主要配置點、TLAB 內外配置
G1:young / mixed / Full 次數、evacuation failure、humongous regions
系統:程序 CPU、GC CPU、CPU throttling、RSS、swap、major page fault、磁碟延遲回收前後格式 used-before → used-after (heap-capacity) 能回答本次回收釋放了多少,但一個點不等於趨勢。 old-after-GC 隨相同負載單調上升,表示 live set 或晉升壓力在增長;基線穩定但回收越來越頻繁,通常更 接近高物件配置率。gc+cpu=info 中 real time 遠大於 user+sys 時,要優先調查排程、限額或換頁,不要繼續 增加 GC 執行緒。
第三步:按證據區分五條根因路徑。
- 高物件配置率。 old-after-GC 穩定,young pause 次數和每分鐘總停頓時間上升,JFR allocation 事件集中
在新快取的鍵建構、序列化或暫存集合。先減少複製、重用有明確生命週期的緩衝區、控制批次大小,不能 盲目物件池化並引入共享狀態。
- 存活集增長或記憶體洩漏。 old-after-GC 持續上升、回收收益下降。先在多個時點比較類別直方圖;
只有在離峰副本或流量回放環境才抓 heap dump,用 retained size、dominator tree 和 GC roots 區分合理 快取與無界保留。快取達到容量後穩定屬於配置問題;失效物件仍被引用才是洩漏。
- G1 超大物件。 G1 把大小大於或等於半個 region 的物件視為 humongous,並直接放入連續的 old
regions。若 Humongous regions: X → Y 高企,追蹤大型 byte[]、char[]、壓縮塊或批次;優先縮小 單一物件和批次。調整 region size 只在測量證明有效時作為實驗,因為它也改變 region 數量和回收粒度。
- 並行標記來不及與 evacuation failure。 若日誌出現 destination space 不足、region 無法搬遷,
最壞情況會進入整個堆積的 Full GC。應降低舊生代物件配置/晉升,為並行標記保留餘量,檢查堆積與 live set 的安全間隔;不能只把 MaxGCPauseMillis 調得更小,迫使每次做更少工作後讓系統更接近耗盡。
- 顯式 GC 或非 GC 安全點。 GC cause 若為
System.gc(),透過呼叫堆疊、相依套件和維運命令找到
來源,再決定刪除呼叫、修改套件配置或隔離任務。DisableExplicitGC 只能在確認語意影響後使用。若 safepoint 長但 GC pause 短,檢查反最佳化、類別重定義或其他 VM operation 等實際 cause,不能靠換收集器解決。
第四步:分層使用診斷工具,控制觀測風險。
先用持續低成本層:應用 SLI、統一 GC/safepoint 日誌和標準 JFR。需要即時確認時,可用同一使用者在同機 執行 jcmd PID JFR.start、JFR.check 和 JFR.dump;具體命令先以 jcmd PID help COMMAND 核對目前 JVM 支援的選項。
再進入高成本層。官方 jcmd 文件把 GC.classhistogram 標為高影響;GC.heapdump 同樣是高影響, 且預設會請求 Full GC。JFR 的 heap statistics 也會在開始和結束觸發額外 old GC。因此排查延遲時不要 預設打開 heap statistics。先導流或在副本重現,確認磁碟空間與敏感資料處理,再採集直方圖或 heap dump。 比較兩份相隔穩定負載窗口的證據,比只看一份「最大物件清單」更能說明增長來源。
第五步:修程式碼、容量或收集器參數時保持單一因果。
本題首先審查新快取:是否無界、TTL 是否真正生效、權重是否按位元組而非項目、value 是否複製大型陣列、 並行 miss 是否同時建構相同物件。程式碼修復可能是限制最大權重、合併同 key 的並行載入、串流序列化、 縮小批次,或讓暫存物件更快失去引用。每次實驗只改變一個主要變數。
加堆積可以增加 GC 間隔,卻也可能掩蓋無界保留、提高容器記憶體風險,並讓需要處理的 live data 更多。 修改 IHOP、young generation、region size 或停頓目標前,先指出日誌中哪個階段或資源不足會被該參數 改變。換 ZGC 之類的收集器是架構級實驗,需要重新驗證吞吐、CPU、堆積餘量、容器限制和維運能力,不能 作為第一條事故處置命令。
第六步:用同流量 canary 和反事實驗證關閉問題。
用生產形狀的請求、物件大小、快取命中率和並行度回放基線版本與修復版本。canary 至少跨越原來多個 3 至 5 分鐘尖峰週期,並加入快取冷啟動和穩定態。驗收門檻在實驗前寫明,例如:請求 p99 小於 200ms、 GC pause p99 小於 100ms 且最大值小於 500ms、每分鐘總停頓低於 1%、old-after-GC 在預熱後不再持續 上升,同時吞吐不降、CPU 不超預算、OOM 和錯誤率不增加。
接著做反事實檢查:回滾新快取是否讓物件配置率和尖峰同時恢復;只限制快取權重是否讓 old-after-GC 穩定; 只縮小批次是否讓 humongous regions 下降。指標同時按預期變化,才能把修復與根因建立連結。擴大流量時 分階段發布並保留自動回滾條件。
高品質示範回答
「現有資料只能說明 GC 值得懷疑,還不能證明 2 到 4 秒請求尖峰由 GC 導致。我會先按實例和 UTC 時間把 請求 SLI、-Xlog:gc*,safepoint、JFR 的 jdk.GCPhasePause、CPU throttling、page fault 和下游延遲 對齊。如果尖峰與 pause 重合,再看是單次 pause 過長,還是許多短 pause 累積;如果只看到長 safepoint, 就按 safepoint cause 排查;如果兩者都不重合,就退出 GC 路徑。
本題發布後物件配置率從 600 MiB/s 升到 1.4 GiB/s,同時 old-after-GC 從 6.1 GiB 升到 9.2 GiB,這至少提出 兩個假設:快取引入大量暫存物件配置,以及快取或關聯物件擴大了 live set。我要看 young/mixed/Full 的 cause、 回收前後占用、promotion、evacuation failure 和 humongous regions。JFR 的 allocation 事件用於定位類別、 執行緒和呼叫點;多時點直方圖用於確認哪些類別持續增長。heap dump 只在導流後的副本或回放環境採集, 因為它是高影響動作,預設還可能請求 Full GC。
如果回收後基線穩定但 young pause 很頻繁,我會減少快取鍵建構、序列化和暫存集合造成的配置;如果基線 持續上升,我會用 retained size 和 GC root 路徑區分有界快取與洩漏;如果大型陣列跨過半個 G1 region 並伴隨 humongous region 增長,就拆小緩衝區和批次;如果日誌 cause 是 System.gc(),就定位呼叫者; 如果 real time 遠大於 GC CPU,則檢查 quota、swap 和宿主機競爭。
我不會把 24 GiB 堆積直接當修復。每個候選改動都在同一流量 canary 單獨驗證,並覆蓋快取冷啟動和多個 原尖峰週期。最終同時檢查請求 p99、pause p99/max、總停頓占比、物件配置率、old-after-GC、humongous regions、吞吐、CPU、RSS、錯誤率和 OOM。符合預先設定門檻後才逐步放量,並保留回滾。」
常見錯誤
- **錯誤表現:看到堆積鋸齒就斷定延遲來自 GC → 失敗原因:鋸齒只能證明發生回收,不能證明請求尖峰與
stop-the-world 在同一實例同一時間重合 → 修正方法:用實例、GC ID 和統一時間軸關聯請求、日誌與 JFR。**
- **錯誤表現:只看平均 GC 時長 → 失敗原因:平均值會掩蓋單次 3 秒長尾,也會忽略大量短停頓的總占比
→ 修正方法:同時統計 pause 分位數、最大值、每分鐘總停頓和 cause。**
- **錯誤表現:把 GC 週期時長當成應用停頓時長 → 失敗原因:G1 的標記等工作大多可與應用並行 → 修正
方法:以實際 pause 事件和請求 SLI 為準。**
- **錯誤表現:事故時直接把堆積從 12 GiB 加到 24 GiB → 失敗原因:可能掩蓋無界保留、增加記憶體風險,
並未證明 live set 有合理上限 → 修正方法:先區分物件配置率與回收後存活集,再做有回滾的容量實驗。**
- **錯誤表現:在唯一生產實例立即抓 heap dump → 失敗原因:命令是高影響動作,預設可能請求 Full GC,
還會產生大檔案與敏感資料風險 → 修正方法:先導流,在副本或可控回放中採集。**
- **錯誤表現:看到大物件就調整 G1 region size → 失敗原因:大物件不一定跨過半 region 門檻,參數還會
改變整個堆積的 region 粒度 → 修正方法:先用 gc+heap 和配置證據確認 humongous object,再對比實驗。**
- **錯誤表現:把
-XX:MaxGCPauseMillis=50當 SLA → 失敗原因:它是 G1 的目標提示,G1 不是即時收集器
→ 修正方法:用真實 pause 分布驗收,並在目標、吞吐和堆積餘量之間做容量權衡。**
- **錯誤表現:發現
System.gc()就全域停用 → 失敗原因:呼叫可能來自相依套件或維運語意,直接停用會
改變行為 → 修正方法:先定位呼叫者和意圖,再刪除、配置或隔離。**
- **錯誤表現:換收集器後只確認 p99 下降 → 失敗原因:停頓可能換成更高 CPU、吞吐下降或更大記憶體占用
→ 修正方法:在同流量下同時驗證延遲、吞吐、CPU、RSS、錯誤率和恢復邊界。**
追問及應對
追問一:如何區分記憶體洩漏和正常快取預熱?
看相同負載下多次 old GC 之後的存活集,不要只看程序剛啟動的一段斜率。正常有界快取會在達到最大權重 和命中率穩定後形成平台,項目淘汰與 TTL 也可被觀測;洩漏會讓不再有業務價值的物件仍沿 GC root 路徑 可達,基線繼續上升。用多個時點的類別直方圖找增長類別,再在受控 heap dump 中檢查 dominator、 retained size 和引用路徑。即使快取最終穩定,若平台 9.2 GiB 讓 12 GiB 堆積沒有並行標記與突發配置 餘量,它仍是容量設計問題。
追問二:為什麼不能先調小 MaxGCPauseMillis?
它是 G1 用來調整每次回收工作量的目標,不是強制上限。若根因是 live set 太大、並行標記趕不上或容器 拿不到 CPU,調小目標可能讓每次 mixed collection 回收更少,增加回收頻率,甚至更快逼近 evacuation failure。應先從 phase timing、堆積餘量和配置/晉升速率提出參數為何有效的假設,再在同負載下單變數驗證。
追問三:GC 日誌顯示 real time 很長,但 user 和 sys time 很短,說明什麼?
表示 wall-clock 期間 GC 執行緒沒有一直消耗 CPU。候選原因包括容器 CPU throttling、宿主機爭用、swap 或 major page fault、虛擬化停頓和日誌 I/O。檢查 cgroup quota/throttled time、執行佇列、page fault、 swap、磁碟延遲和同宿主機事件。增加並行 GC 執行緒可能讓競爭更嚴重;先解決 JVM 外的資源供應證據。
追問四:什麼時候才考慮從 G1 換成 ZGC?
當業務有明確的低延遲目標,已經控制配置與 live set,G1 在目標堆積和流量下仍無法滿足 pause SLO, 而且團隊能承擔額外 CPU、堆積餘量、版本與維運驗證時。用生產形狀負載 A/B 比較,包含冷啟動、穩態、 流量突發、故障恢復和容器限制。收集器遷移是容量與執行模型選擇,不替代修復物件洩漏或無界快取。
追問五:怎樣證明修復長期有效,而不是把尖峰延後?
canary 必須執行足夠久,覆蓋多個原尖峰週期、快取達到穩態以及預期高峰。比較 old-after-GC 的斜率、 每小時 Full GC/evacuation failure、物件配置率、humongous regions、總停頓占比和請求 SLI,並做超過預計 峰值的餘量測試。若加堆積只讓第一次尖峰從 5 分鐘延後到 10 分鐘,基線斜率沒有改變,就應判定修復失敗。