在高性能計算領域,我們習慣於在代碼、算法或基礎設施中尋找瓶頸。但我遇到過的最棘手的問題卻不在這些方面。那是Java虛擬機(JVM)的垃圾回收器與服務器磁盤之間一種無形的交互,導致一個每秒處理數百萬請求的服務出現了15秒以上的全局暫停(STW)。
503 突增
我當時正在處理一個大規模的Java服務,該服務每秒要處理數百萬用户請求。這個系統是為極高的吞吐量而設計的,但我們卻深受負載均衡器間歇性超時峯值的困擾,這導致向用户返回了503響應。
我們發現,在負載情況下,一部分Web服務器會停滯並停止接受新連接數秒,導致請求堆積並失敗。唯一的線索是,這種行為與大量磁盤I/O的時段相關,而這些磁盤I/O是同一主機上另一個基於磁盤的緩存系統產生的。
確鑿證據:解讀GC日誌
經過數週的調試,我們終於在垃圾回收日誌中找到了“確鑿證據”。一次典型的新生代垃圾回收停頓時間應該在幾十或幾百毫秒。但我們看到的情況是這樣的:
[timestamp]: 184512.789: [GC [PSYoungGen: 1058042K->17224K(1069568K)]
3112024K->2018456K(3258112K), 15.3495220 secs]
[Times: user=0.25 sys=0.05, real=15.35 secs]
乍一看,這似乎是一個長得離譜的垃圾回收暫停。但“啊哈!”的時刻出現在[Times]部分:
user=0.25 sys=0.05(總CPU時間:0.30秒):這是垃圾回收(GC)進程使用的實際CPU時間。垃圾回收算法本身快得令人難以置信且效率很高。real=15.35 secs(掛鐘時間):這是從暫停開始到暫停結束在現實世界中經過的總時間。
這種差異十分明顯:JVM處於全局停頓狀態超過15秒,但實際(佔用CPU)運行的時間僅為0.3秒。在其餘約15秒內,STW線程處於“脱離CPU”狀態,陷入了等待狀態。
年輕代GC是一種“Stop-the-World”事件。JVM實際上會暫停所有應用線程,以安全地移動內存。我們發現,這個GC操作的最後一步是將日誌條目同步寫入GC日誌文件。那個簡單的write()系統調用就是問題所在。由於磁盤受到其他緩存進程的激烈爭用,內核的I/O隊列已飽和。GC線程的日誌寫入——一個看似無害的操作,在該隊列中陷入停滯,等待物理磁盤。而且由於JVM處於STW暫停狀態,整個應用程序都被凍結,等待那一行日誌寫入完成。
修復方法很簡單:我們不再將GC日誌寫入那個競爭激烈的磁盤。
解決方案
這個基本問題,即一個關鍵的、阻塞的線程在等待輸入/輸出。以下是解決這個問題的兩種主要方法。
1. 文件系統級修復(我們的解決方案)
這是我們最初的解決方案。我們將GC日誌路徑從物理磁盤上的默認位置更改為內存支持的文件系統。
方式:我們將日誌輸出(例如,-Xloggc:/var/log/my-app/gc.log)指向tmpfs中的一個路徑,例如-Xloggc:/dev/shm/my-app-gc.log。
工作原理:寫入tmpfs並非真正的磁盤輸入/輸出操作。它們是內存到內存的複製,幾乎是瞬時完成的。write()調用會立即返回,從而結束STW暫停。
內存溢出怎麼辦?這是一個合理的擔憂。理論上,將日誌寫入內存可能會耗盡所有可用內存並導致服務器崩潰。我們通過使用JVM的內置日誌輪轉標誌來緩解此問題。通過設置:
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=6 -XX:GCLogFileSize=20M
我們將垃圾回收日誌的總內存使用量限制在可預測的120MB,從而消除了進程失控的風險。
缺點:
- 日誌是臨時的:
/dev/shm中的日誌會在每次系統重啓或容器重啓時丟失。 - 歸檔功能丟失:這一變更意味着日誌不再被我們的集中式持久化日誌系統自動收集。如果仍需持久化,則需要配置單獨的日誌傳輸代理(如邊車代理)。
2. JVM級修復(現代方法)
多年來,tmpfs 這種權宜之計是 唯一 的解決方案。最近,亞馬遜 Corretto 團隊開發並貢獻了一項正式的 JVM 功能,用於添加異步 GC 日誌記錄,該功能已成為 OpenJDK 17 中的標準功能。
方法:將 async 修飾符用於您的-Xlog標誌。
-Xlog:async -XX:AsyncLogBufferSize=100M
其工作原理:STW GC線程不再執行I/O操作。它會將日誌消息寫入一個小型的內存緩衝區,並立即恢復應用線程。隨後,一個獨立的低優先級後台線程負責將該緩衝區的內容刷新到磁盤。
優點:
- 這是“官方”且期望的解決方案。
- 無需操作系統級別的技巧。
- 日誌寫入標準文件路徑,便於收集。
缺點:
- 在極端突發場景下(對於GC日誌來説不太可能),異步緩衝區可能會被填滿,這可能導致主線程停滯。
- 在較舊的Java部署中不是一個選項
為何這仍然重要
在容器時代,這個問題再次出現。現代的“最佳實踐”是讓應用程序直接將日誌輸出到stdout/stderr。但stdout並非一個神奇的虛無空間,它是一個管道。必須有其他進程從該管道的另一端進行讀取。
這通常是容器運行時(如containerd)或日誌代理(如Fluentd、Vector或Logstash)。如果該日誌代理運行緩慢、配置錯誤,或者它自身的網絡或磁盤I/O出現阻塞,其讀取緩衝區就會被填滿。這種背壓會沿着管道向上傳遞,導致應用程序下一次對stdout執行write()操作時會阻塞。如果JVM在STW暫停期間嘗試將GC日誌寫入stdout,而日誌代理又不堪重負,那麼你將會再次陷入同樣的停滯狀態。
核心要點
real與user+sys是你的信號:當你在任何日誌中看到較高的real時間但較低的user+sys時間時,這不是CPU問題。而是I/O問題(磁盤、網絡)或操作系統調度程序問題(CPU飢餓)。- 關鍵路徑上無輸入/輸出操作:切勿在整個應用程序所依賴的線程上執行阻塞性輸入/輸出操作,包括“簡單的”日誌記錄。
- 使用
async日誌記錄:對於現代JVM,使用-XLog:async標誌。這是將此I/O從關鍵路徑移開的最簡潔方法。 - 要警惕
stdout:在容器化的世界中,向stdout記錄日誌仍然是一個阻塞式I/O調用。要確保你的集羣日誌管道既穩健又非阻塞,否則你可能會將日誌記錄延遲變成整個應用程序的停滯。
> 原文鏈接:https://dzone.com/articles/the-jvm-pause-that-wasnt-a-war-story