HBase探索篇 _ ZGC和G1在HBase叢集之中的GC效能對比
1. 前言
本文為了銜接公眾號中的前幾篇ZGC相關的文章,繼續探索ZGC在HBase叢集中真實的GC表現能力,並把其與G1 GC做一個簡單的對比,驗證ZGC是否真如傳言中的那般,令人歎為觀止。
在前幾篇文章中,我為大家分享了使用JDK15編譯HBase(和CDH HBase)的踩坑記錄和ZGC在HBase叢集中的配置方法,有對ZGC感興趣的小夥伴,也可以親自動手嘗試一下,體驗體驗這個來自未來的技術。
2. GC之痛
很多低延遲高可用的Java服務的系統可用性飽受GC停頓的困擾,例如:HBase,GC停頓是影響HBase讀寫延時的一大元凶。GC停頓是指垃圾回收期間的STW(Stop The World),當STW發生的時候,所有應用執行緒停止活動,等待GC停頓的結束。
我們線上HBase叢集的GC優化經歷過CMS和G1,G1 GC調優之後,在很長的一段時間之內,是可以滿足我們線上介面對HBase查詢延時的需求。但更高敏感的業務上線之後,我們的叢集便立馬捉襟見肘,例如:我們的某些核心業務要求100ms內返回結果,並且可用性要達到99.9%甚至99.99%,但在各種各樣因素的綜合影響之下,我們的叢集一直無法滿足業務方的要求。
我們做過資料請求測試,持續用一個rowKey來迴圈請求HBase叢集,統計查詢耗時,一直無法滿足99.9%的查詢目標,而且,在耗時查詢發生的相同時間點,也伴隨著GC的發生。單次GC的停頓,可能是導致我們在這種查詢場景下,出現耗時查詢的最大元凶。
降低單次GC的時間和降低GC發生的頻率,可能會進一步提升我們叢集的查詢效能,出於這個目標,我們才開始了對ZGC的探索之路。
3. CMS和G1停頓時間瓶頸
介紹ZGC之前,先簡單回顧下CMS和G1的GC過程,以及停頓時間的瓶頸。CMS新生代的Young GC、G1和ZGC都基於標記-複製演算法,但演算法具體實現的不同就導致了巨大的效能差異。
標記-複製演算法應用在CMS新生代(ParNew是CMS預設的新生代垃圾回收器)和G1垃圾回收器中。標記-複製演算法可以分為三個階段:
- 標記階段,即從GC Roots集合開始,標記活躍物件;
- 轉移階段,即把活躍物件複製到新的記憶體地址上;
- 重定位階段,因為轉移導致物件的地址發生了變化,在重定位階段,所有指向物件舊地址的指標都要調整到物件新的地址上。
下面以G1為例,通過G1中標記-複製演算法過程(G1的Young GC和Mixed GC均採用該演算法),分析G1停頓耗時的主要瓶頸。G1垃圾回收週期如下圖所示:
G1的混合回收過程可以分為標記階段、清理階段和複製階段。
標記階段停頓分析
- 初始標記階段:初始標記階段是指從GC Roots出發標記全部直接子節點的過程,該階段是STW的。由於GC Roots數量不多,通常該階段耗時非常短。
- 併發標記階段:併發標記階段是指從GC Roots開始對堆中物件進行可達性分析,找出存活物件。該階段是併發的,即應用執行緒和GC執行緒可以同時活動。併發標記耗時相對長很多,但因為不是STW,所以我們不太關心該階段耗時的長短。
- 再標記階段:重新標記那些在併發標記階段發生變化的物件。該階段是STW的。
清理階段停頓分析
- 清理階段清點出有存活物件的分割槽和沒有存活物件的分割槽,該階段不會清理垃圾物件,也不會執行存活物件的複製。該階段是STW的。
複製階段停頓分析
- 複製演算法中的轉移階段需要分配新記憶體和複製物件的成員變數。轉移階段是STW的,其中記憶體分配通常耗時非常短,但物件成員變數的複製耗時有可能較長,這是因為複製耗時與存活物件數量與物件複雜度成正比。物件越複雜,複製耗時越長。
四個STW過程中,初始標記因為只標記GC Roots,耗時較短。再標記因為物件數少,耗時也較短。清理階段因為記憶體分割槽數量少,耗時也較短。轉移階段要處理所有存活的物件,耗時會較長。因此,G1停頓時間的瓶頸主要是標記-複製中的轉移階段STW。為什麼轉移階段不能和標記階段一樣併發執行呢?主要是G1未能解決轉移過程中準確定位物件地址的問題。
G1的Young GC和CMS的Young GC,其標記-複製全過程STW,不再詳細闡述,這裡列舉幾篇範欣欣大神寫的文章。
4. ZGC 原理
4.1 全併發的ZGC
與CMS中的ParNew和G1類似,ZGC也採用標記-複製演算法,不過ZGC對該演算法做了重大改進:ZGC在標記、轉移和重定位階段幾乎都是併發的,這是ZGC實現停頓時間小於10ms目標的最關鍵原因。
ZGC垃圾回收週期如下圖所示:
ZGC只有三個STW階段:初始標記,再標記,初始轉移。其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎所有暫停都只依賴於GC Roots集合大小,停頓時間不會隨著堆的大小或者活躍物件的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活物件的大小增加而增加。
4.2 ZGC中的關鍵技術
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嚮應用程式碼插入一小段程式碼的技術。當應用執行緒從堆中讀取物件引用時,就會執行這段程式碼。需要注意的是,僅“從堆中讀取物件引用”才會觸發這段程式碼。
讀屏障示例:
ZGC中讀屏障的程式碼作用:在物件標記和轉移過程中,用於確定物件的引用地址是否滿足條件,並作出相應動作。
ZGC併發處理演示
接下來詳細介紹ZGC一次垃圾回收週期中地址檢視的切換過程:
- 初始化:ZGC初始化之後,整個記憶體空間的地址檢視被設定為Remapped。程式正常執行,在記憶體中分配物件,滿足一定條件後垃圾回收啟動,此時進入標記階段。
- 併發標記階段:第一次進入標記階段時檢視為M0,如果物件被GC標記執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從Remapped調整為M0。所以,在標記階段結束之後,物件的地址要麼是M0檢視,要麼是Remapped。如果物件的地址是M0檢視,那麼說明物件是活躍的;如果物件的地址是Remapped檢視,說明物件是不活躍的。
- 併發轉移階段:標記結束後就進入轉移階段,此時地址檢視再次被設定為Remapped。如果物件被GC轉移執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從M0調整為Remapped。
其實,在標記階段存在兩個地址檢視M0和M1,上面的過程顯示只用了一個地址檢視。之所以設計成兩個,是為了區別前一次標記和當前標記。即第二次進入併發標記階段後,地址檢視調整為M1,而非M0。
著色指標
和讀屏障技術
不僅應用在併發轉移階段,還應用在併發標記階段:將物件設定為已標記,傳統的垃圾回收器需要進行一次記憶體訪問,並將物件存活資訊放在物件頭中;而在ZGC中,只需要設定指標地址的第42~45位即可,並且因為是暫存器訪問,所以速度比訪問記憶體更快。
5. 初探ZGC在HBase中的GC表現
ZGC相關的調優引數究竟該如何配置,實在無法提供出來一個標準的答案。我們參考美團ZGC實踐中的一個案例,來針對我所用的HBase叢集來進行ZGC相關引數的設定,然後在YCSB的壓測場景下,收集、分析ZGC的GC日誌。
參考的文章連結是,其中上文有關G1和ZGC的理論知識剖析也是摘選自這篇文章。
https://www.secpulse.com/archives/137305.html
新一代垃圾回收器ZGC的探索與實踐——美團
https://www.secpulse.com/archives/137305.html
此次測試使用的HBase叢集由三個節點組成,物理機配置:24核,記憶體370G,其中為HBase分配了31G的堆記憶體。HBase的版本是cdh-6.3.2-hbase2.1.0。壓測時使用的工具是阿里的AHBench(基於YCSB包裝了一層,方便對YCSB測試結果資料的收集和彙總),並保證在測試期間,唯一的變數是GC的使用方式。
YCSB壓測的場景是:資料量一個億,分別在使用G1和ZGC的場景下跑AHBench的full_test,然後對測試期間G1和ZGC的詳細gc日誌生成GC指標分析報告。
RegionServer重要配置引數示例:
-Xms31G -Xmx31G
-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=/var/log/hbase/region-server-zgc-%t.log:time,tid,tags:filecount=5,filesize=500m
--illegal-access=deny
--add-exports=java.base/jdk.internal.access=ALL-UNNAMED
--add-exports=java.base/jdk.internal=ALL-UNNAMED
--add-exports=java.base/jdk.internal.misc=ALL-UNNAMED
--add-exports=java.base/sun.security.pkcs=ALL-UNNAMED
--add-exports=java.base/sun.nio.ch=ALL-UNNAMED
--add-opens=java.base/java.nio=ALL-UNNAMED
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
-Dorg.apache.hbase.thirdparty.io.netty.tryReflectionSetAccessible=true
-Xms -Xmx:堆的最大記憶體和最小記憶體,這裡都設定為31G,程式的堆記憶體將保持31G不變。
-XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize: 設定CodeCache的大小, JIT編譯的程式碼都放在CodeCache中,一般服務64m或128m就已經足夠。這裡設定的數值也只是參考了美團ZGC實踐示例。
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC:啟用ZGC的配置。
-XX:ConcGCThreads:併發回收垃圾的執行緒。預設是總核數的12.5%,8核CPU預設是1。調大後GC變快,但會佔用程式執行時的CPU資源,吞吐會受到影響。
這個引數的設定效果,在CDH的CPU指標監控圖例中就可以明顯看得到。
在ZGC測試期間,我們觀察到CPU的消耗較以往顯著增加,尤其是在叢集高負載的情況下格外明顯,而其他使用G1 GC的HBase叢集中的CPU負載趨勢則如下圖所示:
-XX:ParallelGCThreads:STW階段使用執行緒數,預設是總核數的60%。
-XX:ZCollectionInterval:ZGC發生的最小時間間隔,單位秒,該引數的設定效果在CDH中的GC時間監控圖例中得到體現。
正常情況下GC發生的頻次,時間間隔均勻,正是兩分鐘(120s)。-XX:ZCollectionInterval=120。而且,在叢集高負載的情況下,ZGC的GC時間可以達到分鐘級別,這也正印證了,ZGC全程併發,不會影響到你的應用程序。因為,如果是秒級別甚至分鐘級別的STW,你的業務方早已提刀而來。G1 GC場景下,GC的消耗時間趨勢如下圖:
我們在進行G1 GC調優設定引數的時候,期望的GC時間是在100ms,但真實的情況是不管如何調整,GC的耗時遠超100ms。
200ms GC耗時均值中的STW的時間佔比,將直接影響著HBase叢集查詢延時的佔比。
-XX:ZAllocationSpikeTolerance:ZGC觸發自適應演算法的修正係數,預設2,數值越大,越早的觸發ZGC。
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否啟用主動回收,預設開啟,這裡的配置表示關閉。
-Xlog:設定GC日誌中的內容、格式、位置以及每個日誌的大小。
理解ZGC的觸發時機
相比於CMS和G1的GC觸發機制,ZGC的GC觸發機制有很大不同。ZGC的核心特點是併發,GC過程中一直有新的物件產生。如何保證在GC完成之前,新產生的物件不會將堆佔滿,是ZGC引數調優的第一大目標。因為在ZGC中,當垃圾來不及回收將堆佔滿時,會導致正在執行的執行緒停頓,持續時間可能長達秒級之久。
ZGC有多種GC觸發機制,總結如下:
- 阻塞記憶體分配請求觸發:當垃圾來不及回收,垃圾將堆佔滿時,會導致部分執行緒阻塞。我們應當避免出現這種觸發方式。日誌中關鍵字是“Allocation Stall”。
- 基於分配速率的自適應演算法:最主要的GC觸發方式,其演算法原理可簡單描述為”ZGC根據近期的物件分配速率以及GC時間,計算出當記憶體佔用達到什麼閾值時觸發下一次GC”。自適應演算法的詳細理論可參考彭成寒《新一代垃圾回收器ZGC設計與實現》一書中的內容。通過ZAllocationSpikeTolerance引數控制閾值大小,該引數預設2,數值越大,越早的觸發GC。我們通過調整此引數解決了一些問題。日誌中關鍵字是“Allocation Rate”。
- 基於固定時間間隔:通過ZCollectionInterval控制,適合應對突增流量場景。流量平穩變化時,自適應演算法可能在堆使用率達到95%以上才觸發GC。流量突增時,自適應演算法觸發的時機可能會過晚,導致部分執行緒阻塞。我們通過調整此引數解決流量突增場景的問題,比如定時活動、秒殺等場景。日誌中關鍵字是“Timer”。
- 主動觸發規則:類似於固定間隔規則,但時間間隔不固定,是ZGC自行算出來的時機,我們的服務因為已經加了基於固定時間間隔的觸發機制,所以通過-ZProactive引數將該功能關閉,以免GC頻繁,影響服務可用性。日誌中關鍵字是“Proactive”。
- 預熱規則:服務剛啟動時出現,一般不需要關注。日誌中關鍵字是“Warmup”。
- 外部觸發:程式碼中顯式呼叫System.gc()觸發。日誌中關鍵字是“System.gc()”。
- 元資料分配觸發:元資料區不足時導致,一般不需要關注。日誌中關鍵字是“Metadata GC Threshold”。
更細緻的GC 日誌分析,可以參考美團ZGC實踐那篇文章中的分析思路。
6. ZGC與G1 GC的資料統計對比
我們收集ZGC與G1 GC在相同壓測場景下生成的詳細gc日誌,上傳到https://gceasy.io/之後,分別得出的GC報告如下圖所示:
6.1 G1
6.2 ZGC
僅從這兩個GC報告對比來看,ZGC確實做到了幾乎百分之百的GC時間在10ms內。
6.3 G1與ZGC吞吐量相關指標比較
以下圖例記錄了相同YCSB壓測場景下,G1與ZGC各項指標比較。
讀寫吞吐量指標比較
讀寫平均延時指標比較
G1與ZGCp999延時指標比較
以上指標對比,在不同的壓測場景,不同的叢集環境之下的結果可能會有所不同,不能代表線上真正的表現情況,希望大家如感興趣,可以親自嘗試測試一波。
7. 總結
本篇文章為大家分享了ZGC的特點,簡單記錄了ZGC的一些核心技術,如著色指標、讀屏障等。並在相同的YCSB壓測場景下,分別測試了G1和ZGC在真實的應用環境中的GC的表現能力,並得出GC分析報告,從GC停頓時間和讀寫吞吐、延遲等方面,做了比較詳細的對比,然後初步驗證了以下幾個觀點:
- ZGC 可以達到幾乎百分百GC耗時在10ms內的目標
- 通過設定引數,可以主動控制ZGC的GC發生頻率
- 與G1相比,ZGC在GC過程中會消耗更多的CPU
有關GC更深入的理解和使用,甚至進一步調優ZGC的表現能力,這將在後續的文章中繼續和大家探討。同時,本人對GC的認知有限,文中個別地方描述不恰當,或對實驗資料心存異議的夥伴,還請及時告知。
8. 參考連結
https://www.secpulse.com/archives/137305.html
http://hbasefly.com/2016/05/21/hbase-gc-1/