凌晨零點,一個 TODO,差點把我們整個部門抬走
尼恩 e2e JVM性能調優方法論
尼恩為大家 整合出一套清晰、可落地的、e2e JVM性能調優方法論。
其核心工作流可以概括為下圖:
圖片
五步定位閉環:從現象到根因的系統化診斷
這套方法的核心在于用數據說話,避免盲目猜測,形成從現象到根因的完整證據鏈。
第一步:數據采集——全面捕捉系統異常
當系統出現異常(如CPU飆升、響應變慢、OOM)時,需要立即采集以下數據以保留“案發現場”:
- GC日志:通過JVM參數
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps開啟。關鍵看Full GC頻率、單次GC耗時、GC前后內存回收效果。 - 堆內存快照(Heap Dump):用于分析內存中到底存放了什么對象。可以通過命令
jmap -dump:format=b,file=heap.hprof <pid>主動生成,或設置參數-XX:+HeapDumpOnOutOfMemoryError讓JVM在OOM時自動生成。 - 線程快照(Thread Dump):用于分析CPU飆升、死鎖、線程阻塞問題。使用命令
jstack <pid> > thread.txt獲取。建議間隔5-10秒連續采集多次,以便觀察線程狀態的變化。 - 實時運行時數據:使用
jstat -gcutil <pid> 1000每秒打印一次各內存區域使用率和GC情況,快速評估GC壓力。
第二步:線程分析——定位并發瓶頸
線程問題是高并發場景下性能瓶頸的常見根源。采集到線程快照后,需要分析:
- 線程狀態統計:統計各狀態線程數量,重點關注
BLOCKED、WAITING狀態的線程。
```
# 統計線程狀態分布
$ grep "java.lang.Thread.State" thread_dump.txt | sort | uniq -c
15 java.lang.Thread.State: BLOCKED (on object monitor)
32 java.lang.Thread.State: RUNNABLE
8 java.lang.Thread.State: WAITING (on object monitor)- 死鎖檢測:使用
jstack <pid> | grep -i deadlock或工具自動檢測死鎖。 - 熱點線程分析:結合
top -H -p <pid>找到高CPU線程ID,轉換為16進制后在線程快照中查找對應線程棧,定位正在執行的熱點方法。
第三步:日志解析——篩選有效線索
- 分析業務日志:從文本日志中提取根因線索,排除無關干擾
- 分析GC日志:重點關注Full GC是否頻繁,以及每次GC的停頓時間。如果Full GC后老年代使用率依然很高,可能存在內存泄漏。同時關注GC的觸發原因,如
Allocation Failure(分配失敗)或Metadata GC Threshold(元空間閾值)。 - 分析線程快照:搜索關鍵字“deadlock”來快速排查死鎖。查看大量線程是否阻塞在同一個鎖或資源上。
- 初步查看堆快照:使用
jmap -histo:live <pid> | head -n 20查看當前存活對象中,哪些類的實例數量最多、總大小最大,快速鎖定嫌疑對象。
第四步:堆Dump分析——定位內存真相
如果懷疑內存泄漏,堆Dump是最有力的證據。
使用MAT(Memory Analyzer Tool) 或 JVisualVM 打開堆Dump文件。
- 查看支配樹(Dominator Tree):直接列出在內存中占據最大空間的對象,并顯示其引用鏈。
- 查找內存泄漏疑點:工具通常會提供Leak Suspects Report,它會自動分析可能的內存泄漏點。重點檢查 全局性的集合類(如靜態Map)、未關閉的連接(如數據庫連接、文件流) 以及線程局部變量(ThreadLocal)是否未及時清理。
第五步:根因驗證——得出結論
將前四步的線索串聯起來,形成完整的證據鏈。例如:
- 線索1(GC日志):Full GC頻繁,且每次回收后老年代可用空間增長很小。
- 線索2(線程分析):大量線程阻塞在同一個緩存操作上。
- 線索3(堆Dump分析):一個靜態的
HashMap占據了80%的堆內存,且其中的緩存條目沒有有效的過期機制。 - 結論:由于全局緩存未設置過期策略導致內存泄漏。
三階段e2e解決閉環:從緊急止血到徹底根治
定位到根本原因后,按照“先止血、再局部優化、后架構升級”的層次推進解決,確保問題徹底根治。
階段一:緊急止血(目標:1-3小時內恢復服務)
目標是快速恢復服務,避免業務長時間中斷。
- 服務重啟與隔離:重啟是最快的臨時解決方案。如果可能,先將故障實例從負載均衡中摘除再重啟,避免流量沖擊。在K8s環境中,可標記Pod為不可調度并驅逐。
- 彈性擴容:緊急擴容實例,用“人海戰術”分擔負載,為后續排查爭取時間。
- 流量降級與限流:關閉非核心功能或對異常接口實施限流,保障核心鏈路可用。
階段二:局部優化(目標:1-3天內優化性能瓶頸)
目標是解決直接痛點,優化性能瓶頸,防止問題復發。
- 代碼優化(根本之策)修復內存泄漏:
例如,將無界緩存改為有界緩存(如Caffeine),并設置合理的過期時間或大小限制。確保資源(如數據庫連接、文件流)使用 try-with-resources正確關閉。
例如,避免在循環內創建大對象:如JSON序列化、日志拼接等。將大對象的創建移出循環。
例如,優化數據結構與算法:選擇正確的集合類,設置合理的初始大小避免頻繁擴容。
- JVM參數調優(輔助手段)設置堆大小:
-Xms和-Xmx設置為相同值,避免動態調整的開銷。根據系統總內存和監控數據設定。 - 選擇垃圾回收器: G1 GC(JDK9+):適用于大多數需要平衡吞吐量和延遲的服務端應用。
- 參數示例:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200。 - ZGC(JDK11+):適用于追求極致低延遲(停頓<10ms)且堆內存非常大的場景。
- 調整新生代:通過
-Xmn顯式設置新生代大小。對象創建頻繁的應用可適當調大。
階段三:架構升級(目標:1-3個月內構建長效機制)
目標是構建長效機制,從根本上提升系統韌性,支持業務未來增長。
- 引入更健壯的中間件:例如,用Redis等分布式緩存替代JVM本地緩存,徹底解決JVM內存管理問題。
- 完善監控告警體系:搭建Prometheus + Grafana監控平臺,對JVM核心指標(GC次數、耗時、內存使用率)進行持續監控和告警。建立代碼塊級耗時監控。
- 建立性能壓測與故障演練流程:在大促或重大變更前,進行全鏈路壓測,提前發現性能瓶頸。定期進行故障注入演練,驗證應急預案有效性。
- 代碼規范與評審:將“避免內存泄漏的編碼規范”納入代碼評審環節,從源頭杜絕問題。
接下來,結合 尼恩 e2e JVM性能調優方法論 ,分析一下 一個大廠的 線程池耗盡、頻繁fullgc的事故現場。
注意,下面的文章,來自互聯網。尼恩也只是拿了給大家學習, 學習交流。
由于沒有聯系原作者,如果原作者不同意尼恩的分析和學習,就只能從尼恩的公眾號取下來。
事故現場:線程池耗盡, 一個 TODO 把整個大促部門抬走
事故來自一個頭部電商平臺, 正為一個S級的“會員閃促”活動做最后的護航,它將在零點準時生效。
作戰室里燈火通明,所有人都盯著大盤,期待著活動上線后,GMV曲線能像火箭一樣發射。
然而, 剛過0點, 出事故了。
告警群里的消息開始瘋狂刷屏,聲音急促得像是防空警報:
[嚴重] promotion-marketing集群 - 應用可用度 < 10%
[嚴重] promotion-marketing集群 - HSF線程池活躍線程數 > 95%
[緊急] promotion-marketing集群 - CPU Load > 8.0立馬打開 監控系統——整個promotion-marketing集群,上百臺機器,像被病毒感染了一樣,CPU和Load曲線集體垂直拉升,整整齊齊。
這意味著,作為促銷中樞的服務已經事實性癱瘓。
所有促銷頁面上,為大會員準備的活動入口,都因服務超時而被降級——活動,上線即“失蹤”。
一場精心籌備的S級大促,在上線的第一秒,就“出師未捷身先死”了。
第一幕:無效的掙扎
故障排查。
1. 第一步,看日志。
首先 發現一個NPE(空指針異常),并且 NPE 數量有點多。
但仔細一看,NPE 來自一個非常邊緣的富客戶端jar包,跟核心鏈路無關。
NPE 排除。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 3. 日志解析
關聯原因:日志解析的核心目標是 “從文本日志中提取根因線索,排除無關干擾”。
通過分析業務日志, 篩選關鍵異常、結合業務鏈路判斷關聯性,最終剔除干擾項,避免定位方向走偏。
2. 第二步,懷疑死鎖。
HSF線程池全部耗盡,是線程“罷工”的典型癥狀。
立刻拉取線程快照,用jstack分析,卻沒有發現任何死鎖跡象。
再次排除。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 2. 線程分析
線程分析的核心目標是 “排查線程級問題(死鎖、線程池耗盡、鎖競爭等)”,核心動作是 “用
jstack采集線程快照、識別線程異常狀態”。“拉取線程快照” 分析 “排查死鎖并排除”,完全符合線程分析的操作邏輯 —— 針對 “線程池耗盡” 的現象,優先驗證常見誘因(死鎖),排除后進一步縮小根因范圍
3. 第三步,重啟大法。
挑了幾臺負載最高的機器進行重啟。
起初兩分鐘確實有效,但只要新流量一進來,CPU和Load就像脫韁的野馬,再次沖頂。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題解決方法 - 1. 緊急止血(1-3 小時)
緊急止血的核心目標是 “1-3 小時內快速緩解業務中斷,為后續定位爭取時間”,重啟屬于典型的臨時止血措施 —— 通過重啟釋放臨時占用的線程、內存資源,短暫恢復服務可用性。
雖未根治問題(流量進來后復發),但符合 “優先止損” 的緊急止血原則。
4. 第四步,擴容。
既然單機扛不住,那就用“人海戰術”。
緊急擴容了20臺機器。
但新機器就像沖入火場的士兵,沒堅持幾分鐘,就同樣陷入了高負載、瘋狂GC的泥潭。
此時,距離故障爆發已經過去了18分鐘。
作戰室里的氣氛已經從緊張變成了壓抑。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題解決方法 - 1. 緊急止血(1-3 小時)
擴容是緊急止血階段的 “資源擴容類措施”,核心邏輯是 “通過增加實例數量分散流量壓力”。
“緊急擴容 20 臺機器” 雖未解決根本問題(新機器同樣高負載),但屬于 1-3 小時時間窗口內的臨時應對動作,目的是嘗試緩解集群壓力,符合緊急止血 “不追求根治、僅求臨時止損” 的目標。
第二幕:深入“肌體”
常規手段全部失效,唯一的辦法,就是深入到JVM的“肌體”內部,看看它的“細胞”到底出了什么問題。
我保留了一臺故障機作為“案發現場”,然后dump了它的堆內存和線程棧。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 2. 線程分析(線程棧采集) + 4. 堆 dump 分析(堆內存采集)
關聯原因:線程分析和堆 dump 分析的前提是 “獲取完整的線程 / 內存數據”。
“保留故障機” 避免數據丟失,“dump 堆內存 + 線程棧” 是兩個定位步驟的核心數據采集動作 —— 線程棧為后續分析線程狀態打基礎,堆內存為排查內存異常(如老年代高占用)提供數據支撐。
分析堆內存,我發現老年代(Old Gen)的使用率居高不下,CMS回收的效果非常差,導致了頻繁且耗時的Full GC,這完美解釋了為什么CPU會飆升。
同時, 內存里駐留了大量char[]數組,內容都指向一個和“萬豪活動配置”相關的字符串常量。
這說明,有一個巨大的活動配置對象,像一個幽靈,賴在內存里不走。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 4. 堆 dump 分析
堆 dump 分析的核心目標是 “排查內存泄漏、大對象占用、分區異常等問題”。
“分析老年代高使用率(CMS 回收差→頻繁 Full GC)”“定位大量
char[]數組(關聯萬豪活動配置大對象)”,完全匹配堆 dump 分析的核心動作 —— 通過內存分區、對象類型分析,解釋了 “CPU 飆升” 的底層誘因(Full GC 占用 CPU),并鎖定 “大對象駐留內存” 的關鍵線索。
接著, 開始分析線程棧快照。
用grep簡單統計了一下:
# 查看等待的線程
$ sgrep 'TIMED_WAITING' HSF_JStack.log | wc -l
336
# 查看正在運行的線程
$ sgrep 'RUNNABLE' HSF_JStack.log | wc -l
246三百多個線程在等待,兩百多個在運行。 問題大概率就出在這兩百多個RUNNABLE的線程上。
一個熟悉的身影,反復出現在 屏幕上:
at com.alibaba.fastjson.toJSONString(...)大量的線程,都卡在了FastJSON的序列化操作上!
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 2. 線程分析
關聯原因:線程分析的進階動作是 “統計線程狀態分布、鎖定異常活躍線程”。
“用
grep統計 TIMED_WAITING/RUNNABLE 線程數”“判斷問題出在 RUNNABLE 線程”,屬于線程分析的關鍵環節 —— 通過狀態分布排除 “線程等待過多” 的問題,聚焦 “活躍線程的資源占用”,為后續定位卡點方法打基礎“過濾 RUNNABLE 線程堆棧”“發現大量線程卡在 FastJSON 序列化”,完全符合線程分析的深度操作 —— 通過調用鏈定位具體耗時方法,將 “線程池耗盡” 與 “序列化操作” 直接關聯,為根因推導提供關鍵依據。
結合堆內存里那個巨大的“萬豪配置”字符串,一個大膽的猜測浮現在我腦海里:
有一個巨大的對象,正在被瘋狂地、反復地序列化,這個CPU密集型操作,耗盡了線程資源,拖垮了整個集群!
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 5. 根因驗證(初步推導)
根因驗證的前置動作是 “結合多維度數據(內存 + 線程)推導根因”。
“結合堆內存大對象(萬豪配置)+ 線程卡點(FastJSON 序列化)”,推導出 “反復序列化大對象耗盡線程資源” 的結論,屬于根因驗證的核心邏輯 —— 通過多定位步驟的結果交叉驗證,形成初步根因假設。
第三幕:“一行好代碼”
順著線程棧的指引, 很快定位到了代碼里的“犯罪現場”:
XxxxxCacheManager.java
在這段代碼上方,還留著一行幾個月前同事留下的、刺眼的注釋:
// TODO: 此處有性能風險,大促前需優化。
正是這個被所有人遺忘的TODO,在今晚,變成了捅向我們所有人的那把尖刀。
這是一個從緩存(Redis)里獲取活動玩法數據的工具類。
而寫入緩存的方法,則讓人大開眼界:
// ... 省略部分代碼
// 從緩存(Redis)里獲取活動玩法數據的工具類
public void updateActivityXxxCache(Long sellerId, List<XxxDO> xxxDOList) {
try {
if (CollectionUtils.isEmpty(xxxDOList)) {
xxxDOList = new ArrayList<>();
}
.....
// TODO: 此處有性能風險,大促前需優化
// 為了防止單Key讀壓力過大,設計了20個散列Key
for (int index = 0; index < XXX_CACHE_PARTITION_NUMBER; index++) {
// 致命問題:將序列化操作放在了循環體內!
RedisCache.put(String.format(ACTIVITY_PLAY_KEY, xxxId, index),
JSON.toJSONString(xxxDOList), // 就是這行代碼,序列化了20次!
EXPIRE_TIME);
}
} catch (Exception e) {
log.warn("update cache exception occur", e);
}
}看著這段代碼,問題特大。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題定位方法 - 5. 根因驗證(根因確認)
根因驗證的最終目標是 “找到代碼級具體根因”。
“順著線程棧定位代碼文件”“發現循環體內重復序列化大對象(20 次)”,驗證了之前的根因假設,屬于根因驗證的關鍵閉環動作 —— 將抽象的 “序列化問題” 落地到具體代碼邏輯,明確 “循環內重復序列化” 是根本誘因。
這個代碼 解決的是 緩存擊穿 : 零點活動生效,緩存里沒有數據,發生了緩存擊穿,這很正常。
為了防止單Key讀壓力過大,作者設計了20個散列Key來分散讀流量。
解決 緩存擊穿 ,這思路也沒問題。
但致命的是,在寫入緩存時,序列化的操作,竟然被放在了for循環內部!
序列化 的是一個 巨大對象(約1-2MB),而且要循環 20次。
這意味著,每一次緩存擊穿后的回寫,都會將一個1MB的巨大對象,連續不斷地、在同一個線程里,序列化整整20次!
這已經不是代碼了,這是一臺CPU絞肉機。
而更要命的是,我們的緩存中間件Redis LDB本身性能脆弱,被這放大了20倍的寫流量(20 x 1MB)瞬間打爆,觸發了限流。
Redis被限流后,寫入耗時急劇增加,從幾十毫秒飆升到幾秒。這導致“CPU絞肉機”的操作時間被進一步拉長。
最終,HSF線程池被這些“又慢又能吃”的線程全部占滿,服務雪崩。
修改的代碼如下:
// ... 省略部分代碼
// 從緩存(Redis)里獲取活動玩法數據的工具類
public void updateActivityXxxCache(Long sellerId, List<XxxDO> xxxDOList) {
try {
if (CollectionUtils.isEmpty(xxxDOList)) {
xxxDOList = new ArrayList<>();
}
.....
//代碼優化
String jsnotallow= JSON.toJSONString(xxxDOList);//序列化移動到循環外部
// 為了防止單Key讀壓力過大,設計了20個散列Key
for (int index = 0; index < XXX_CACHE_PARTITION_NUMBER; index++) {
// 致命問題:將序列化操作放在了循環體內!
RedisCache.put(String.format(ACTIVITY_PLAY_KEY, xxxId, index),
json, // 就是這行代碼,序列化了20次!
EXPIRE_TIME);
}
} catch (Exception e) {
log.warn("update cache exception occur", e);
}
}這個動作, 對應到尼恩jvm調優方法論的具體步驟是: 問題解決方法 - 2. 局部代碼優化(1-3 天)(優化方向推導)
原因:局部代碼優化的核心目標是 “針對代碼級根因做修復,避免問題復發”。
“指出序列化應移到循環體外(僅序列化 1 次)”,是典型的局部代碼優化方向 —— 針對 “循環內重復序列化” 的根因,修改代碼邏輯消除性能瓶頸,且該優化可在 1-3 天內完成(代碼修改 + 測試 + 發布),符合局部優化的時間窗口與操作范圍。
第四幕:真相與反思
故障的根因已經水落出。
我們緊急回滾了這段“循環序列化”的代碼,集群在凌晨0點30分左右,終于恢復了平靜。
30分鐘,生死時速。
在事后的復盤會上,我分享了 的“改進的三法則”:
法則一:任何脫離了容量評估的“優化”,都是在“耍流氓”。
這次故障的始作俑者,就是一段為了解決“讀壓力”而設計的“好代碼”。
但好的優化是錦上添花,壞的優化是“畫蛇添足”。
敬畏之心,比奇技淫巧更重要。
法則二:監控的終點,是“代碼塊耗時”。
我們有機器、接口、中間件等各種監控,但唯獨缺少對“代碼塊耗 plataformas”的精細化監控。
如果APM工具能第一時間告訴我們90%的耗時都在XxxxxCacheManager的update方法里,排查效率至少能提高一倍。
這個動作, 對應到尼恩jvm調優方法論的具體步驟是:問題解決方法 - 3. 架構升級(1-3 個月)(監控架構優化方向)
原因:架構升級的核心目標是 “解決系統性瓶頸,支撐長期穩定”。
“缺少代碼塊級 APM 監控” 屬于系統性監控缺失,需通過架構升級補全 —— 引入支持 “代碼塊耗時” 的 APM 工具(如 SkyWalking),實現精細化監控,該動作需 1-3 個月(工具選型 + 部署 + 埋點),符合架構升級的時間窗口。
法則三:技術債,總會在你最想不到的時候“爆炸”。
TODO 技術債務,一定要做重點review。
技術債就像家里的蟑螂,你平時可能看不到它,但它總會在最關鍵、最要命的時候,從角落里爬出來,給你致命一擊。
那天凌晨一點,我走在杭州空無一人的大街上,吹著冷風,腦子里卻異常地清醒。
因為在那場驚心動魄的“雪崩”里,在那一串串冰冷的線程堆棧中,我再次確認了一個樸素的道理:
所有宏大的系統,最終都是由一行行具體的代碼組成的。而魔鬼,恰恰就藏在其中。




























