HBase GC的前生今世 – 身世篇

在之前的HBase BlockCache系列文章中已經簡單提到:使用LRUBlockCache緩存機制會因爲CMS GC策略導致內存碎片過多,從而可能引發臭名昭著的Full GC,觸發可怕的’stop-the-world’暫停,嚴重影響上層業務;而Bucket Cache緩存機制因爲在初始化的時候就申請了一片固定大小的內存作爲緩存,緩存淘汰不再由 JVM管理,數據Block的緩存操作只是對這篇空間的訪問和覆蓋,因而大大減少了內存碎片的出現,降低了Full GC發生的頻率。那CMS GC策略如何導致內存碎片過多?內存碎片過多如何觸發Full GC?HBase在演進的道路上又如何不斷優化CMS GC?接下來這個系列《HBase GC的前生今生》將會爲你一一揭開謎底,這個系列一共兩篇文章,本篇文章-’身世篇’將會帶你全面瞭解HBase的GC機制,後面一篇-’演進篇’將會給你道出HBase在發展的道路上如何不斷對Full GC進行優化。

Java GC概述

整個HBase是構建在JVM虛擬機上的,因此瞭解HBase的內存管理機制以及不同緩存機制對GC的影響,就必須對Java GC有一個全面的瞭解。至於深入地理解Java GC 的工作原理,不在本文的討論範圍之內;當然,如果已經對Java GC比較熟悉,也可以跳過此節。

Java GC建立在這樣一個假設基礎上的:大多數內存對象要麼生存週期比較短,很快就會沒人引用,比如處理RPC請求的buffer可能只會生存幾微秒;要麼生存週期比較長,比如Block Cache中的熱點Block,可能就會生存幾分鐘,甚至更長時間。基於這樣的事實,JVM將整個堆內存分爲兩個部分:新生代(young generation)和老生代(tenured generation),除此之外,JVM還有一個非堆內存區-Perm區,主要存放class信息以及其他meta元信息,內存結構如下圖所示:

在這裏插入圖片描述其中Young區又分爲Eden區和兩個Survivor 區:S0和S1。一個內存對象在創建之後,首先會爲其在新生代申請一塊內存空間,如果這個對象在新生代存活了很長時間,會將其遷移到老生代。 在大多數對延遲敏感的業務場景下(比如HBase),建議使用如下JVM參數,-XX:+UseParNewGC和XX:+UseConcMarkSweepGC,其中前者表示對新生代執行並行的垃圾回收機制,而後者表示對老生代執行並行標記-清除垃圾回收機制。可見,JVM允許針對不同內存區執行不同的GC策略。

新生代GC策略 – Parallel New Collector

根據上文所述,對象初始化之後會被放入Young區,更具體的話應該是Eden區,當Eden區滿了之後,會進行一次GC。GC算法會檢查所有對象的引用情況,如果某個對象還有被引用,表示該對象存活。檢查完成之後,會將這些存活的對象移到S0區,並且回收整個Eden區空間,稱爲一次Minor GC;接着新對象進來,又會放入Eden區,滿了之後會檢查S0和Eden區存活的對象,將所有存活的對象移到S1區,再回收整個S0和Eden區空間;很容易理解,S0和S1兩個區總會有一個區是預留給下次存放存活對象用的。

整個過程可以使用如下圖示:
在這裏插入圖片描述這種算法稱爲複製算法,對於這種算法,有兩點需要關注:

  1. 算法會執行’stop-the-world’暫停,但時間非常短。因爲Young區通常會設置的比較小(一般不建議不超過512M),而且JVM會啓動大量線程併發執行,一次Minor GC一般都會在幾毫秒內完成
  2. 不會產生碎片,每次GC之後都會將存活的對象放入連續的空間(S0或S1)
    內存中所有對象都會維護一個計數器,每次Minor GC移動一個對象之後,都會爲這個對象的計數器加一。當計數器增加到一定閾值之後,算法就會認爲該對象生命週期很長,會將其移入老生代。該閾值可以通過JVM參數XX:MaxTenuringThreshold指定。

老生代GC策略 – Concurrent Mark-Sweep

每次執行Minor GC之後,都會有部分生命週期較長的對象被移入老生代,一段時間之後,老生代空間也會被佔滿。此時就需要針對老生代空間執行GC操作,此處我們介紹Concurrent Mark-Sweep(CMS)算法。CMS算法整個流程分爲6個階段,其中部分階段會執行 ‘stop-the-world’ 暫停,部分階段會和應用線程一起併發執行:

  1. initial-mark:這個階段虛擬機會暫停所有正在執行的任務。這一過程虛擬機會標記所有 ‘根對象’,所謂‘根對象’,一般是指一個運行線程直接引用到的對象。雖然會暫停整個JVM,但因爲’根對象’相對較少,這個過程通常很快。
  2. concurrent mark:垃圾回收器會從‘根節點’開始,將所有引用到的對象都打上標記。這個階段應用程序的線程和標記線程併發執行,因此用戶並不會感到停頓。
  3. concurrent precleaning:併發預清理階段仍然是併發的。在這個階段,虛擬機查找在執行mark階段新進入老年代的對象(可能會有一些對象從新生代晉升到老年代, 或者有一些對象被分配到老年代)。
  4. remark:在階段3的基礎上對查找到的對象進行重新標記,這一階段會暫停整個JVM,但是因爲階段3已經欲檢查出了所有新進入的對象,因此這個過程也會很快。
  5. concurrent sweep:上述3階段完成了引用對象的標記,此階段會將所有沒有標記的對象作爲垃圾回收掉。這個階段應用程序的線程和標記線程併發執行。
  6. concurrent reset:重置CMS收集器的數據結構,等待下一次垃圾回收。

