如何降低90%Java垃圾回收時間?以阿里HBase的GC優化實踐為例
GC一直是Java應用中討論的一個熱門話題,尤其在像HBase這樣的大型線上儲存系統中,大堆下(百GB)的GC停頓延遲產生的線上實時影響,成為核心和應用開發者的一大痛點。
過去的一年裡,我們準備在Ali-HBase上突破這個被普遍認知的痛點,為此進行了深度分析及全面創新的工作,獲得了一些比較好的效果。以螞蟻風控場景為例,HBase的線上young GC時間從120ms減少到15ms,結合阿里巴巴JDK團隊提供的利器——ZenGC,進一步在實驗室壓測環境做到了5ms。本文主要介紹我們過去在這方面的一些工作和技術思想。
背景
JVM的GC機制對開發者遮蔽了記憶體管理的細節,提高了開發效率。說起GC,很多人的第一反應可能是JVM長時間停頓或者FGC導致程式卡死不可服務的情況。但就HBase這樣的大資料儲存服務而言,JVM帶來的GC挑戰相當複雜和艱難。原因有三:
1、記憶體規模巨大。線上HBase程式多數為96G大堆,今年新機型已經上線部分160G以上的堆配置
2、物件狀態複雜。HBase伺服器內部會維護大量的讀寫cache,達到數十GB的規模。HBase以表格的形式提供有序的服務資料,資料以一定的結構組織起來,這些資料結構產生了過億級別的物件和引用
3、young GC頻率高。訪問壓力越大,young區的記憶體消耗越快,部分繁忙的叢集可以達到每秒1~2次youngGC, 大的young區可以減少GC頻率,但是會帶來更大的young GC停頓,損害業務的實時性需求。
思路
1、HBase作為一個儲存系統,使用了大量的記憶體作為寫buffer和讀cache,比如96G的大堆(4G young + 92G old)下,寫buffer+讀cache會佔用70%以上的記憶體(約70G),本身堆內的記憶體水位會控制在85%,而剩餘的佔用記憶體就只有在10G以內了。所以,如果我們能在應用層面自管理好這70G+的記憶體,那麼對於JVM而言,百G大堆的GC壓力就會等價於10G小堆的GC壓力,並且未來面對更大的堆也不會惡化膨脹。 在這個解決思路下,我們線上的young GC時間獲得了從120ms到15ms的優化效果。
2、在一個高吞吐的資料密集型服務系統中,大量的臨時物件被頻繁建立與回收,如何能夠針對性管理這些臨時物件的分配與回收,AliJDK團隊研發了一種新的基於租戶的GC演算法—ZenGC。集團HBase基於這個新的ZenGC演算法進行改造,我們在實驗室中壓測的young GC時間從15ms減少到5ms,這是一個未曾期望的極致效果。
下面將逐一介紹Ali-HBase版本GC優化所使用的關鍵技術。
消滅一億個物件:更快更省的CCSMap
目前HBase使用的儲存模型是LSMTree模型,寫入的資料會在記憶體中暫存到一定規模後再dump到磁碟上形成檔案。
下面我們將其簡稱為寫快取。寫快取是可查詢的,這就要求資料在記憶體中有序。為了提高併發讀寫效率,並達成資料有序且支援seek&scan的基本要求,SkipList是使用得比較廣泛的資料結構。
我們以JDK自帶的ConcurrentSkipListMap為例子進行分析,它有下面三個問題:
-
內部物件繁多。每儲存一個元素,平均需要4個物件(index+node+key+value,平均層高為1)
-
新插入的物件在young區,老物件在old區。當不斷插入元素時,內部的引用關係會頻繁發生變化,無論是ParNew演算法的CardTable標記,還是G1演算法的RSet標記,都有可能觸發old區掃描。
-
業務寫入的KeyValue元素並不是規整長度的,當它晉升到old區時,可能產生大量的記憶體碎片。
問題1使得young區GC的物件掃描成本很高,young GC時晉升物件更多。問題2使得young GC時需要掃描的old區域會擴大。問題3使得記憶體碎片化導致的FGC概率升高。當寫入的元素較小時,問題會變得更加嚴重。我們曾對線上的RegionServer程式進行統計,活躍Objects有1億2千萬之多!
分析完當前young GC的最大敵人後,一個大膽的想法就產生了,既然寫快取的分配,訪問,銷燬,回收都是由我們來管理的,如果讓JVM“看不到”寫快取,我們自己來管理寫快取的生命週期,GC問題自然也就迎刃而解了。
說起讓JVM“看不到”,可能很多人想到的是off-heap的解決方案,但是這對寫快取來說沒那麼簡單,因為即使把KeyValue放到offheap,也無法避免問題1和問題2。而1和2也是young GC的最大困擾。
問題現在被轉化成了:如何不使用JVM物件來構建一個有序的支援併發訪問的Map。
當然我們也不能接受效能損失,因為寫入Map的速度和HBase的寫吞吐息息相關。
需求再次強化:如何不使用物件來構建一個有序的支援併發訪問的Map,且不能有效能損失。
為了達成這個目標,我們設計了這樣一個資料結構:
-
它使用連續的記憶體(堆內or堆外),我們通過程式碼控制內部結構而不是依賴於JVM的物件機制
-
在邏輯上也是一個SkipList,支援無鎖的併發寫入和查詢
-
控制指標和資料都存放在連續記憶體中
上圖所展示的即是CCSMap(CompactedConcurrentSkipListMap)的記憶體結構。 我們以大塊的記憶體段(Chunk)的方式申請寫快取記憶體。每個Chunk包含多個Node,每個Node對應一個元素。新插入的元素永遠放在已使用記憶體的末尾。Node內部複雜的結構,存放了Index/Next/Key/Value等維護資訊和資料。新插入的元素需要拷貝到Node結構中。當HBase發生寫快取dump時,整個CCSMap的所有Chunk都會被回收。當元素被刪除時,我們只是邏輯上把元素從連結串列裡"踢走",不會把元素實際從記憶體中收回(當然做實際回收也是有方法,就HBase而言沒有那個必要)。
插入KeyValue資料時雖然多了一遍拷貝,但是就絕大多數情況而言,拷貝反而會更快。因為從CCSMap的結構來看,一個Map中的元素的控制節點和KeyValue在記憶體上是鄰近的,利用CPU快取的效率更高,seek會更快。對於SkipList來說,寫速度其實是bound在seek速度上的,實際拷貝產生的overhead遠不如seek的開銷。根據我們的測試,CCSMap和JDK自帶的ConcurrentSkipListMap相比,50Byte長度KV的測試中,讀寫吞吐提升了20~30%。
由於沒有了JVM物件,每個JVM物件至少佔用16Byte空間也可以被節省掉(8byte為標記預留,8byte為型別指標)。還是以50Byte長度KeyValue為例,CCSMap和JDK自帶的ConcurrentSkipListMap相比,記憶體佔用減少了40%。
CCSMap在生產中上線後,實際優化效果: young GC從120ms+減少到了30ms
優化前
優化後
使用了CCSMap後,原來的1億2千萬個存活物件被縮減到了千萬級別以內,大大減輕了GC壓力。由於緊緻的記憶體排布,寫入吞吐能力也得到了30%的提升。
永不晉升的Cache:BucketCache
HBase以Block的方式組織磁碟上的資料。一個典型的HBase Block大小在16K~64K之間。HBase內部會維護BlockCache來減少磁碟的I/O。BlockCache和寫快取一樣,不符合GC演算法理論裡的分代假說,天生就是對GC演算法不友好的 —— 既不稍縱即逝,也不永久存活。
一段Block資料從磁碟被load到JVM記憶體中,生命週期從分鐘到月不等,絕大部分Block都會進入old區,只有Major GC時才會讓它被JVM回收。它的麻煩主要體現在:
-
HBase Block的大小不是固定的,且相對較大,記憶體容易碎片化
-
在ParNew演算法上,晉升麻煩。麻煩不是體現在拷貝代價上,而是因為尺寸較大,尋找合適的空間存放HBase Block的代價較高。
讀快取優化的思路則是,向JVM申請一塊永不歸還的記憶體作為BlockCache,我們自己對記憶體進行固定大小的分段,當Block載入到記憶體中時,我們將Block拷貝到分好段的區間內,並標記為已使用。當這個Block不被需要時,我們會標記該區間為可用,可以重新存放新的Block,這就是BucketCache。關於BucketCache中的記憶體空間分配與回收(這一塊的設計與研發在多年前已完成),詳細可以參考 : http://zjushch.iteye.com/blog/1751387
BucketCache
很多基於堆外記憶體的RPC框架,也會自己管理堆外記憶體的分配和回收,一般通過顯式釋放的方式進行記憶體回收。但是對HBase來說,卻有一些困難。我們將Block物件視為需要自管理的記憶體片段。Block可能被多個任務引用,要解決Block的回收問題,最簡單的方式是將Block對每個任務copy到棧上(copy的block一般不會晉升到old區),轉交給JVM管理就可以。
實際上,我們之前一直使用的是這種方法,實現簡單,JVM背書,安全可靠。但這是有損耗的記憶體管理方式,為了解決GC問題,引入了每次請求的拷貝代價。由於拷貝到棧上需要支付額外的cpu拷貝成本和young區記憶體分配成本,在cpu和匯流排越來越珍貴的今天,這個代價顯得高昂。
於是我們轉而考慮使用引用計數的方式管理記憶體,HBase上遇到的主要難點是:
-
HBase內部會有多個任務引用同一個Block
-
同一個任務內可能有多個變數引用同一個Block。引用者可能是棧上臨時變數,也可能是堆上物件域。
-
Block上的處理邏輯相對複雜,Block會在多個函式和物件之間以引數、返回值、域賦值的方式傳遞。
-
Block可能是受我們管理的,也可能是不受我們管理的(某些Block需要手動釋放,某些不需要)。
-
Block可能被轉換為Block的子型別。
這幾點綜合起來,對如何寫出正確的程式碼是一個挑戰。但在C++ 上,使用智慧指標來管理物件生命週期是很自然的事情,為什麼到了Java裡會有困難呢?
Java中變數的賦值,在使用者程式碼的層面上,只會產生引用賦值的行為,而C++ 中的變數賦值可以利用物件的構造器和析構器來幹很多事情,智慧指標即基於此實現(當然C++的構造器和析構器使用不當也會引發很多問題,各有優劣,這裡不討論)
於是我們參考了C++的智慧指標,設計了一個Block引用管理和回收的框架ShrableHolder來抹平coding中各種if else的困難。它有以下的正規化:
-
ShrableHolder可以管理有引用計數的物件,也可以管理非引用計數的物件
-
ShrableHolder在被重新賦值時,釋放之前的物件。如果是受管理的物件,引用計數減1,如果不是,則無變化。
-
ShrableHolder在任務結束或者程式碼段結束時,必須被呼叫reset
-
ShrableHolder不可直接賦值。必須呼叫ShrableHolder提供的方法進行內容的傳遞
-
因為ShrableHolder不可直接賦值,需要傳遞包含生命週期語義的Block到函式中時,ShrableHolder不能作為函式的引數。
根據這個正規化寫出來的程式碼,原來的程式碼邏輯改動很少,不會引入if else。雖然看上去仍然有一些複雜度,所幸的是,受此影響的區間還是侷限於非常區域性的下層,對HBase而言還是可以接受的。為了保險起見,避免記憶體洩漏,我們在這套框架里加入了探測機制,探測長時間不活動的引用,發現之後會強制標記為刪除。
將BucketCache應用之後,減少了BlockCache的晉升開銷,減少了young GC時間:
(CCSMap+BucketCache優化後的效果)
追求極致:ZenGC
經過以上兩個大的優化之後,螞蟻風控生產環境的young GC時間已經縮減到15ms。由於ParNew+CMS演算法在這個尺度上再做優化已經很困難了,我們轉而投向ZenGC的懷抱。ZenGC在G1演算法的基礎上做了深度改進,記憶體自管理的大堆HBase和ZenGC產生了很好的化學反應。
ZenGC是阿里巴巴JVM團隊基於G1演算法, 面向大堆 (LargeHeap) 應用場景,優化的GC演算法的統稱。這裡主要介紹下多租戶GC。
多租戶GC包含的三層核心邏輯:1) 在JavaHeap上,物件的分配按照租戶隔離,不同的租戶使用不同的Heap區域;2)允許GC以更小的代價發生在租戶粒度,而不僅僅是應用的全域性;3)允許上層應用根據業務需求對租戶靈活對映。
ZenGC將記憶體Region劃分為了多個租戶,每個租戶內獨立觸發GC。在個基礎上,我們將記憶體分為普通租戶和中等生命週期租戶。中等生命週期物件指的是,既不稍縱即逝,也不永久存在的物件。由於經過以上兩個大幅優化,現在堆中等生命週期物件數量和記憶體佔用已經很少了。但是中等生命週期物件在生成時會被old區物件引用,每次young GC都需要掃描RSet,現在仍然是young GC的耗時大頭。
藉助於AJDK團隊的ObjectTrace功能,我們找出中等生命週期物件中最"大頭"的部分,將這些物件在生成時直接分配到中等生命週期租戶的old區,避免RSet標記。而普通租戶則以正常的方式進行記憶體分配。
普通租戶GC頻率很高,但是由於晉升的物件少,跨代引用少,Young區的GC時間得到了很好的控制。在實驗室場景模擬環境中,我們將young GC優化到了5ms。
(ZenGC優化後的效果,單位問題,此處為us)
雲端使用
阿里HBase目前已經在阿里雲提供商業化服務,任何有需求的使用者都可以在阿里雲端使用深入改進的、一站式的HBase服務。雲HBase版本與自建HBase相比在運維、可靠性、效能、穩定性、安全、成本等方面均有很多的改進,更多內容歡迎大家關注 https://www.aliyun.com/product/hbase
【本文轉載自雲棲社群,作者: 中介軟體那珂 ,原文連結:https://yq.aliyun.com/articles/618575?spm=a2c4e.11157919.spm-cont-list.133.146c27aeXblzoL,轉載授權請聯絡原作者】
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31077337/viewspace-2158713/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Java——GC(垃圾回收)JavaGC
- .NET垃圾回收(GC)機制效能優化方案GC優化
- 給祖傳系統做了點 GC調優,暫停時間降低了 90%GC
- Java gc(垃圾回收機制)小結,以及Android優化建議JavaGCAndroid優化
- Java垃圾回收調優實戰Java
- Unity GC垃圾回收UnityGC
- GC垃圾回收器GC
- 深入理解Java的垃圾回收機制(GC)實現原理JavaGC
- .NET垃圾回收(GC)原理GC
- 從C#垃圾回收(GC)機制中挖掘效能優化方案C#GC優化
- 降低Java垃圾回收開銷的5條建議Java
- .Net平臺的GC垃圾回收GC
- 【Android面試-Java-V05】Java GC 垃圾回收Android面試JavaGC
- 為任務關鍵型Java應用優化垃圾回收(上)Java優化
- 為任務關鍵型Java應用優化垃圾回收(下)Java優化
- Apache HBase MTTR 優化實踐Apache優化
- java學習筆記-4 JVM垃圾回收(GC)Java筆記JVMGC
- 十種GC垃圾回收器GC
- Java GC 專家系列3:GC調優實踐JavaGC
- 聊聊JVM的垃圾回收機制GCJVMGC
- 為什麼GC(垃圾回收)必須stop-the-world?GC
- Java以後將可以不用GC實現記憶體回收JavaGC記憶體
- Java垃圾回收是如何工作的?Java
- 託管堆和垃圾回收(GC)GC
- 視覺化Java垃圾回收的原理和實現視覺化Java
- 高吞吐低延遲Java應用的垃圾回收優化Java優化
- HBase最佳實踐-讀效能優化策略優化
- Java介面全鏈路優化:如何降低介面RT時長Java優化
- Java虛擬機器5:Java垃圾回收(GC)機制詳解Java虛擬機GC
- GC 分代回收 - 垃圾收集器GC
- 秋招乾貨 - JVM 垃圾回收(GC)JVMGC
- HBase 讀流程解析與優化的最佳實踐優化
- Java垃圾回收Java
- 面試官,不要再問我“Java GC垃圾回收機制”了面試JavaGC
- Java 22中三種垃圾回收GC效能獲得了大提升JavaGC
- HBase最佳實踐-列族設計優化優化
- jvm系列(十):如何優化Java GC「譯」JVM優化JavaGC
- Java中的垃圾回收Java