1 為什麽要升級JDK
此前轉轉平台基礎體驗部的後端服務JDK版本為1.8(1.8.0_191),在1.8以後已經叠代了十幾個版本,每個版本中都包含了許多新特性。新特性有助於簡化程式碼操作、提升系統安全性、降低系統的開銷等等。
結合實際場景,商列服務作為最上層服務只有RPC呼叫和業務程式碼,包含了:商品列表頁數據以及篩選項,其中下遊返回的篩選項報文很大。當商列服務有尖峰流量湧入時,如:618、雙11場景,大量新生物件產生,記憶體回收速率跟不上套用申請記憶體速率,導致高頻YGC/FGC、GC時間過長,降低服務可用性。透過縱向擴容方式,對於大記憶體,1.8的各種垃圾回收器都不能達到良好的回收效果;透過橫向擴容方式,成本增加、數據庫等連線數也會升高。隨著ZGC垃圾回收器問世,借助它的並行回收、低延遲、毫秒級暫停、支持大記憶體的特性,與我們的現狀契合,基於此平台開始考慮升級JDK。
2 為什麽選擇JDK21
從Oracle長期支持的版本看,可選的版本有11、17、21,如圖1所示。
圖1 Oracle Java SE Support Roadmap
基於自身使用訴求,需要引入支持ZGC垃圾回收器的JDK,支持ZGC的JDK版本列表如圖2所示。
圖2 支持ZGC的JDK版本列表
綜上可以考慮JDK17或JDK21。對於JDK17我們線上上接入並且壓測過。當壓測流量為日常峰值的4倍時,因記憶體回收速率跟不上套用申請記憶體速率,觸發Allocation Stall(Allocation Stall是觸發GC的一個原因,由於無充足可用記憶體導致,會引發套用執行緒停頓,類似Stop The World),進而引起套用執行緒等待直到可以重新申請新的記憶體,最終服務可用率下降。如圖3所示,在壓測的8分鐘內出現396次Allocation Stall。
圖3 JDK17 Allocation Stall範例
可以考慮透過增加記憶體以承載更大的流量,但這並不是一個長期可行的方案。JDK開發組在JEP 439中提出了分代ZGC。在相同的堆記憶體條件下,分代ZGC只需70%的記憶體,達到4倍的吞吐量,並且仍然可以保持停頓時間小於1ms,大幅降低了Allocation Stall。至此,我們選擇了JDK21,開始了實踐之路。
圖4 ZGC與分代ZGC Benchmarks
3 分代ZGC簡介
3.1 什麽是分代ZGC
ZGC是一個可伸縮的低延遲垃圾收集器,最高能支持TB級堆記憶體,能並行執行繁重任務,且不會讓套用的暫停時間超過1ms。ZGC適用於要求低延遲的套用,暫停時間與所使用的堆大小無關。
分代ZGC是ZGC的一個實作版本,依據假說:套用中的大部份物件都是短生命周期的,被設計為分代,即:年輕代、老年代。相對ZGC,分代ZGC提高了套用吞吐率、降低了Allocation Stall頻率、且依然能夠保持對套用的暫停時間小於1ms。
3.2 垃圾回收過程
3.2.1 堆記憶體模型
分代ZGC將堆記憶體分為兩個邏輯區域:年輕代、老年代,堆記憶體模型如圖5所示。
圖5 分代ZGC堆記憶體模型
當分配物件時,它首先會被分配到年輕代,如圖6所示。若該物件經歷過多次年輕代回收後依然存活,它將會被晉升到老年代,如圖7所示。
圖6 新物件被分配到年輕代
圖7 物件被晉升到老年代
在實際的記憶體分布中,年輕代、老年代會分布在不連續的記憶體區域,如圖8所示。
圖8 年輕代、老年代的記憶體分布
3.2.2 分代ZGC回收階段
回收一個代的階段如圖9所示,包含:垂直方向的GC暫停,以及水平方向的並行階段。
圖9 回收一個代的階段
(1)暫停點1:這是一個同步點,僅標識標記開始。
(2)並行階段1:開始執行應用程式、並行標記獲取物件是否可達,在並行標記的同時,對最近一次GC Cycle內的物件remapping(當我們獲取物件參照時,分代ZGC的load barrier會檢查物件參照,若物件參照過期,會生成新的物件參照,這個過程稱為remapping)。
(3)暫停點2:這是也一個同步點,用於標識標記結束。
(4)並行階段2:為疏散區域(Region)做準備工作、處理reference、類的解除安裝等。
(5)暫停點3:同樣也一個同步點,用於標識將要移動物件。
(6)並行階段3:移動物件,以便釋放出連續的記憶體。
在分代ZGC各階段(Phases)中,年輕代回收階段、老年代回收階段以及應用程式的執行完全是並行的,如圖10所示。
圖10 分代ZGC各階段
分代ZGC將回收階段劃分為兩類:Minor Collections和Major Collections以統一管理。
(1)GC Marking Roots:這樣的欄位包含唯一參照,使年輕代Object Graph的一部份保持可達。GC必須將這些欄位視為Object Graph的根,以確保所有存活的物件都被發現,並標記他們的存活狀態。
(2)老年代中的陳舊指標:收集年輕代時會移動物件,這些物件的指標沒有被立即更新。
老年代到年輕代的指標集合稱為remembered set,包含了所有指向年輕代的指標。
圖11 Minor Collection
圖12 Major Collection
3.3 分代ZGC調優方式
分代ZGC在設計之初,希望是自適應的,且以最小化人工配置對其進行調優,大部份內容都由分代ZGC內部自動計算調整,唯一重要的、需要調優的參數只有最大堆記憶體,即:-Xmx。
堆記憶體的大小根據記憶體分配速率以及套用中的存活物件集大小決定。通常來說,提供的堆記憶體越大,分代ZGC的效能表現越好。
此前用到的很多參數項都不需要再設定,在分代ZGC中即使設定了這些參數也是無效的。例如:-Xmn、-XX:TenuringThrehold、-XX:InitiatingHeapOccupancyPercent、-XX:ConGCThreads等等。對於分代ZGC的其他調優點,例如:使用大頁、使用透明大頁等,詳見:參考資料第3點。分代ZGC支持的所有GC參數項如下:
圖13 分代ZGC支持的GC參數項列表
需要註意的是,在JDK21版本中,仍然保留了ZGC的參數項。某些參數剛剛提到過,對於分代ZGC無需設定-XX:ConGCThreads參數項。
3.4 分代ZGC設計要點
分代ZGC將堆劃分為兩個邏輯區域:年輕代、老年代,二者的回收完全獨立,分代ZGC關註更有回收價值的年輕代物件。與ZGC一樣,分代ZGC的執行和套用執行並行。由於與應用程式同時需要讀取/修改Object Graph,必須為應用程式提供一致的Object Graph。分代ZGC透過:colored pointers(染色指標)、load barrier(載入屏障)、store barrier(儲存屏障)實作,不再使用multi-mapped memory做多次對映。
新的染色指標數據結構,支持了更多的color bit(染色位)以支持實作更復雜的演算法、擴大了物件地址的儲存空間、規避了因使用multi-mapped memory導致的RSS統計為ZGC實際記憶體使用的3倍。
圖14 分代ZGC load barrier染色指標地址結構
圖15 分代ZGC store barrier染色指標地址結構
圖16 ZGC load barrier染色指標地址結構
分代ZGC還有其他設計要點,幫助分代ZGC實作卓越的效能。簡列如下,因篇幅限制、理論性較強,讀者可以檢視參考資料第6點JEP 439,獲取更多技術細節。
4 接入分代ZGC與監控搭建
4.1 接入分代ZGC
接入分代ZGC的前提是要接入JDK21。
接入JDK21期間,你可能會遇到以下問題:
成功接入JDK21後,使用-XX:+UseZGC -XX:+ZGenerational即可開啟分代ZGC。JVM參數配置樣例:
-XX:MetaspaceSize=640m -XX:MaxMetaspaceSize=640m -Xms12g -Xmx12g -XX:+UseZGC -XX:+ZGenerational -Xlog:safepoint, classhisto*=trace,age*,gc*=info:file=日誌路徑/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
4.2 分代ZGC監控搭建
GC的暫停時間、GC的頻率、引發GC的原因等是衡量GC健康度的關鍵指標。因為分代ZGC的暫停時間極低,日常中主要關註:GC原因中的Allocation Stall頻率即可。GC原因包括:
接下來我們看下監控搭建,透過實作NotificationListener介面完成:自訂監聽、數據上報邏輯,然後將該監聽器註冊到垃圾回收的管理介面中,即可完成監控數據的獲取。範例如下,監控中包含了:堆記憶體使用、記憶體使用、GC暫停時間、GC暫停次數、GC原因、GC回收周期、GC回收次數監控項。
/** * GC通知過濾器 */public class InfoShowGCNotificationFilter implements NotificationFilter { /** * 是否啟用通知 */ @Override public boolean isNotificationEnabled(Notification notification) { boolean enable = GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals(notification.getType()); return enable; }}
/** * GC監聽器註冊 */@Slf4j@Componentpublic class InfoShowGCNotificationRegister implements InitializingBean { private static List<GarbageCollectorMXBean> garbageCollectorMXBeanList = ManagementFactory.getGarbageCollectorMXBeans(); @Override public void afterPropertiesSet() throws Exception { if (CollectionUtils.isEmpty(garbageCollectorMXBeanList)) { return; } for (GarbageCollectorMXBean garbageCollectorMXBean : garbageCollectorMXBeanList) { try { NotificationEmitter notificationEmitter = (NotificationEmitter) garbageCollectorMXBean; InfoShowGCNotificationListener notificationListener = new InfoShowGCNotificationListener(); // 聲明一個監聽器 InfoShowGCNotificationFilter notificationFilter = new InfoShowGCNotificationFilter(); // 聲GC通知過濾器 notificationEmitter.addNotificationListener(notificationListener, notificationFilter, garbageCollectorMXBean); // 註冊監聽器、通知過濾器 } catch (Exception e) { log.error("desc=GC監聽器註冊失敗 e=", e); } } }}
/** * GC監聽器 */@Slf4jpublic class InfoShowGCNotificationListener implements NotificationListener { /** * 處理通知 */ @Override public void handleNotification(Notification notification, Object handback) { try { GarbageCollectionNotificationInfo notificationInfo = GarbageCollectionNotificationInfo.from((CompositeData) notification.getUserData()); GcInfo gcInfo = notificationInfo.getGcInfo(); String gcName = notificationInfo.getGcName(); // GC類別名稱: Minor/Major GC String gcCause = notificationInfo.getGcCause(); // GC原因 String gcAction = notificationInfo.getGcAction(); // GC動作 // 因篇幅原因,字串不再定義為常量,直接書寫在程式碼中 if ("end of GC pause".equals(gcAction)) { ZGC_GC_PAUSE_TIME.labels("time").set(new BigDecimal(String.valueOf(gcInfo.getDuration())).doubleValue()); ZGC_GC_PAUSE_TIMES.labels("times").inc(); } if ("end of GC cycle".equals(gcAction)) { StringBuilder gcCauseStr = new StringBuilder(); gcCauseStr.append(gcName).append(" ").append(gcCause); ZGC_GC_CAUSE.labels(gcCauseStr.toString()).inc(); ZGC_GC_CYCLE_TIMES.labels("times").inc(); double gcCycleTime = gcInfo.getDuration(); ZGC_GC_CYCLE_TIME.labels("time").set(gcCycleTime); Map<String, MemoryUsage> gcBeforeMemoryInfo = gcInfo.getMemoryUsageBeforeGc(); Map<String, MemoryUsage> gcAfterMemoryInfo = gcInfo.getMemoryUsageAfterGc(); MemoryUsage youngGenerationMemoryBeforeGc = MapUtils.getObject(gcBeforeMemoryInfo, "ZGC Young Generation", null); MemoryUsage youngGenerationMemoryAfterGc = MapUtils.getObject(gcAfterMemoryInfo, "ZGC Young Generation", null); MemoryUsage oldGenerationMemoryBeforeGc = MapUtils.getObject(gcBeforeMemoryInfo, "ZGC Old Generation", null); MemoryUsage oldGenerationMemoryAfterGc = MapUtils.getObject(gcAfterMemoryInfo, "ZGC Old Generation", null); // 其他程式碼為GC發生前後的記憶體使用量計算、上報邏輯,因篇幅原因,省略。 // 如果想統計堆記憶體,需要排除以下這幾個記憶體部份:"Metaspace", "Compressed class Space", "CodeHeap 'profiled nmethods'", "CodeHeap 'non-profiled nmethods'", "CodeHeap 'non-nmethods'" } } catch (Exception e) { log.error("desc=上報分代ZGC監控數據異常 e=", e); } return; }}
Prometheus Collector定義如下:
public static final Counter ZGC_GC_CAUSE = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CAUSE").labelNames("reason").help("GC原因").register(); public static final Gauge ZGC_HEAP_USED = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_HEAP_USED").labelNames("phase").help("堆記憶體使用(M)").register(); public static final Gauge ZGC_MEMORY_USED = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_MEMORY_USED").labelNames("memory").help("記憶體使用(M)").register(); public static final Gauge ZGC_GC_PAUSE_TIME = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIME").labelNames("time").help("GC暫停時間ms").register(); public static final Counter ZGC_GC_PAUSE_TIMES = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIMES").labelNames("times").help("GC暫停次數").register(); public static final Gauge ZGC_GC_CYCLE_TIME = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CYCLE_TIME").labelNames("time").help("GC回收周期ms").register(); public static final Counter ZGC_GC_CYCLE_TIMES = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CYCLE_TIMES").labelNames("times").help("GC回收次數").register();
我們將GC的監控數據上報到了Prometheus中,監控看板樣例如圖17、18所示。
圖17 分代ZGC監控看板範例-1
圖18 分代ZGC監控看板範例-2
5 效能測試
5.1 壓測環境
JDK21的ZGC和JDK17的ZGC並無區別,為了驗證其一致性,也壓測過,篇幅原因不再贅述。本壓測透過對比JDK21(21.0.2_13)的ZGC和分代ZGC,評估下ZGC在支持分代前/後的效能。環境配置資訊如下:
圖19 壓測環境詳情
每組有3個例項。
壓測的介面為:App首頁商列、App主搜商列、App C2C商詳推薦等核心商品列表頁介面。
一共壓測3輪,每輪壓測時長為10分鐘,壓測流量倍數分別為日常流量峰值(QPS)的2倍、4倍、8倍。
5.2 壓測數據
匯總各輪次的壓測數據如圖20-22所示。第一行數據對應ZGC,第二行數據對應分代ZGC。
圖20 2倍日常峰值流量時,集群基礎數據
圖21 4倍日常峰值流量時,集群基礎數據
圖22 8倍日常峰值流量時,集群基礎數據
另附上:8倍日常流量峰值時,GC Allocation Stall、集群QPS、壓測錯誤率、GC暫停時間監控附圖,如圖23-26所示。
圖23 8倍日常峰值流量時,GC Allocation Stall對比數據
圖24 8倍日常峰值流量時,集群QPS對比數據
圖25 8倍日常峰值流量時,壓測錯誤率對比數據
圖26 8倍日常峰值流量時,GC暫停時間對比數據
5.3 壓測結論
以日常流量峰值的8倍場景為例,詳細數據為:
綜上,分代ZGC可提高資源利用率,更低的Allocation Stall次數,更高的集群QPS,更低的TP,更低的介面錯誤率,垃圾回收幾乎沒有停頓。至此,可全量使用JDK21分代ZGC。
6 後續進展
轉轉平台基礎體驗部的新媒體承接服務、商列服務都已經接入了JDK21,其中商列服務更是經歷了2024年618的實戰考驗,服務非常穩定。
其他核心服務之後陸續也會升級到JDK21。除了分代ZGC,借助JDK21的虛擬執行緒、結構化並行等新特性,將會帶來更多新的可能。
7 致謝
感謝架構部、工程效率部、運維部在支持JDK21過程中付出的努力,使得JDK21能夠順利地套用在服務中。
相信JDK21定會是下一個具有劃時代意義的版本,透過本次JDK的升級,讓我們保持在技術革命風口的最前沿。
8 參考資料
[1] Oracle Java SE Support Roadmap,2024,https://www.oracle.com/java/technologies/java-se-support-roadmap.html
[2] Iris Clark,Stefan Karlsson.The Z Garbage Collector (ZGC),2023,https://wiki.openjdk.org/display/zgc/Main
[3] The Z Garbage Collector,https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector.html
[4] Erik Österlund.Generational ZGC and Beyond,2023,https://inside.java/2023/08/31/generational-zgc-and-beyond/
[5] Garbage Collector Implementation,https://docs.oracle.com/en/java/javase/21/gctuning/garbage-collector-implementation.html
[6] Stefan Karlsson,Erik Helin,Erik Österlund,Vladimir Kozlov.JEP 439: Generational ZGC,2023,https://openjdk.org/jeps/439
[7] Deprecated API,https://docs.oracle.com/en/java/javase/21/docs/api/deprecated-list.html
[8] 苑沖.JDK21 調研踩坑記錄,2024
[9] Andy Wilkinson.Spring Boot 2.0 Migration Guide,2021,https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.0-Migration-Guide
作者
張鵬程,來自轉轉集團-研發中心-平台基礎體驗後端團隊,負責轉轉App後端開發工作。
來源-微信公眾號:轉轉技術
出處:https://mp.weixin.qq.com/s/bDp_4EAZoaGdXLV17s4vjw