從HBase offheap到Netty的內存管理

HBase的offheap現狀

HBase做爲一款流行的分佈式NoSQL數據庫,被各個公司大量應用,其中有不少業務場景,例如信息流和廣告業務,對訪問的吞吐和延遲要求都很是高。HBase2.0爲了盡最大可能避免Java GC對其形成的性能影響,已經對讀寫兩條核心路徑作了offheap化,也就是對象的申請都直接向JVM offheap申請,而offheap分出來的內存都是不會被JVM GC的,須要用戶本身顯式地釋放。在寫路徑上,客戶端發過來的請求包都會被分配到offheap的內存區域,直到數據成功寫入WAL日誌和Memstore,其中維護Memstore的ConcurrentSkipListSet其實也不是直接存Cell數據,而是存Cell的引用,真實的內存數據被編碼在MSLAB的多個Chunk內,這樣比較便於管理offheap內存。相似地,在讀路徑上,先嚐試去讀BucketCache,Cache未命中時則去HFile中讀對應的Block,這其中佔用內存最多的BucketCache就放在offheap上,拿到Block後編碼成Cell發送給用戶,整個過程基本上都不涉及heap內對象申請。



可是在小米內部最近的性能測試結果中發現,100% Get的場景受Young GC的影響仍然比較嚴重,在HBASE-21879貼的兩幅圖中,能夠很是明顯的觀察到Get操做的p999延遲跟G1 Young GC的耗時基本相同,都在100ms左右。按理說,在HBASE-11425以後,應該是全部的內存分配都是在offheap的,heap內應該幾乎沒有內存申請。可是,在仔細梳理代碼後,發現從HFile中讀Block的過程仍然是先拷貝到堆內去的,一直到BucketCache的WriterThread異步地把Block刷新到Offheap,堆內的DataBlock才釋放。而磁盤型壓測試驗中,因爲數據量大,Cache命中率並不高(~70%),因此會有大量的Block讀取走磁盤IO,因而Heap內產生大量的年輕代對象,最終致使Young區GC壓力上升。html

消除Young GC直接的思路就是從HFile讀DataBlock的時候,直接往Offheap上讀。以前留下這個坑,主要是HDFS不支持ByteBuffer的Pread接口,固然後面開了HDFS-3246在跟進這個事情。但後面發現的一個問題就是:Rpc路徑上讀出來的DataBlock,進了BucketCache以後實際上是先放到一個叫作RamCache的臨時Map中,並且Block一旦進了這個Map就能夠被其餘的RPC給命中,因此當前RPC退出後並不能直接就把以前讀出來的DataBlock給釋放了,必須考慮RamCache是否也釋放了。因而,就須要一種機制來跟蹤一塊內存是否同時再也不被全部RPC路徑和RamCache引用,只有在都不引用的狀況下,才能釋放內存。天然而言的想到用reference Count機制來跟蹤ByteBuffer,後面發現其實Netty已經較完整地實現了這個東西,因而看了一下Netty的內存管理機制。程序員


Netty內存管理概述

Netty做爲一個高性能的基礎框架,爲了保證GC對性能的影響降到最低,作了大量的offheap化。而offheap的內存是程序員本身申請和釋放,忘記釋放或者提早釋放都會形成內存泄露問題,因此一個好的內存管理器很重要。首先,什麼樣的內存分配器,纔算一個是一個「好」的內存分配器:算法

  1. 高併發且線程安全。通常一個進程共享一個全局的內存分配器,得保證多線程併發申請釋放既高效又不出問題。數據庫

  2. 高效的申請和釋放內存,這個不用多說。緩存

  3. 方便跟蹤分配出去內存的生命週期和定位內存泄露問題。安全

  4. 高效的內存利用率。有些內存分配器分配到必定程度,雖然還空閒大量內存碎片,但卻再也無法分出一個稍微大一點的內存來。因此須要經過更精細化的管理,實現更高的內存利用率。數據結構

  5. 儘可能保證同一個對象在物理內存上存儲的連續性。例如分配器當前已經沒法分配出一塊完整連續的70MB內存來,有些分配器可能會經過多個內存碎片拼接出一塊70MB的內存,但其實合適的算法設計,能夠保證更高的連續性,從而實現更高的內存訪問效率。多線程

