不少低延遲高可用Java服務的系統可用性常常受GC停頓的困擾,做爲新一代的低延遲垃圾回收器,ZGC在大內存低延遲服務的內存管理和回收方面,有着很是不錯的表現。本文從GC之痛、ZGC原理、ZGC調優實踐、升級ZGC效果等維度展開,詳述了ZGC在美團低延時場景中的應用,以及在生產環境中取得的一些成果。但願這些實踐對你們有所幫助或者啓發。html
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延遲垃圾回收器,它的設計目標包括:java
從設計目標來看,咱們知道ZGC適用於大內存低延遲服務的內存管理和回收。本文主要介紹ZGC在低延時場景中的應用和卓越表現,文章內容主要分爲四部分:git
不少低延遲高可用Java服務的系統可用性常常受GC停頓的困擾。GC停頓指垃圾回收期間STW(Stop The World),當STW時,全部應用線程中止活動,等待GC停頓結束。以美團風控服務爲例,部分上游業務要求風控服務65ms內返回結果,而且可用性要達到99.99%。但由於GC停頓,咱們未能達到上述可用性目標。當時使用的是CMS垃圾回收器,單次Young GC 40ms,一分鐘10次,接口平均響應時間30ms。經過計算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的請求的響應時間會增長0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的請求響應時間會增長40ms。可見,GC停頓對響應時間的影響較大。爲了下降GC停頓對系統可用性的影響,咱們從下降單次GC時間和下降GC頻率兩個角度出發進行了調優,還測試過G1垃圾回收器,但這三項措施均未能下降GC對服務可用性的影響。github
在介紹ZGC以前,首先回顧一下CMS和G1的GC過程以及停頓時間的瓶頸。CMS新生代的Young GC、G1和ZGC都基於標記-複製算法,但算法具體實現的不一樣就致使了巨大的性能差別。算法
標記-複製算法應用在CMS新生代(ParNew是CMS默認的新生代垃圾回收器)和G1垃圾回收器中。標記-複製算法能夠分爲三個階段:apache
下面以G1爲例,經過G1中標記-複製算法過程(G1的Young GC和Mixed GC均採用該算法),分析G1停頓耗時的主要瓶頸。G1垃圾回收週期以下圖所示:api
G1的混合回收過程能夠分爲標記階段、清理階段和複製階段。安全
標記階段停頓分析微信
清理階段停頓分析markdown
複製階段停頓分析
四個STW過程當中,初始標記由於只標記GC Roots,耗時較短。再標記由於對象數少,耗時也較短。清理階段由於內存分區數量少,耗時也較短。轉移階段要處理全部存活的對象,耗時會較長。所以,G1停頓時間的瓶頸主要是標記-複製中的轉移階段STW。爲何轉移階段不能和標記階段同樣併發執行呢?主要是G1未能解決轉移過程當中準肯定位對象地址的問題。
G1的Young GC和CMS的Young GC,其標記-複製全過程STW,這裏再也不詳細闡述。
與CMS中的ParNew和G1相似,ZGC也採用標記-複製算法,不過ZGC對該算法作了重大改進:ZGC在標記、轉移和重定位階段幾乎都是併發的,這是ZGC實現停頓時間小於10ms目標的最關鍵緣由。
ZGC垃圾回收週期以下圖所示:
ZGC只有三個STW階段:初始標記,再標記,初始轉移。其中,初始標記和初始轉移分別都只須要掃描全部GC Roots,其處理時間和GC Roots的數量成正比,通常狀況耗時很是短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎全部暫停都只依賴於GC Roots集合大小,停頓時間不會隨着堆的大小或者活躍對象的大小而增長。與ZGC對比,G1的轉移階段徹底STW的,且停頓時間隨存活對象的大小增長而增長。
ZGC經過着色指針和讀屏障技術,解決了轉移過程當中準確訪問對象的問題,實現了併發轉移。大體原理描述以下:併發轉移中「併發」意味着GC線程在轉移對象的過程當中,應用線程也在不停地訪問對象。假設對象發生轉移,但對象地址未及時更新,那麼應用線程可能訪問到舊地址,從而形成錯誤。而在ZGC中,應用線程訪問對象將觸發「讀屏障」,若是發現對象被移動了,那麼「讀屏障」會把讀出來的指針更新到對象的新地址上,這樣應用線程始終訪問的都是對象的新地址。那麼,JVM是如何判斷對象被移動過呢?就是利用對象引用的地址,即着色指針。下面介紹着色指針和讀屏障技術細節。
着色指針
着色指針是一種將信息存儲在指針中的技術。
ZGC僅支持64位系統,它把64位虛擬地址空間劃分爲多個子空間,以下圖所示:
其中,[0~4TB) 對應Java堆,[4TB ~ 8TB) 稱爲M0地址空間,[8TB ~ 12TB) 稱爲M1地址空間,[12TB ~ 16TB) 預留未使用,[16TB ~ 20TB) 稱爲Remapped空間。
當應用程序建立對象時,首先在堆空間申請一個虛擬地址,但該虛擬地址並不會映射到真正的物理地址。ZGC同時會爲該對象在M0、M1和Remapped地址空間分別申請一個虛擬地址,且這三個虛擬地址對應同一個物理地址,但這三個空間在同一時間有且只有一個空間有效。ZGC之因此設置三個虛擬地址空間,是由於它使用「空間換時間」思想,去下降GC停頓時間。「空間換時間」中的空間是虛擬空間,而不是真正的物理空間。後續章節將詳細介紹這三個空間的切換過程。
與上述地址空間劃分相對應,ZGC實際僅使用64位地址空間的第041位,而第4245位存儲元數據,第47~63位固定爲0。
ZGC將對象存活信息存儲在42~45位中,這與傳統的垃圾回收並將對象存活信息放在對象頭中徹底不一樣。
讀屏障
讀屏障是JVM嚮應用代碼插入一小段代碼的技術。當應用線程從堆中讀取對象引用時,就會執行這段代碼。須要注意的是,僅「從堆中讀取對象引用」纔會觸發這段代碼。
讀屏障示例:
Object o = obj.FieldA // 從堆中讀取引用,須要加入屏障
<Load barrier>
Object p = o // 無需加入屏障,由於不是從堆中讀取引用
o.dosomething() // 無需加入屏障,由於不是從堆中讀取引用
int i = obj.FieldB //無需加入屏障,由於不是對象引用
複製代碼
ZGC中讀屏障的代碼做用:在對象標記和轉移過程當中,用於肯定對象的引用地址是否知足條件,並做出相應動做。
接下來詳細介紹ZGC一次垃圾回收週期中地址視圖的切換過程:
其實,在標記階段存在兩個地址視圖M0和M1,上面的過程顯示只用了一個地址視圖。之因此設計成兩個,是爲了區別前一次標記和當前標記。也即,第二次進入併發標記階段後,地址視圖調整爲M1,而非M0。
着色指針和讀屏障技術不只應用在併發轉移階段,還應用在併發標記階段:將對象設置爲已標記,傳統的垃圾回收器須要進行一次內存訪問,並將對象存活信息放在對象頭中;而在ZGC中,只須要設置指針地址的第42~45位便可,而且由於是寄存器訪問,因此速度比訪問內存更快。
ZGC不是「銀彈」,須要根據服務的具體特色進行調優。網絡上能搜索到實戰經驗較少,調優理論需自行摸索,咱們在此階段也耗費了很多時間,最終才達到理想的性能。本文的一個目的是列舉一些使用ZGC時常見的問題,幫助你們使用ZGC提升服務可用性。
理解ZGC重要配置參數
以咱們服務在生產環境中ZGC參數配置爲例,說明各個參數的做用:
重要參數配置樣例:
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
複製代碼
-Xms -Xmx:堆的最大內存和最小內存,這裏都設置爲10G,程序的堆內存將保持10G不變。 -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize:設置CodeCache的大小, JIT編譯的代碼都放在CodeCache中,通常服務64m或128m就已經足夠。咱們的服務由於有必定特殊性,因此設置的較大,後面會詳細介紹。 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:啓用ZGC的配置。 -XX:ConcGCThreads:併發回收垃圾的線程。默認是總核數的12.5%,8核CPU默認是1。調大後GC變快,但會佔用程序運行時的CPU資源,吞吐會受到影響。 -XX:ParallelGCThreads:STW階段使用線程數,默認是總核數的60%。 -XX:ZCollectionInterval:ZGC發生的最小時間間隔,單位秒。 -XX:ZAllocationSpikeTolerance:ZGC觸發自適應算法的修正係數,默認2,數值越大,越早的觸發ZGC。 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否啓用主動回收,默認開啓,這裏的配置表示關閉。 -Xlog:設置GC日誌中的內容、格式、位置以及每一個日誌的大小。
理解ZGC觸發時機
相比於CMS和G1的GC觸發機制,ZGC的GC觸發機制有很大不一樣。ZGC的核心特色是併發,GC過程當中一直有新的對象產生。如何保證在GC完成以前,新產生的對象不會將堆佔滿,是ZGC參數調優的第一大目標。由於在ZGC中,當垃圾來不及回收將堆佔滿時,會致使正在運行的線程停頓,持續時間可能長達秒級之久。
ZGC有多種GC觸發機制,總結以下:
理解ZGC日誌
一次完整的GC過程,須要注意的點已在圖中標出。
注意:該日誌過濾了進入安全點的信息。正常狀況,在一次GC過程當中還穿插着進入安全點的操做。
GC日誌中每一行都註明了GC過程當中的信息,關鍵信息以下:
日誌中內容較多,關鍵點已用紅線標出,含義較好理解,更詳細的解釋你們能夠自行在網上查閱資料。
理解ZGC停頓緣由
咱們在實戰過程當中共發現了6種使程序停頓的場景,分別以下:
咱們維護的服務名叫Zeus,它是美團的規則平臺,經常使用於風控場景中的規則管理。規則運行是基於開源的表達式執行引擎Aviator。Aviator內部將每一條表達式轉化成Java的一個類,經過調用該類的接口實現表達式邏輯。
Zeus服務內的規則數量超過萬條,且每臺機器天天的請求量幾百萬。這些客觀條件致使Aviator生成的類和方法會產生不少的ClassLoader和CodeCache,這些在使用ZGC時都成爲過GC的性能瓶頸。接下來介紹兩類調優案例。
內存分配阻塞,系統停頓可達到秒級
案例一:秒殺活動中流量突增,出現性能毛刺
日誌信息:對比出現性能毛刺時間點的GC日誌和業務日誌,發現JVM停頓了較長時間,且停頓時GC日誌中有大量的「Allocation Stall」日誌。
分析:這種案例多出如今「自適應算法」爲主要GC觸發機制的場景中。ZGC是一款併發的垃圾回收器,GC線程和應用線程同時活動,在GC過程當中,還會產生新的對象。GC完成以前,新產生的對象將堆佔滿,那麼應用線程可能由於申請內存失敗而致使線程阻塞。當秒殺活動開始,大量請求打入系統,但自適應算法計算的GC觸發間隔較長,致使GC觸發不及時,引發了內存分配阻塞,致使停頓。
解決方法:
(1)開啓」基於固定時間間隔「的GC觸發機制:-XX:ZCollectionInterval。好比調整爲5秒,甚至更短。
(2)增大修正係數-XX:ZAllocationSpikeTolerance,更早觸發GC。ZGC採用正態分佈模型預測內存分配速率,模型修正係數ZAllocationSpikeTolerance默認值爲2,值越大,越早的觸發GC,Zeus中全部集羣設置的是5。
案例二:壓測時,流量逐漸增大到必定程度後,出現性能毛刺
日誌信息:平均1秒GC一次,兩次GC之間幾乎沒有間隔。
分析:GC觸發及時,但內存標記和回收速度過慢,引發內存分配阻塞,致使停頓。
解決方法:增大-XX:ConcGCThreads, 加快併發標記和回收速度。ConcGCThreads默認值是核數的1/8,8核機器,默認值是1。該參數影響系統吞吐,若是GC間隔時間大於GC週期,不建議調整該參數。
GC Roots 數量大,單次GC停頓時間長
案例三: 單次GC停頓時間30ms,與預期停頓10ms左右有較大差距
日誌信息:觀察ZGC日誌信息統計,「Pause Roots ClassLoaderDataGraph」一項耗時較長。
分析:dump內存文件,發現系統中有上萬個ClassLoader實例。咱們知道ClassLoader屬於GC Roots一部分,且ZGC停頓時間與GC Roots成正比,GC Roots數量越大,停頓時間越久。再進一步分析,ClassLoader的類名代表,這些ClassLoader均由Aviator組件生成。分析Aviator源碼,發現Aviator對每個表達式新生成類時,會建立一個ClassLoader,這致使了ClassLoader數量巨大的問題。在更高Aviator版本中,該問題已經被修復,即僅建立一個ClassLoader爲全部表達式生成類。
解決方法:升級Aviator組件版本,避免生成多餘的ClassLoader。
案例四:服務啓動後,運行時間越長,單次GC時間越長,重啓後恢復
日誌信息:觀察ZGC日誌信息統計,「Pause Roots CodeCache」的耗時會隨着服務運行時間逐漸增加。
分析:CodeCache空間用於存放Java熱點代碼的JIT編譯結果,而CodeCache也屬於GC Roots一部分。經過添加-XX:+PrintCodeCacheOnCompilation參數,打印CodeCache中的被優化的方法,發現大量的Aviator表達式代碼。定位到根本緣由,每一個表達式都是一個類中一個方法。隨着運行時間越長,執行次數增長,這些方法會被JIT優化編譯進入到Code Cache中,致使CodeCache愈來愈大。
解決方法:JIT有一些參數配置能夠調整JIT編譯的條件,但對於咱們的問題都不太適用。咱們最終經過業務優化解決,刪除不須要執行的Aviator表達式,從而避免了大量Aviator方法進入CodeCache中。
值得一提的是,咱們並非在全部這些問題都解決後才全量部署全部集羣。即便開始有各類各樣的毛刺,但計算後發現,有各類問題的ZGC也比以前的CMS對服務可用性影響小。因此從開始準備使用ZGC到全量部署,大概用了2周的時間。在以後的3個月時間裏,咱們邊作業務需求,邊跟進這些問題,最終逐個解決了上述問題,從而使ZGC在各個集羣上達到了一個更好表現。
TP(Top Percentile)是一項衡量系統延遲的指標:TP999表示99.9%請求都能被響應的最小耗時;TP99表示99%請求都能被響應的最小耗時。
在Zeus服務不一樣集羣中,ZGC在低延遲(TP999 < 200ms)場景中收益較大:
超低延遲(TP999 < 20ms)和高延遲(TP999 > 200ms)服務收益不大,緣由是這些服務的響應時間瓶頸不是GC,而是外部依賴的性能。
對吞吐量優先的場景,ZGC可能並不適合。例如,Zeus某離線集羣原先使用CMS,升級ZGC後,系統吞吐量明顯下降。究其緣由有二:第一,ZGC是單代垃圾回收器,而CMS是分代垃圾回收器。單代垃圾回收器每次處理的對象更多,更耗費CPU資源;第二,ZGC使用讀屏障,讀屏障操做需耗費額外的計算資源。
ZGC做爲下一代垃圾回收器,性能很是優秀。ZGC垃圾回收過程幾乎所有是併發,實際STW停頓時間極短,不到10ms。這得益於其採用的着色指針和讀屏障技術。
Zeus在升級JDK 11+ZGC中,經過將風險和問題分類,而後各個擊破,最終順利實現了升級目標,GC停頓也幾乎再也不影響系統可用性。
最後推薦你們升級ZGC,Zeus系統由於業務特色,遇到了較多問題,而風控其餘團隊在升級時都很是順利。歡迎你們加入「ZGC使用交流」羣。
在生產環境升級JDK 11,使用ZGC,你們最關心的可能不是效果怎麼樣,而是這個新版本用的人少,網上實踐也少,靠不靠譜,穩不穩定。其次是升級成本會不會很大,萬一不成功豈不是白白浪費時間。因此,在使用新技術前,首先要作的是評估收益、成本和風險。
評估收益
對於JDK這種世界關注的程序,大版本升級所引入的新技術通常已經在理論上通過驗證。咱們要作的事情就是肯定當前系統的瓶頸是不是新版本JDK可解決的問題,切忌問題未診斷清楚就採起措施。評估完收益以後再評估成本和風險,收益過大或者太小,其餘兩項影響權重就會小不少。
以本文開頭提到的案例爲例,假設GC次數不變(10次/分鐘),且單次GC時間從40ms下降10ms。經過計算,一分鐘內有100/60000 = 0.17%的時間在進行GC,且期間全部請求僅停頓10ms,GC期間影響的請求數和因GC增長的延遲都有所減小。
評估成本
這裏主要指升級所須要的人力成本。此項相對比較成熟,根據新技術的使用手冊判斷改動點。跟作其餘項目區別不大,再也不具體細說。
在咱們的實踐中,兩週時間完成線上部署,達到安全穩定運行的狀態。後續持續迭代3個月,根據業務場景對ZGC進行了更契合的優化適配。
評估風險
升級JDK的風險能夠分爲三類:
通過分類後,每類風險的應對轉化成了常見的測試問題,再也不屬於未知風險。風險是指不肯定的事情,若是不肯定的事情都能轉化成可肯定的事情,意味着風險已消除。
選擇JDK 11,是由於在JDK 11中首次支持ZGC,並且JDK 11屬於長期支持(Long Term Support,LTS)版本,至少會被維護三年,普通版本(如JDK 十二、JDK 13和JDK 14)只有6個月的維護週期,不建議使用。
本地測試環境安裝
從兩個源OpenJDK和OracleJDK 下載JDK 11,二個版本的JDK主要區別是長時期的免費和付費,短時間內都免費。注意JDK 11版本中的ZGC不支持Mac OS系統,在Mac OS系統上使用JDK 11只能用其餘垃圾回收器,如G1。
生產環境安裝
升級JDK 11不只僅是升級本身項目的JDK版本,還須要編譯、發佈部署、運行、監控、性能內存分析工具等項目支持。美團內部的實踐:
編譯打包:美團發佈系統支持選擇JDK 11進行編譯打包。 線上運行 & 全量部署:要求線上機器已安裝JDK11,有3種方式:
1.新申請默認安裝JDK 11的虛擬機:試用JDK 11時可用這種方式;全量部署時,若是新申請機器數量過多,可能沒有足夠機器資源。 2.經過手寫腳本給存量虛擬機安裝JDK 11:不推薦,業務同窗過多參與到運維當中。 3.使用容器提供的鏡像部署功能,在打包鏡像時安裝JDK 11:推薦方式,不須要新申請資源。
監控指標:主要是GC的時間和頻率,咱們經過美團的CAT監控系統支持ZGC數據的收集(CAT已開源)。 性能內存分析:線上遇到性能問題時,還須要藉助Profiling工具,美團的性能診斷優化平臺Scalpel已支持JDK 11的性能內存分析。若是你的公司沒有相關工具,推薦使用JProfier。
解決組件兼容性
咱們的項目包含二十多萬行代碼,須要從JDK 7升級到JDK 11,依賴組件衆多。雖然看起來升級會比較複雜,但實際只花了兩天時間即解決了兼容性問題。具體過程以下:
1.編譯,須要修改pom文件中的build配置,根據報錯做修改,主要有兩類:
a.一些類被刪除:好比「sun.misc.BASE64Encoder」,找到替換類java.util.Base64便可。
b.組件依賴版本不兼容JDK 11問題:找到對應依賴組件,搜索最新版本,通常都支持JDK 11。
2.編譯成功後,啓動運行,此時仍有可能組件依賴版本問題,按照編譯時的方式處理便可。
升級所修改的依賴:
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-parent</artifactId>
<version>6.0.16.Final</version>
</dependency>
<dependency>
<groupId>com.sankuai.inf</groupId>
<artifactId>patriot-sdk</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.39.Final</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
複製代碼
JDK 11已經出來兩年,常見的依賴組件都有兼容性版本。可是,若是是公司內部提供的公司級組件,可能會不兼容JDK 11,須要推進相關組件進行升級。若是對方升級較爲困難,能夠考慮拆分功能,將依賴這些組件的功能單獨部署,繼續使用低版本JDK。隨着JDK11的卓越性能被你們悉知,相信會有更多團隊會用JDK 11解決GC問題,使用者越多,各個組件升級的動力也會越大。
驗證功能正確性
經過完備的單測、集成和迴歸測試,保證功能正確性。
閱讀更多技術文章,請掃碼關注微信公衆號-美團技術團隊!