原創宣告:本文系作者原創,謝絕個人、媒體、公眾號或網站未經授權轉載,違者追究其法律責任。
導讀
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 垃圾回收器。
JVM 基礎
為了更好地理解後面提及的 YGC 優化思路和策略,需要先回顧一下與 GC 相關的基礎知識。
GC 分代假設
對傳統的、基本的 GC 實現來說,由於它們在 GC 的整個工作過程中都要 “stop-the-world”,如何縮短 GC 的工作時長是一件非常重要的事情。為了降低單次回收的時間,目前絕大部分的 GC 演算法,都是將堆記憶體進行分代 (Generation) 處理,將不同年齡的物件置於不同的記憶體空間,並針對不同空間中物件的特性使用更有效率的回收演算法分別進行回收,而這主要是基於如下的分代假設:
- 絕大部分物件的生命週期都非常短暫
- 剩下的物件,則很可能會存活很長時間,並不太可能使用到年輕物件
基於這個假設,JVM 將記憶體分為年輕代和老年代,讓新建立的物件都從年輕代中分配,通過頻繁對年輕代進行回收,絕大部分垃圾都能在 YGC 中被回收,只剩下極少部分的物件需要晉升到老年代中。由於整個年輕代通常比較小,只佔整個堆記憶體的 1/3 ~ 1/2,並且處於其內物件的存活率很低,非常適合使用拷貝演算法來進行回收,能有效降低 YGC 時的停頓時間,降低對應用的影響。
然而,如果應用中的物件不滿足上述提到的分代假設,例如出現了大量生命週期中等的物件,則會嚴重影響 YGC 的效率。
YGC 的基本過程
基於標記-複製演算法的 YGC 大致分為如下幾個步驟:
- 從 GC Roots 開始查詢並標註存活的物件
- 將 eden 區和 from 區存活的物件拷貝到 to 區
- 清理 eden 區和 from 區
YGC 耗時分析
當使用 G1 收集器時,通過 -XX:+PrintGCDetails 引數可以生成最為詳細的 GC 日誌,通過該詳細日誌,可以檢視到 GC 各個階段的耗時,為 GC 優化提供便利。然而如果使用的是 ParNew 和 CMS 垃圾回收器,實際上官方並未提供可以檢視 GC 各階段耗時的方法。所幸在 AliJDK 中,提供了類似的功能,通過 PrintGCRootsTraceTime 能列印出 ParNew 和 CMS 的詳細耗時。MsgBroker 的 GC 詳情日誌如下:
從上述詳細日誌中可以看出,YGC 主要存在如下各個階段:
- 各種 Roots 階段:從各型別 root 物件出發標記存活物件
- older-gen scanning:掃描老年代到新生代的引用以及拷貝 eden 區和 from 區中的存活物件至 to 區
- other:將需要晉升的物件從新生代拷貝到老年代
通常情況下,older-gen scanning 階段會在YGC中佔用大部分耗時。從上述 GC 詳細日誌中也能看出,MsgBroker 的 YGC 耗時大約在 90ms,而 older-gen scanning 階段就佔用了約 80ms。
old-gen scanning 階段
為了有針對性地對 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 階段實際上是無需進行追蹤的。
Card marking
回憶之前提到的分代假設,其中一條即是:存在少部分物件,可能會存活很長時間,並不太可能使用到年輕物件。這意味著,只有極少部分的老年代物件,會持有年輕代物件的引用,如果使用遍歷整個老年代的方式找出這部分物件,顯然效率十分低下。
一般而言,如下兩種情況會使得老年代物件持有年輕代的引用:
- 持有其他年輕代物件引用的物件被晉升到老年代
- 某個老年代物件持有的引用被修改為指向某個年輕代物件
對於第一種情況,因為晉升本身就發生在 YGC 執行期間,垃圾回收器能夠明確知曉哪些物件需要被晉升到老年代,而對於第二種情況,則需要依賴額外的設計。
在 HotSpot JVM 的實現中,ParNew 使用 Card marking 演算法來識別老年代物件所持有引用的修改。在該演算法中,老年代空間被分成大小為 512B 的若干個 card,並由 JVM 使用一個陣列來維護其對映關係,陣列中的每一位代表一個 card。每當修改堆中物件的引用時,就會將對應的 card 置為 dirty。當進行 YGC 時,只需要先通過掃描 card 陣列,就可以很快識別出哪部分空間可能存在老年代物件持有年輕代物件引用的情況,通過空間換時間的方式,避免對整個老年代進行掃描。
YGC 優化
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,與其他的投遞失敗不同,一旦出現大量投遞超時,訊息至少會在 MsgBroker 的記憶體中停留 10s,這會給 YGC 帶來非常大的壓力。
- 由於投遞失敗後的更新操作是非同步的,同時為了避免訊息更新操作對訊息新增造成影響,更新操作通常不會有太多的執行緒資源。當存在大量投遞失敗時,對訊息的更新操作很可能因為任務量過大而積壓在記憶體中。
為了解決上述問題,MsgBroker 實現了一種自適應投遞限流演算法,如下圖所示。演算法的基本思路就是服務端會不斷根據訂閱端的消費結果估計訂閱端的消費能力,並按照估計出的訂閱端消費能力進行限流投遞,對於被限流的訊息,能夠快速失敗掉,不必在記憶體中再停留 10s,同時也無需再執行 DB 更新操作。這樣,即保護了訂閱端,有利於積壓訊息的快速消化,也能保護服務端不受訂閱端的影響,並進一步降低 DB 的壓力。
通過引入自適應投遞限流,在實驗室測試環境下,MsgBroker 在異常場景下的 YGC 耗時進一步從 83ms 降低到 40ms,恢復了正常的水平。
YGC 優化總結
通過上面的 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)