爲了優化多線程競爭申請內存帶來額外開銷,Netty的PooledByteBufAllocator默認爲每一個處理器初始化了一個內存池,多個線程經過Hash選擇某個特定的內存池。這樣即便是多處理器併發處理的狀況下,每一個處理器基本上能使用各自獨立的內存池,從而緩解競爭致使的同步等待開銷。併發

Netty的內存管理設計的比較精細。首先,將內存劃分紅一個個16MB的Chunk,每一個Chunk又由2048個8KB的Page組成。這裏須要提一下,對每一次內存申請,都將二進制對齊,例如須要申請150B的內存,則實際待申請的內存實際上是256B,並且一個Page在未進Cache前(後續會講到Cache)都只能被一次申請佔用,也就是說一個Page內申請了256B的內存後,後面的請求也將不會在這個Page中申請,而是去找其餘徹底空閒的Page。有人可能會疑問,那這樣豈不是內存利用率超低?由於一個8KB的Page被分配了256B以後,就再也分配了。其實不是,由於後面進了Cache後,仍是能夠分配出31個256B的ByteBuffer的。框架

多個Chunk又能夠組成一個ChunkList,再根據Chunk內存佔用比例(Chunk使用內存/16MB * 100%)劃分紅不一樣等級的ChunkList。例如,下圖中根據內存使用比例不一樣,分紅了6個不一樣等級的ChunkList,其中q050內的Chunk都是佔用比例在[50,100)這個區間內。隨着內存的不斷分配,q050內的某個Chunk佔用比例可能等於100,則該Chunk被挪到q075這個ChunkList中。由於內存一直在申請和釋放,上面那個Chunk可能因某些對象釋放後,致使內存佔用比小於75,則又會被放回到q050這個ChunkList中;固然也有可能某次分配後,內存佔用比例再次到達100,則會被挪到q100內。這樣設計的一個好處在於,能夠儘可能讓申請請求落在比較空閒的Chunk上,從而提升了內存分配的效率。



仍以上述爲例,某對象A申請了150B內存,二進制對齊後實際申請了256B的內存。對象A釋放後,對應申請的Page也就釋放,Netty爲了提升內存的使用效率,會把這些Page放到對應的Cache中,對象A申請的Page是按照256B來劃分的,因此直接按上圖所示,進入了一個叫作TinySubPagesCaches的緩衝池。這個緩衝池其實是由多個隊列組成,每一個隊列內表明Page劃分的不一樣尺寸,例如queue->32B,表示這個隊列中,緩存的都是按照32B來劃分的Page,一旦有32B的申請請求,就直接去這個隊列找未佔滿的Page。這裏,能夠發現,隊列中的同一個Page能夠被屢次申請,只是他們申請的內存大小都同樣,這也就不存在以前說的內存佔用率低的問題,反而佔用率會比較高。

固然,Cache又按照Page內部劃份量(稱之爲elemSizeOfPage,也就是一個Page內會劃分紅8KB/elemSizeOfPage個相等大小的小塊)分紅3個不一樣類型的Cache。對那些小於512B的申請請求,將嘗試去TinySubPagesCaches中申請;對那些小於8KB的申請請求,將嘗試去SmallSubPagesDirectCaches中申請;對那些小於16MB的申請請求,將嘗試去NormalDirectCaches中申請。若對應的Cache中,不存在能用的內存,則直接去下面的6個ChunkList中找Chunk申請,固然這些Chunk有可能都被申請滿了,那麼只能向Offheap直接申請一個Chunk來知足需求了。


Chunk內部分配的連續性(cache coherence)

上文基本理清了Chunk之上內存申請的原理,整體來看,Netty的內存分配仍是作的很是精細的,從算法上看,不管是申請/釋放效率仍是內存利用率都比較有保障。這裏簡單闡述一下Chunk內部如何分配內存。

一個問題就是:若是要在一個Chunk內申請32KB的內存,那麼Chunk應該怎麼分配Page才比較高效,同時用戶的內存訪問效率比較高?

一個簡單的思路就是,把16MB的Chunk劃分紅2048個8KB的Page,而後用一個隊列來維護這些Page。若是一個Page被用戶申請,則從隊列中出隊;Page被用戶釋放,則從新入隊。這樣內存的分配和釋放效率都很是高,都是O(1)的複雜度。但問題是,一個32KB對象會被分散在4個不連續的Page,用戶的內存訪問效率會受到影響。

