在實際工作中,經常會需要進行在全鏈路壓測,最佳化 GC引數,最佳化 JVM 記憶體分配。
當知道 1 次 RPC 請求和 Http 請求需要的堆記憶體大小後,你可以精確地計算:指定的併發量之下,系統需申請多少堆記憶體。同時結合 JVM 新生代堆大小,就能推算出 1 分鐘發生多少次 GC,這個 GC頻率是否過於頻繁?從而針對性的最佳化。
我們希望 1 次 Rpc、Http 請求申請堆記憶體足夠少,這樣可減少 GC 導致的系統停頓,提高系統性能,單機可以支撐更高的併發量。
1次 Http 請求,申請多少堆記憶體?
1 次 RPC 請求,申請多少堆記憶體?
如果不親自實驗,無法得出結論。
1. 實驗思路
關鍵動作
建立SpringBoot新應用(版本
2.5.4
)。新增 Post 介面,供 JMeter 呼叫。
JMeter(開源壓測工具)新建測試計劃。每個執行緒執行2000 次Http介面呼叫,共10 個執行緒,總呼叫 20000 次。
SpringBoot 列印 GC 詳細日誌,記錄GC 前後,新生代申請了多少記憶體。
Jmeter 呼叫 20000 次 Http 介面以後,透過手動 GC 的方式觸發 GC,透過 GC 詳細日誌計算壓測期間新生代堆記憶體增長量。(物件基本分配在新生代)
2. SpringBoot 宣告 Http 介面
如下程式碼宣告了一個 Post介面 create; 建立了 Get 介面,用於觸發GC。
@Slf4j @RestController public class TestController { private AtomicLong count = new AtomicLong(0); @ResponseBody @RequestMapping(value = "create", method = RequestMethod.POST) public String create(@RequestBody Order order) { //log.warn("收到提單請求 cnt{}:{}", count.getAndIncrement(), order); return "ok"; } @ResponseBody @RequestMapping(value = "gc", method = RequestMethod.GET) public String gc() { System.gc(); return "ok"; } }
3. JMeter 新建測試計劃
3.1 新增執行緒組
新建執行緒組,選擇 10 個執行緒,每個執行緒迴圈 2000次。
3.2 新建 Http 預設值
3.3 新建請求頭
由於請求體是 JSON,所以新增請求頭 Content-Type
3.4 新建 Http 請求
請求中指定 Url 和 請求體
4. 實驗過程
4.1 啟動 SpringBoot應用
堆記憶體大小 4G,其中新生代記憶體 2G。SurivivorRadio=8,即每個 Surivivor 佔比新生代 1/10。
指定GC日誌位置:-Xloggc:/Users/testUser/log/gc.log
java -server -Xmx4g -Xms4g -XX:SurvivorRatio=8 -Xmn2g -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=1g -XX:MaxDirectMemorySize=1g -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCCause -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=32768 -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:ParallelCMSThreads=6 -XX:+CMSClassUnloadingEnabled -XX:+UseCMSCompactAtFullCollection -XX:+CMSParallelInitialMarkEnabled -XX:+CMSParallelRemarkEnabled -XX:+CMSScavengeBeforeRemark -XX:+PrintHeapAtGC -XX:CMSFullGCsBeforeCompaction=1 -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+PrintReferenceGC -XX:+ParallelRefProcEnabled -XX:ReservedCodeCacheSize=256M -Xloggc:/Users/testUser/log/gc.log -jar target/activiti-0.0.1-SNAPSHOT.jar
4.2 多次手動 GC
由於JVM 啟動過程中,需要載入大量物件,所以我們在壓測之前先手動 GC,清理一下存量物件。
curl http://localhost:8080/gc
4.3 Jmeter 啟動壓測
執行 JMeter壓測計劃,每次執行會呼叫 20000 次,
4.4 GC 日誌解讀
GC 以後,新生代 Eden 區已使用記憶體為 0。 GC 前 Eden區大小就是 20000 次 Http 呼叫所申請的記憶體總和!
5. 實驗結果
SpringBoot 在處理 Http 請求時,即使請求體相對較小,平均每次 Http 呼叫仍會申請約 34 K 的堆記憶體。這一點顯得尤為突出,因為請求體僅包含 50 個字元,遠遠未達到 1K 大小。然而,每次 Http 請求所消耗的記憶體卻依然高達 34K。這可能是由於在 SpringBoot 的內部處理流程中需要建立多個物件,這些物件的總記憶體佔用顯著高於請求體本身。
{"userId": 32898493, "productId":39043, "detail": ""}
在調整 Http 請求後,如將 detail 欄位設定為 1200 個字元時,每次 Http 呼叫平均佔用堆記憶體為 36K。兩次實驗結果間的差異為 2K,這與 1200 個字元佔用的記憶體大小基本持平(需考慮一定的誤差)。
這表明 SpringBoot 內部未進行多次請求體複製。
5.1 新增日誌列印
log.warn("收到提單請求 cnt{}:{}", count.getAndIncrement(), order);
在列印請求日誌後,單次 Http 請求的平均記憶體使用量達到了 56 KB,比之前增加了整整 20 KB。
然而,當我移除 detail 欄位後,單次請求的記憶體使用驟降至 35.7 KB。
這表明,當日志量較小時,列印日誌對記憶體佔用的影響較小。但隨著日誌大小的增加,記憶體佔用顯著上升,這可能觸發更頻繁地GC,最終導致系統性能明顯下降。
因此,建議各位嚴格控制單條日誌的大小,以最佳化記憶體使用和系統性能。
Case | 記憶體消耗 |
---|---|
小請求 | 34K |
小請求 + 打日誌 | 35.7 K |
大請求 | 36K |
大請求 + 打日誌 | 56K |
6. 真實的資料
根據以上實驗結果可得出結論:單次 HTTP 請求消耗約34KB記憶體,這並不意味著所有SpringBoot應用的記憶體消耗都是如此。由於實驗所用的程式碼相對簡單,因此34KB可能是記憶體消耗的最小值。
舉例來說,在我司的線上環境中,單次RPC請求的記憶體消耗在 0.5MB 到 1MB 之間,記憶體佔用量相對較大。
這是因為複雜的基礎架構、複雜的業務邏輯、複雜的流程、多次下游呼叫、多次SQL 呼叫、多次快取呼叫、日誌列印等等 均需要消耗大量的記憶體!
在此之前,我一直對新生代 Eden 區高達 5G 的情況下,仍每分鐘進行 1-2 次 young GC 感到困惑。
經過粗略計算後發現,如果每次請求消耗 0.5M 記憶體,當單臺伺服器每秒併發度達到 500 次時,每分鐘需要分配的記憶體高達 15G。因此,至少需要進行3次 young GC 才能滿足需求。