相應的,對於CMS算法,也需要關注兩點:

  1. ‘stop-the-world’暫停時間也很短暫,耗時較長的標記和清理都是併發執行的。
  2. CMS算法在標記清理之後並沒有重新壓縮分配存活對象,因此整個老生代會產生很多的內存碎片。

CMS Failure Mode

上文提到在正常的情況下CMS整個流程的暫停時間都是很短的,一般也就在10ms~100ms左右。然而這與線上的情況並不相符,線上集羣在讀寫壓力很大的情況下,經常會出現長時間的卡頓,有些卡頓甚至長達幾分鐘,導致很嚴重的讀寫阻塞,甚至會造成Region Server和Zookeeper之間Session超時,使得Region Server異常離線。實際上,CMS並不是很完美,它會在兩種場景下產生嚴重的Full GC,接下來分別進行介紹。

Concurrent Failure

這種場景其實比較簡單,假如現在系統正在執行CMS回收老生代空間,在回收的過程中新生代來了一批對象進來,不巧的是,老生代已經沒有空間再容納這些對象了。這種場景下,CMS回收器會停止繼續工作,系統進入 ’stop-the-world’ 模式,並且回收算法會退化爲單線程複製算法,重新分配整個堆內存的存活對象到S0中,釋放所有其他空間。很顯然,整個過程會非常’漫長’。但是這種問題也很容易解決,只需要讓CMS回收器更早一點回收就可以避免。JVM提供了參數-XX:CMSInitiatingOccupancyFraction=N來設置CMS回收的時機,其中N表示當前老生代已使用內存佔新生代總內存的比例,該值默認爲68,可以將該值修改的更小使得回收更早進行。

Promotion Failure

假設此時設置XX:CMSInitiatingOccupancyFraction=60,但是在已使用內存還沒有達到總內存60%的時候,已經沒有空間容納從新生代遷移的對象了。oh,my god!怎麼會這樣?罪魁禍首就是內存碎片,上文中提到CMS算法會產生大量碎片,當碎片容量積累到一定大小之後就會造成上面的場景。這種場景下,CMS回收器一樣會停止工作,進入漫長的 ’stop-the-world’ 模式。JVM也提供了參數 -XX: UseCMSCompactAtFullCollection來減少碎片的產生,這個參數表示會在每次CMS回收垃圾之後執行一次碎片整理,很顯然,這個參數會對性能有比較大的影響,對HBase這種對延遲敏感的業務來說並不是一個完美解決方案。

HBase內存碎片統計實驗

在實際線上環境中,很少出現Concurrent Failure模式的Full GC,大多數Full GC場景都是Promotion Failure。我們線上集羣也會每隔半個月左右就會因爲Promotion Failure觸發一次Full GC。爲了更好地理解CMS策略下內存碎片是如何觸發Promotion Failure,接下來我們做一個簡單的實驗:JVM提供了參數 -XX:PrintFLSStatistics=1來打印每次GC前後內存碎片的統計信息,統計信息主要包括3個維度:Free Space、Max Chunk Size和Num Chunks,其中Free Space表示老生代當前空閒的總內存容量,Max Chunk Size表示老生代中最大的內存碎片所佔的內存容量大小,Num Chunks表示老生代中總的內存碎片數。我們在測試環境集羣(共4臺Region Server)將這個參數設置爲1,然後使用一個客戶端YCSB執行Read-And-Write操作,分別統計日誌中Free Space和Max Chunk Size兩個指標隨時間的變化情況。

測試結果如下圖所示,其中第一張圖表示Total Free Space隨時間的變化曲線圖,第二張圖表示Max Chunk Size隨時間變化曲線圖。其中橫座標表示時間,縱座標表示相應內存大小。

在這裏插入圖片描述在這裏插入圖片描述 根據第一張曲線圖可知,老生代總的空閒內存容量維持在300M~400M之間,當內存容量到達300M左右時就會進行一次GC,GC後內存容量就會又回到400M左右。而第二張曲線圖會更加形象地說明內存碎片導致的Promotion Failure,剛開始隨着數據不斷寫入,Max Chunk Size會不斷變小,之後很長一段時間基本維持在30M左右。在橫座標爲1093那點,人爲地將寫入的單條數據大小由500Byte變爲5M大小,此後Max Chunk Size會再次減小,當減小到一定程度之後曲線會忽然升高到350M左右,經過日誌確認,此時JVM發生了Promotion Failure模式的Full GC,持續時間約4.91s。此後一段時間Full GC還在持續發生。

經過上述分析,可以知道:CMS GC會不斷產生內存碎片,當碎片小到一定程度之後就會基本維持不變,如果此時業務寫入一些單條數據量很大的KeyValue,就有可能觸發Promotion Failure模式Full GC。

總結

本文首先介紹了兩種常見的Java GC策略,再接着介紹了CMS策略可能引起兩種模式的Full GC,最後通過一個小實驗說明了CMS GC確實產生了內存碎片,而且會導致長時間的Full GC發生。接下來《演進篇》會詳細介紹從一開始HBase是如何針對CMS進行優化處理的,敬請期待!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章