原創聲明:本文系做者原創,謝絕我的、媒體、公衆號或網站未經受權轉載,違者追究其法律責任。
GC 一直是 Java 應用中被討論得最多的話題之一,尤爲對於消息中間件這樣的基礎應用,GC 停頓產生的延遲會嚴重影響其在線服務能力,是開發和運維人員關注的重點。
關於 GC 優化,首先最容易想到的就是調整那些影響 GC 性能的 JVM 參數(如新生代與老年代的大小、晉升到老年代的年齡、甚至是 GC 回收器類型等),使得老年代中存活的對象數量儘量的少,從而下降 GC 停頓時間。然而,除了少數較爲通用的參數設置方法能夠參照和遵循,在大部分場景下,因爲不一樣應用所建立對象的大小與生命週期不盡相同,GC 參數調優其實是個很是複雜且極具個性化的工做,並不存在萬能的調優策略能夠知足全部的場景。同時,因爲虛擬機內部已經作了不少優化來儘可能下降 GC 的停頓時間,GC 參數調優並不必定能達到預期的效果,甚至極可能拔苗助長。
拋開被老生常談的 GC 參數調優,本文將經過講述螞蟻消息中間件(MsgBroker) 的 YGCT 從 120ms 優化到 30ms 的歷程,並從中總結出較爲通用的 YGC 優化策略。
談到 GC,不少人的第一反應是 JVM 長時間停頓或 FGC 致使服務長時間不可用,但對於 MsgBroker 這樣的基礎消息服務而言,對 GC 停頓會更加敏感,須要解決的 GC 問題也更加複雜:
-
對於普通應用,若是 YGC 耗時在 100ms 之內,通常是無需進行優化的。但對於 MsgBroker 這類在線基礎服務,GC 停頓產生的延遲根據業務的複雜程度會被放大數倍甚至數十倍,太高的 YGC 耗時會嚴重損害業務的實時性和用戶體驗,所以須要被嚴格控制在 50ms 之內,且越低越好。然而,隨着新特性的開發和消息量的增加,咱們發現 MsgBroker 的 YGC 平均耗時已緩慢增加至 50ms~60ms,甚至部分機房的 YGC 平均耗時已高達 120ms。
-
一方面,爲保證消息數據的高可靠,MsgBroker 使用 DB 進行消息的持久化,並使用消息緩存下降消息投遞時對 DB 的讀壓力;另外一方面,做爲一個主要服務於在線業務的消息系統,爲嚴格保證消息的實時性,MsgBroker 使用推模型進行消息投遞。然而,咱們發現,當訂閱端的能力與發送端不匹配時,會產生大量的投遞超時,並進一步加劇MsgBroker 的內存和 GC 壓力。訂閱端的消費能力會對 MsgBroker 的服務質量形成影響,這在絕大部分場景下是難以接受的。
-
在某些極端場景下(例如訂閱端容量出現問題,大量消息持續投遞超時,隨着積壓的消息愈來愈多,甚至可能引起下游鏈路「雪崩」,致使長時間沒法恢復),YGC 耗時很是高,同時也有可能發生 FULL GC,而觸發緣由主要爲 promotion failed 以及 concurrent mode failure,懷疑是由於內存碎片過多所致。
須要指出的是,MsgBroker 運行在普通的 4C8G 機器上,堆大小爲 4G,所以使用的是 ParNew 與 CMS 垃圾回收器。
爲了更好地理解後面說起的 YGC 優化思路和策略,須要先回顧一下與 GC 相關的基礎知識。
對傳統的、基本的 GC 實現來講,因爲它們在 GC 的整個工做過程當中都要 「stop-the-world」,如何縮短 GC 的工做時長是一件很是重要的事情。爲了下降單次回收的時間,目前絕大部分的 GC 算法,都是將堆內存進行分代 (Generation) 處理,將不一樣年齡的對象置於不一樣的內存空間,並針對不一樣空間中對象的特性使用更有效率的回收算法分別進行回收,而這主要是基於以下的分代假設:
基於這個假設,JVM 將內存分爲年輕代和老年代,讓新建立的對象都從年輕代中分配,經過頻繁對年輕代進行回收,絕大部分垃圾都能在 YGC 中被回收,只剩下極少部分的對象須要晉升到老年代中。因爲整個年輕代一般比較小,只佔整個堆內存的 1/3 ~ 1/2,而且處於其內對象的存活率很低,很是適合使用拷貝算法來進行回收,能有效下降 YGC 時的停頓時間,下降對應用的影響。
然而,若是應用中的對象不知足上述提到的分代假設,例如出現了大量生命週期中等的對象,則會嚴重影響 YGC 的效率。
基於標記-複製算法的 YGC 大體分爲以下幾個步驟:
-
從 GC Roots 開始查找並標註存活的對象
-
將 eden 區和 from 區存活的對象拷貝到 to 區
-
清理 eden 區和 from 區
當使用 G1 收集器時,經過 -XX:+PrintGCDetails 參數能夠生成最爲詳細的 GC 日誌,經過該詳細日誌,能夠查看到 GC 各個階段的耗時,爲 GC 優化提供便利。然而若是使用的是 ParNew 和 CMS 垃圾回收器,實際上官方並未提供能夠查看 GC 各階段耗時的方法。所幸在 AliJDK 中,提供了相似的功能,經過 PrintGCRootsTraceTime 能打印出 ParNew 和 CMS 的詳細耗時。MsgBroker 的 GC 詳情日誌以下:
從上述詳細日誌中能夠看出,YGC 主要存在以下各個階段:
一般狀況下,older-gen scanning 階段會在YGC中佔用大部分耗時。從上述 GC 詳細日誌中也能看出,MsgBroker 的 YGC 耗時大約在 90ms,而 older-gen scanning 階段就佔用了約 80ms。
爲了有針對性地對 old-gen scanning 階段耗時進行優化,有必要先了解一下爲何會有 old-gen scanning 階段。
在常見的垃圾回收算法中,不管是拷貝算法,仍是標記-清除算法,又或者是標記-整理算法,都須要從一系列的 Roots 節點出發,根據引用關係遍歷和標記全部存活的對象。
對於 YGC,在從 GC Roots 開始遍歷並標記全部的存活對象時,會放棄追蹤處於老年代的對象,因爲須要遍歷的對象數目減小,能顯著提高 GC 的效率。 但這會產生一個問題:若是某個年輕代對象並不能經過 GC Roots 遍歷到,而某個老年代對象卻引用了該年輕代的對象,那麼該如何正確標記到該對象?
爲解決這個問題,一個最直觀的想法就是遍歷整個老年代,找到其中持有年輕代引用的對象,但顯然這樣作的開銷太大,且違背了分代 GC 的設計。所以,垃圾回收器必須可以以較高的效率準確找到並跟蹤那些處於老年代且持有年輕代引用的對象,並將這部分對象放到和 GC Roots 同等的位置,這就是 old-gen scanning 階段的來歷。
下圖大體展現了 YGC 時是如何追蹤和標記存活的對象的。圖中的箭頭表示對象之間的引用關係,其中紅色箭頭表示老年代到年輕代的引用,這部分對象會被添加到 old-gen scanning 中,而藍色的箭頭表示 GC Roots 或年輕代對象到老年代的引用,這部分對象在 YGC 階段其實是無需進行追蹤的。
回憶以前提到的分代假設,其中一條便是:存在少部分對象,可能會存活很長時間,並不太可能使用到年輕對象。這意味着,只有極少部分的老年代對象,會持有年輕代對象的引用,若是使用遍歷整個老年代的方式找出這部分對象,顯然效率十分低下。
通常而言,以下兩種狀況會使得老年代對象持有年輕代的引用:
對於第一種狀況,由於晉升自己就發生在 YGC 執行期間,垃圾回收器可以明確知曉哪些對象須要被晉升到老年代,而對於第二種狀況,則須要依賴額外的設計。
在 HotSpot JVM 的實現中,ParNew 使用 Card marking 算法來識別老年代對象所持有引用的修改。在該算法中,老年代空間被分紅大小爲 512B 的若干個 card,並由 JVM 使用一個數組來維護其映射關係,數組中的每一位表明一個 card。每當修改堆中對象的引用時,就會將對應的 card 置爲 dirty。當進行 YGC 時,只須要先經過掃描 card 數組,就能夠很快識別出哪部分空間可能存在老年代對象持有年輕代對象引用的狀況,經過空間換時間的方式,避免對整個老年代進行掃描。
ParGCCardsPerStrideChunk 參數
既然 old-gen scanning 在 YGC 中佔用大部分耗時,是 YGC 耗時高的主要緣由,那麼首先想到的是,可否經過調整參數加快 old-gen scanning 的掃描速度?
在 old-gen scanning 階段,老年代會被切分爲若干個大小相等的區域,每一個工做線程負責處理其中的一部分,包括掃描對應的 card 數組以及掃描被標記爲 dirty 的老年代空間。因爲處理不一樣的老年代區域所須要的處理時間相差可能很大,爲防止部分工做線程過於空閒,一般被切分出的老年代區域數須要大於工做線程的數目,而 ParGCCardsPerStrideChunk 參數則是用於控制被切分出的區域的大小。
默認狀況下,ParGCCardsPerStrideChunk 的值爲 256,因爲每一個card 對應 512 字節的老年代空間,所以在掃描時每一個區域的大小爲 128KB,對於 4GB 的堆,會存在超過 3 萬個區域,比工做線程數足足高了 4 個數量級。下圖即爲將ParGCCardsPerStrideChunk參數分別設置爲 256,2K,4K 和 8K 來運行 GC 基準測試[1],結果顯示,默認值 256 在任何狀況下都顯得有些小,而且堆越大,GC 停頓時間相比其餘值也會越長。
考慮到 MsgBroker 的堆大小爲 4G,ParGCCardsPerStrideChunk設置爲4K已經足夠大。然而,在修改了 ParGCCardsPerStrideChunk 後,並無取得預期內的效果,實際上 MsgBroker 的 YGC 耗時沒有獲得任何下降。這說明,被置爲dirty的card可能很是多,破壞了 GC 的分代假設,使得掃描任務自己過於繁重,其耗費的時間遠遠大於工做線程頻繁切換掃描區域的開銷。
基於上面的猜想,咱們將優化聚焦到了消息緩存上。爲了不消息緩存中消息數量過多致使 OOM,MsgBroker 基於 LinkedHashMap 實現了 LRU Cache 和 FIFO Cache 。衆所周知,LinkedHashMap是 HashMap 的子類,並額外維護了一個雙向鏈表用於保持迭代順序,然而,這可能會帶來如下三個問題:
-
消息緩存中可能存在一些一直未投遞成功的消息,這些消息對象都處於老年代;同時,當收到發送端的發消息請求時,MsgBroker 會將消息插入到緩存中,這部分消息對象處於年輕代。當不斷向消息緩存中插入新的元素時,內部雙向鏈表的引用關係會頻繁發生變化,YGC 時會觸發大規模的老年代掃描。
-
當訂閱端出現問題時,大量未投遞成功的消息都會被緩存起來,即便存在 LRU 等淘汰機制,被淘汰出的消息也頗有可能已經晉升到老年代,不管是 YGC 時拷貝、晉升的壓力,仍是 CMS GC 的頻率,都會顯著提高。
-
不一樣業務所發送的消息的大小區別很是大,當訂閱端出現問題時,會有大量消息被晉升到老年代,這可能會產生大量的內存碎片,甚至引起 FGC。
上述第一個和第二個問題會使得 YGC 時 old-gen scanning 階段的掃描、拷貝成本更高,other 階段晉升的對象更多,而第三個問題則會產生更多的內存碎使得 FGC 的機率升高。
既然消息緩存的插入、查詢、移除、銷燬都是由 MsgBroker 本身控制,那麼,若是這部份內存再也不委託給 JVM,而是徹底由 MsgBroker 自行管理其生命週期,上述 GC 問題就都能獲得解決。
談到讓 JVM 看不見,最直觀的想法就是使用堆外解決方案。然而,在上面的場景中,若是僅僅只是將消息移動到堆外,是沒法徹底解決問題的。若是要解決上述全部問題,須要有一個完整運行在堆外的相似 LinkedHashMap 的數據結構,同時須要具有良好的併發訪問能力,且不能有性能損失。
ohc 做爲一個足夠簡單、侵入性低的堆外緩存庫,最開始是 Apache Cassandra 的堆外內存分配方案,後來 Cassandra 將這塊實現單獨抽象出來,做爲一個獨立的包,使得其餘有一樣需求的應用也能使用。因爲 ohc 提供了完整的堆外緩存實現,支持無鎖的併發寫入和查詢,同時也支持LRU,十分契合 MsgBroker 的需求,本着不重複造輪子的原則,咱們決定基於其實現堆外消息緩存。
與堆內消息緩存相比,使用堆外消息緩存會多一次內存拷貝的開銷。不過,從實際的測試數據看,在給定的吞吐量下,堆外緩存下的 RT 並無出現惡化,僅僅 CPU Util 略微有所提高(從 60% 升到 63%),徹底在能夠接受的範圍內。
經過上述消息緩存優化,並將 ParGCCardsPerStrideChunk 參數設置爲 4K 後,線上大部分機器的 YGC 耗時從 60ms 下降到 30ms 左右,同時 CMS GC 出現的頻率也大大下降。
然而,對於那些 YGC 耗時特別高的機房中的機器,即便經過消息緩存優化,YGC 耗時也只是從 120ms 下降到 80ms 左右,耗時仍然偏高,且 old-gen scanning 階段依然佔用了絕大部分時間。
經過對線上機器的 GC 狀況進行觀察和總結,咱們發現,YGC 耗時在 50ms 左右的機器,鏈接數比較正常,基本都維持在 5000 左右,而那些 YGC 耗時爲 120ms 左右的機器,其鏈接數接近甚至超過 20000。基於這些發現,YGC 問題極可能與通訊層密切相關。
本來,MsgBroker 的網絡通訊層是使用本身開發的網絡框架 Gecko,Gecko 默認會爲每一個網絡鏈接分配 64KB 的內存,若是網絡鏈接數過多,就會佔用大量的內存,致使頻繁 GC,嚴重限制了 MsgBroker 的性能。在這個背景下,MsgBroker 使用自研的 Bolt 網絡框架(基於 Netty)對網絡層進行了重構,默認將網絡鏈接使用的內存分配到堆外,解決了高鏈接數下的性能問題。同時,Bolt 的基準性能測試也顯示,即便在 100000 的鏈接數下,服務端的性能也不會受到鏈接數的影響。
若是通訊框架自己不會遇到鏈接數的問題,那麼頗有多是 MsgBroker 在對通訊框架的使用上存在一些問題。經過 review 代碼、dump 內存等手段,咱們發現問題主要出在消息請求的 decode 上。
以下面的代碼所示,在對消息請求進行 decode 時,RequestDecoder 會首先嚐試解析消息的 header 部分,若是 byteBuf 中的數據足夠,RequestDecoder 會將 header 完整解析出來,並保存在 requestCommand 中。這樣,若是 byteBuf 中的數據不夠解析出消息的 body 部分,下次 decode 時也能夠直接從 body 部分開始,下降重複讀取的開銷。
RequestDecoder 持有 RequestCommand 的引用,本意是爲了不重複讀取 byteBuf。然而,這卻會帶來如下問題:
-
RequestDecoder 基本都處於老年代,而 RequestCommand 處於年輕代。當服務端的某個鏈接不斷接收發消息請求時,其老年代與年輕代之間的引用關係也會不斷變換,這會加劇 YGC 時的老年代掃描壓力,鏈接數越多,壓力越大。
-
對於消息量較少的鏈接,雖然引用關係不會頻繁變換,但因爲 RequestDecoder 會長期持有某個 RequestCommand 的引用,使得該消息沒法被及時回收,容易因達到必定年齡而晉升到老年代,這會加劇 YGC 時的拷貝壓力。一樣,鏈接數越多,壓力也越大。
其實解決思路也很是簡單,讓 RequestDecoder 再也不持有對 RequestCommand 的引用。在 decode 時,若是 byteBuf 中可讀取的內容不夠完整解析出消息,則回滾讀取 index 到初始位置並放棄本次 decode 操做,直到 byteBuf 中存在足夠多的數據。這樣雖然可能會存在重複讀取,但與 GC 比起來,這點開銷徹底能夠接受。
經過上述優化,即便是那些鏈接數特別高的機器,其 YGC 耗時也進一步從 80ms 降低到了 30ms。
MsgBroker 做爲推模式的消息中間件,不管何種狀況都可以有效保證消息投遞的實時性。但若是訂閱端由於頻繁 GC,CPU 或 IO 出現瓶頸,甚至下游鏈路 RT 變高致使消息的消費速度跟不上消息的生產速度,就容易使得大量被實時推送過來的消息堆積在訂閱端的消息處理線程池隊列中,而這其中的絕大部分消息,可能都還不及出隊列獲得被線程執行的機會,就已經被 MsgBroker 斷定爲投遞超時,從而引起大量的投遞超時錯誤,致使大量消息須要被重投。
當新產生的消息疊加上須要被重投的消息,會更加劇訂閱端的負擔,使得因投遞超時而須要被重投的消息愈來愈多,即便後續訂閱端的消費能力恢復正常,也可能由於失敗量過大致使須要很長的消化時間,若是失敗持續時間過長,甚至可能引起這個消費鏈路的雪崩,訂閱端沒法再恢復正常。
儘管經過上述優化,能有效解決內存碎片問題,以及正常場景下的 YGC 耗時高問題。但在異常場景下,YGC 耗時仍然較高(在實驗室構造的超時場景下,儘管鏈接數維持在個位數,YGC 平均耗時也上漲到了 147ms),在而經過上述優化手段,YGC 耗時也僅從 147ms 下降到了 83ms。經過進一步的分析,咱們發現:
爲了解決上述問題,MsgBroker 實現了一種自適應投遞限流算法,以下圖所示。算法的基本思路就是服務端會不斷根據訂閱端的消費結果估計訂閱端的消費能力,並按照估計出的訂閱端消費能力進行限流投遞,對於被限流的消息,可以快速失敗掉,沒必要在內存中再停留 10s,同時也無需再執行 DB 更新操做。這樣,即保護了訂閱端,有利於積壓消息的快速消化,也能保護服務端不受訂閱端的影響,並進一步下降 DB 的壓力。
經過引入自適應投遞限流,在實驗室測試環境下,MsgBroker 在異常場景下的 YGC 耗時進一步從 83ms 下降到 40ms,恢復了正常的水平。
經過上面的 YGC 問題以及優化過程能夠看出,YGC 的惡化,主要就在於應用中的對象違背了 GC 的分代假設,而上述所說起的全部優化手段,也是爲了儘可能讓應用中的對象知足 GC 的分代假設。所以,在平時的研發活動中,程度的設計和實現都應該儘可能知足分代假設。
reference
-
Garbage collection in the HotSpot JVM (https://www.ibm.com/developerworks/library/j-jtp11253/)
-
Secret HotSpot option improving GC pauses on large heaps (http://blog.ragozin.info/2012/03/secret-hotspot-option-improving-gc.html)
-
OHC - An off-heap-cache (https://github.com/snazy/ohc/)
公衆號:金融級分佈式架構(Antfin_SOFA)