如何下降90%Java垃圾回收時間?以阿里HBase的GC優化實踐爲例

GC一直是Java應用中討論的一個熱門話題,尤爲在像HBase這樣的大型在線存儲系統中,大堆下(百GB)的GC停頓延遲產生的在線實時影響,成爲內核和應用開發者的一大痛點。node

過去的一年裏,咱們準備在Ali-HBase上突破這個被廣泛認知的痛點,爲此進行了深度分析及全面創新的工做,得到了一些比較好的效果。以螞蟻風控場景爲例,HBase的線上young GC時間從120ms減小到15ms,結合阿里巴巴JDK團隊提供的利器——ZenGC,進一步在實驗室壓測環境作到了5ms。本文主要介紹咱們過去在這方面的一些工做和技術思想。算法

背景

JVM的GC機制對開發者屏蔽了內存管理的細節,提升了開發效率。提及GC,不少人的第一反應多是JVM長時間停頓或者FGC致使進程卡死不可服務的狀況。但就HBase這樣的大數據存儲服務而言,JVM帶來的GC挑戰至關複雜和艱難。緣由有三:緩存

一、內存規模巨大。線上HBase進程多數爲96G大堆,今年新機型已經上線部分160G以上的堆配置安全

二、對象狀態複雜。HBase服務器內部會維護大量的讀寫cache,達到數十GB的規模。HBase以表格的形式提供有序的服務數據,數據以必定的結構組織起來,這些數據結構產生了過億級別的對象和引用服務器

三、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是使用得比較普遍的數據結構。運維

clipboard.png

咱們以JDK自帶的ConcurrentSkipListMap爲例子進行分析,它有下面三個問題:函數

  1. 內部對象繁多。每存儲一個元素,平均須要4個對象(index+node+key+value,平均層高爲1)
  2. 新插入的對象在young區,老對象在old區。當不斷插入元素時,內部的引用關係會頻繁發生變化,不管是ParNew算法的CardTable標記,仍是G1算法的RSet標記,都有可能觸發old區掃描。
  3. 業務寫入的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,支持無鎖的併發寫入和查詢
  • 控制指針和數據都存放在連續內存中

clipboard.png

上圖所展現的便是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

clipboard.png
優化前

clipboard.png
優化後

使用了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...

clipboard.png
BucketCache

不少基於堆外內存的RPC框架,也會本身管理堆外內存的分配和回收,通常經過顯式釋放的方式進行內存回收。可是對HBase來講,卻有一些困難。咱們將Block對象視爲須要自管理的內存片斷。Block可能被多個任務引用,要解決Block的回收問題,最簡單的方式是將Block對每一個任務copy到棧上(copy的block通常不會晉升到old區),轉交給JVM管理就能夠。

實際上,咱們以前一直使用的是這種方法,實現簡單,JVM背書,安全可靠。但這是有損耗的內存管理方式,爲了解決GC問題,引入了每次請求的拷貝代價。因爲拷貝到棧上須要支付額外的cpu拷貝成本和young區內存分配成本,在cpu和總線愈來愈珍貴的今天,這個代價顯得高昂。

因而咱們轉而考慮使用引用計數的方式管理內存,HBase上遇到的主要難點是:

  1. HBase內部會有多個任務引用同一個Block
  2. 同一個任務內可能有多個變量引用同一個Block。引用者多是棧上臨時變量,也多是堆上對象域。
  3. Block上的處理邏輯相對複雜,Block會在多個函數和對象之間以參數、返回值、域賦值的方式傳遞。
  4. Block多是受咱們管理的,也多是不受咱們管理的(某些Block須要手動釋放,某些不須要)。
  5. Block可能被轉換爲Block的子類型。

這幾點綜合起來,對如何寫出正確的代碼是一個挑戰。但在C++ 上,使用智能指針來管理對象生命週期是很天然的事情,爲何到了Java裏會有困難呢?

Java中變量的賦值,在用戶代碼的層面上,只會產生引用賦值的行爲,而C++ 中的變量賦值能夠利用對象的構造器和析構器來幹不少事情,智能指針即基於此實現(固然C++的構造器和析構器使用不當也會引起不少問題,各有優劣,這裏不討論)

因而咱們參考了C++的智能指針,設計了一個Block引用管理和回收的框架ShrableHolder來抹平coding中各類if else的困難。它有如下的範式:

  1. ShrableHolder能夠管理有引用計數的對象,也能夠管理非引用計數的對象
  2. ShrableHolder在被從新賦值時,釋放以前的對象。若是是受管理的對象,引用計數減1,若是不是,則無變化。
  3. ShrableHolder在任務結束或者代碼段結束時,必須被調用reset
  4. ShrableHolder不可直接賦值。必須調用ShrableHolder提供的方法進行內容的傳遞
  5. 由於ShrableHolder不可直接賦值,須要傳遞包含生命週期語義的Block到函數中時,ShrableHolder不能做爲函數的參數。

根據這個範式寫出來的代碼,原來的代碼邏輯改動不多,不會引入if else。雖然看上去仍然有一些複雜度,所幸的是,受此影響的區間仍是侷限於很是局部的下層,對HBase而言仍是能夠接受的。爲了保險起見,避免內存泄漏,咱們在這套框架里加入了探測機制,探測長時間不活動的引用,發現以後會強制標記爲刪除。

將BucketCache應用以後,減小了BlockCache的晉升開銷,減小了young GC時間:

clipboard.png

clipboard.png
(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。

clipboard.png
(ZenGC優化後的效果,單位問題,此處爲us)

clipboard.png

clipboard.png

雲端使用

阿里HBase目前已經在阿里雲提供商業化服務,任何有需求的用戶均可以在阿里雲端使用深刻改進的、一站式的HBase服務。雲HBase版本與自建HBase相比在運維、可靠性、性能、穩定性、安全、成本等方面均有不少的改進,更多內容歡迎你們關注 https://www.aliyun.com/produc...

本文做者:中間件那珂

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索