Netty的Chunk內分配算法,則兼顧了申請/釋放效率用戶內存訪問效率。提升用戶內存訪問效率的一種方式就是,不管用戶申請多大的內存量,都讓它落在一塊連續的物理內存上,這種特性咱們稱之爲Cache coherence

來看一下Netty的算法設計:



首先,16MB的Chunk分紅2048個8KB的Page,這2048個Page正好能夠組成一顆徹底二叉樹(相似堆數據結構),這顆徹底二叉樹能夠用一個int[] map來維護。例如,map[1]就表示root,map[2]就表示root的左兒子,map[3]就表示root的右兒子,依次類推,map[2048]是第一個葉子節點,map[2049]是第二個葉子節點…,map[4095]是最後一個葉子節點。這2048個葉子節點,正好依次對應2048個Page。

這棵樹的特色就是,任何一顆子樹的全部Page都是在物理內存上連續的。因此,申請32KB的物理內存連續的操做,能夠轉變成找一顆正好有4個Page空閒的子樹,這樣就解決了用戶內存訪問效率的問題,保證了Cache Coherence特性。

但如何解決分配和釋放的效率的問題呢?

思路其實不是特別難,可是Netty中用各類二進制優化以後,顯的不那麼容易理解。因此,我畫了一副圖。其本質就是,徹底二叉樹的每一個節點id都維護一個map[id]值,這個值表示以id爲根的子樹上,按照層次遍歷,第一個徹底空閒子樹對應根節點的深度。例如在step.3圖中,id=2,層次遍歷碰到的第一顆徹底空閒子樹是id=5爲根的子樹,它的深度爲2,因此map[2]=2。

理解了map[id]這個概念以後,再看圖其實就沒有那麼難理解了。圖中畫的是在一個64KB的chunk(由8個page組成,對應樹最底層的8個葉子節點)上,依次分配8KB、32KB、16KB的維護流程。能夠發現,不管是申請內存,仍是釋放內存,操做的複雜度都是log(N),N表明節點的個數。而在Netty中,N=2048,因此申請、釋放內存的複雜度均可以認爲是常數級別的。

經過上述算法,Netty同時保證了Chunk內部分配/申請多個Pages的高效和用戶內存訪問的高效。


引用計數和內存泄漏檢查

上文提到,HBase的ByteBuf也嘗試採用引用計數來跟蹤一塊內存的生命週期,被引用一次則其refCount++,取消引用則refCount--,一旦refCount=0則認爲內存能夠回收到內存池。思路很簡單,只是須要考慮下線程安全的問題。

但事實上,即便有了引用計數,可能仍是容易碰到忘記顯式refCount--的操做,Netty提供了一個叫作ResourceLeakDetector的跟蹤器。在Enable狀態下,任何分出去的ByteBuf都會進入這個跟蹤器中,回收ByteBuf時則從跟蹤器中刪除。一旦發現某個時間點跟蹤器內的ByteBuff總數太大,則認爲存在內存泄露。開啓這個功能必然會對性能有所影響,因此生產環境下都不開這個功能,只有在懷疑有內存泄露問題時開啓用來定位問題用。


總結

Netty的內存管理其實作的很精細,對HBase的Offheap化設計有很多啓發。目前HBase的內存分配器至少有3種:

  1. Rpc路徑上offheap內存分配器。實現較爲簡單,以定長64KB爲單位分配Page給對象,發現Offheap池沒法分出來,則直接去Heap申請。

  2. Memstore的MSLAB內存分配器,核心思路跟RPC內存分配器相差不大。應該能夠合二爲一。

  3. BucketCache上的BucketAllocator。

就第1點和第2點而言,我以爲從此嘗試改爲用Netty的PooledByteBufAllocator應該問題不大,畢竟Netty在多核併發/內存利用率以及CacheCoherence上都作了很多優化。因爲BucketCache既能夠存內存,又能夠存SSD磁盤,甚至HDD磁盤。因此BucketAllocator作了更高程度的抽象,維護的都是一個(offset,len)這樣的二元組,Netty現有的接口並不能知足需求,因此估計暫時只能維持現狀。

能夠預期的是,HBase2.0性能一定是朝更好方向發展的,尤爲是GC對P999的影響會愈來愈小。

- end -

參考資料:

  1. https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf

  2. https://www.facebook.com/notes/facebook-engineering/scalable-memory-allocation-using-jemalloc/480222803919/

  3. https://netty.io/wiki/reference-counted-objects.html

相關文章
相關標籤/搜索