您好,登錄后才能下訂單哦!
本文主要介紹了Netty的內存管理和性能。
HBase作為一款流行的分布式NoSQL數據庫,被各個公司大量應用,其中有很多業(yè)務場景,例如信息流和廣告業(yè)務,對訪問的吞吐和延遲要求都非常高。HBase2.0為了盡最大可能避免Java GC對其造成的性能影響,已經對讀寫兩條核心路徑做了offheap化,也就是對象的申請都直接向JVM offheap申請,而offheap分出來的內存都是不會被JVM GC的,需要用戶自己顯式地釋放。在寫路徑上,客戶端發(fā)過來的請求包都會被分配到offheap的內存區(qū)域,直到數據成功寫入WAL日志和Memstore,其中維護Memstore的ConcurrentSkipListSet其實也不是直接存Cell數據,而是存Cell的引用,真實的內存數據被編碼在MSLAB的多個Chunk內,這樣比較便于管理offheap內存。類似地,在讀路徑上,先嘗試去讀BucketCache,Cache未命中時則去HFile中讀對應的Block,這其中占用內存最多的BucketCache就放在offheap上,拿到Block后編碼成Cell發(fā)送給用戶,整個過程基本上都不涉及heap內對象申請。
但是在小米內部最近的性能測試結果中發(fā)現(xiàn),100% Get的場景受Young GC的影響仍然比較嚴重,在HBASE-21879貼的兩幅圖中,可以非常明顯的觀察到Get操作的p999延遲跟G1 Young GC的耗時基本相同,都在100ms左右。按理說,在HBASE-11425之后,應該是所有的內存分配都是在offheap的,heap內應該幾乎沒有內存申請。但是,在仔細梳理代碼后,發(fā)現(xiàn)從HFile中讀Block的過程仍然是先拷貝到堆內去的,一直到BucketCache的WriterThread異步地把Block刷新到Offheap,堆內的DataBlock才釋放。而磁盤型壓測試驗中,由于數據量大,Cache命中率并不高(~70%),所以會有大量的Block讀取走磁盤IO,于是Heap內產生大量的年輕代對象,最終導致Young區(qū)GC壓力上升。
消除Young GC直接的思路就是從HFile讀DataBlock的時候,直接往Offheap上讀。之前留下這個坑,主要是HDFS不支持ByteBuffer的Pread接口,當然后面開了HDFS-3246在跟進這個事情。但后面發(fā)現(xiàn)的一個問題就是:Rpc路徑上讀出來的DataBlock,進了BucketCache之后其實是先放到一個叫做RamCache的臨時Map中,而且Block一旦進了這個Map就可以被其他的RPC給命中,所以當前RPC退出后并不能直接就把之前讀出來的DataBlock給釋放了,必須考慮RamCache是否也釋放了。于是,就需要一種機制來跟蹤一塊內存是否同時不再被所有RPC路徑和RamCache引用,只有在都不引用的情況下,才能釋放內存。自然而言的想到用reference Count機制來跟蹤ByteBuffer,后面發(fā)現(xiàn)其實Netty已經較完整地實現(xiàn)了這個東西,于是看了一下Netty的內存管理機制。
Netty作為一個高性能的基礎框架,為了保證GC對性能的影響降到最低,做了大量的offheap化。而offheap的內存是程序員自己申請和釋放,忘記釋放或者提前釋放都會造成內存泄露問題,所以一個好的內存管理器很重要。首先,什么樣的內存分配器,才算一個是一個“好”的內存分配器:
高并發(fā)且線程安全。一般一個進程共享一個全局的內存分配器,得保證多線程并發(fā)申請釋放既高效又不出問題。
高效的申請和釋放內存,這個不用多說。
方便跟蹤分配出去內存的生命周期和定位內存泄露問題。
高效的內存利用率。有些內存分配器分配到一定程度,雖然還空閑大量內存碎片,但卻再也沒法分出一個稍微大一點的內存來。所以需要通過更精細化的管理,實現(xiàn)更高的內存利用率。
盡量保證同一個對象在物理內存上存儲的連續(xù)性。例如分配器當前已經無法分配出一塊完整連續(xù)的70MB內存來,有些分配器可能會通過多個內存碎片拼接出一塊70MB的內存,但其實合適的算法設計,可以保證更高的連續(xù)性,從而實現(xiàn)更高的內存訪問效率。
為了優(yōu)化多線程競爭申請內存帶來額外開銷,Netty的PooledByteBufAllocator默認為每個處理器初始化了一個內存池,多個線程通過Hash選擇某個特定的內存池。這樣即使是多處理器并發(fā)處理的情況下,每個處理器基本上能使用各自獨立的內存池,從而緩解競爭導致的同步等待開銷。
Netty的內存管理設計的比較精細。首先,將內存劃分成一個個16MB的Chunk,每個Chunk又由2048個8KB的Page組成。這里需要提一下,對每一次內存申請,都將二進制對齊,例如需要申請150B的內存,則實際待申請的內存其實是256B,而且一個Page在未進Cache前(后續(xù)會講到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)這個區(qū)間內。隨著內存的不斷分配,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。這里,可以發(fā)現(xiàn),隊列中的同一個Page可以被多次申請,只是他們申請的內存大小都一樣,這也就不存在之前說的內存占用率低的問題,反而占用率會比較高。
當然,Cache又按照Page內部劃分量(稱之為elemSizeOfPage,也就是一個Page內會劃分成8KB/elemSizeOfPage個相等大小的小塊)分成3個不同類型的Cache。對那些小于512B的申請請求,將嘗試去TinySubPagesCaches中申請;對那些小于8KB的申請請求,將嘗試去SmallSubPagesDirectCaches中申請;對那些小于16MB的申請請求,將嘗試去NormalDirectCaches中申請。若對應的Cache中,不存在能用的內存,則直接去下面的6個ChunkList中找Chunk申請,當然這些Chunk有可能都被申請滿了,那么只能向Offheap直接申請一個Chunk來滿足需求了。
Chunk內部分配的連續(xù)性(cache coherence)
上文基本理清了Chunk之上內存申請的原理,總體來看,Netty的內存分配還是做的非常精細的,從算法上看,無論是申請/釋放效率還是內存利用率都比較有保障。這里簡單闡述一下Chunk內部如何分配內存。
一個問題就是:如果要在一個Chunk內申請32KB的內存,那么Chunk應該怎么分配Page才比較高效,同時用戶的內存訪問效率比較高?
一個簡單的思路就是,把16MB的Chunk劃分成2048個8KB的Page,然后用一個隊列來維護這些Page。如果一個Page被用戶申請,則從隊列中出隊;Page被用戶釋放,則重新入隊。這樣內存的分配和釋放效率都非常高,都是O(1)的復雜度。但問題是,一個32KB對象會被分散在4個不連續(xù)的Page,用戶的內存訪問效率會受到影響。
Netty的Chunk內分配算法,則兼顧了申請/釋放效率和用戶內存訪問效率。提高用戶內存訪問效率的一種方式就是,無論用戶申請多大的內存量,都讓它落在一塊連續(xù)的物理內存上,這種特性我們稱之為Cache coherence。
來看一下Netty的算法設計:
首先,16MB的Chunk分成2048個8KB的Page,這2048個Page正好可以組成一顆完全二叉樹(類似堆數據結構),這顆完全二叉樹可以用一個int[] map來維護。例如,map[1]就表示root,map[2]就表示root的左兒子,map[3]就表示root的右兒子,依次類推,map[2048]是第一個葉子節(jié)點,map[2049]是第二個葉子節(jié)點…,map[4095]是最后一個葉子節(jié)點。這2048個葉子節(jié)點,正好依次對應2048個Page。
這棵樹的特點就是,任何一顆子樹的所有Page都是在物理內存上連續(xù)的。所以,申請32KB的物理內存連續(xù)的操作,可以轉變成找一顆正好有4個Page空閑的子樹,這樣就解決了用戶內存訪問效率的問題,保證了Cache Coherence特性。
但如何解決分配和釋放的效率的問題呢?
思路其實不是特別難,但是Netty中用各種二進制優(yōu)化之后,顯的不那么容易理解。所以,我畫了一副圖。其本質就是,完全二叉樹的每個節(jié)點id都維護一個map[id]值,這個值表示以id為根的子樹上,按照層次遍歷,第一個完全空閑子樹對應根節(jié)點的深度。例如在step.3圖中,id=2,層次遍歷碰到的第一顆完全空閑子樹是id=5為根的子樹,它的深度為2,所以map[2]=2。
理解了map[id]這個概念之后,再看圖其實就沒有那么難理解了。圖中畫的是在一個64KB的chunk(由8個page組成,對應樹最底層的8個葉子節(jié)點)上,依次分配8KB、32KB、16KB的維護流程??梢园l(fā)現(xiàn),無論是申請內存,還是釋放內存,操作的復雜度都是log(N),N代表節(jié)點的個數。而在Netty中,N=2048,所以申請、釋放內存的復雜度都可以認為是常數級別的。
通過上述算法,Netty同時保證了Chunk內部分配/申請多個Pages的高效和用戶內存訪問的高效。
上文提到,HBase的ByteBuf也嘗試采用引用計數來跟蹤一塊內存的生命周期,被引用一次則其refCount++,取消引用則refCount--,一旦refCount=0則認為內存可以回收到內存池。思路很簡單,只是需要考慮下線程安全的問題。
但事實上,即使有了引用計數,可能還是容易碰到忘記顯式refCount--的操作,Netty提供了一個叫做ResourceLeakDetector的跟蹤器。在Enable狀態(tài)下,任何分出去的ByteBuf都會進入這個跟蹤器中,回收ByteBuf時則從跟蹤器中刪除。一旦發(fā)現(xiàn)某個時間點跟蹤器內的ByteBuff總數太大,則認為存在內存泄露。開啟這個功能必然會對性能有所影響,所以生產環(huán)境下都不開這個功能,只有在懷疑有內存泄露問題時開啟用來定位問題用。
Netty的內存管理其實做的很精細,對HBase的Offheap化設計有不少啟發(fā)。目前HBase的內存分配器至少有3種:
Rpc路徑上offheap內存分配器。實現(xiàn)較為簡單,以定長64KB為單位分配Page給對象,發(fā)現(xiàn)Offheap池無法分出來,則直接去Heap申請。
Memstore的MSLAB內存分配器,核心思路跟RPC內存分配器相差不大。應該可以合二為一。
BucketCache上的BucketAllocator。
就第1點和第2點而言,我覺得今后嘗試改成用Netty的PooledByteBufAllocator應該問題不大,畢竟Netty在多核并發(fā)/內存利用率以及CacheCoherence上都做了不少優(yōu)化。由于BucketCache既可以存內存,又可以存SSD磁盤,甚至HDD磁盤。所以BucketAllocator做了更高程度的抽象,維護的都是一個(offset,len)這樣的二元組,Netty現(xiàn)有的接口并不能滿足需求,所以估計暫時只能維持現(xiàn)狀。
可以預期的是,HBase2.0性能必定是朝更好方向發(fā)展的,尤其是GC對P999的影響會越來越小。
- end -
參考資料:
https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf
https://www.facebook.com/notes/facebook-engineering/scalable-memory-allocation-using-jemalloc/480222803919/
https://netty.io/wiki/reference-counted-objects.html
免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。