作者:vivo 互聯網服務器團隊- Li Gang
本文介紹了遊戲業務使用MAT和GC日誌等工具對 Full GC頻率進行優化的過程。
一、背景
遊戲業務面對用户端的某個工程,每天Full GC頻率達到120次,業務高峯期每7分鐘就會有一次Full GC。為了避免情況持續變差,最大程度減少對系統響應時間的負面影響,需要對該工程的Full GC頻率進行優化。
該項目JDK版本為1.8,老年代使用CMS作為垃圾回收器,優化前的部分啓動參數如下:
-Xms4608M -Xmx4608M -Xmn2048M -XX:MetaspaceSize=320M -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly
二、工具介紹
在本次優化過程中,我們主要使用了MAT和GC日誌作為排查工具。MAT是一個功能強大的內存分析工具,而GC日誌則用於記錄Java虛擬機中的垃圾回收行為和內存情況。這兩者結合起來,能夠幫助開發人員深入分析程序的內存使用情況,並進行相應的優化。下文將詳細的介紹這兩種工具的使用方法,以及對應的優化案例。
2.1 MAT(Memory Analyzer Tool)
Eclipse Memory Analyzer Tool(MAT)是一個開源的Java堆轉儲分析工具。它旨在幫助開發人員識別和消除Java堆中的內存泄漏和優化內存使用。MAT允許用户分析Java堆轉儲文件,識別對象的內存佔用情況,查找潛在的內存泄漏和冗餘對象,以便執行一些內存優化。
使用MAT打開dump文件後,首先進入的是上圖頁面,此頁面會顯示dump包的縮略概覽信息,包括堆大小,類數量,對象數量等信息。其中的Biggest Objects By Retained Size和Leak Suspects在問題明顯時會比較有用,但對相對複雜的問題來説幫助不大。筆者比較常用的是下面這幾個功能,下文將依次介紹:
2.1.1 Dominator Tree
(1)功能
展示對象的支配關係。對象A支配對象B代表從GC Root(也不一定是GC ROOT,也可以是unreachable的起點)達到對象B的所有路徑都必須經過對象A,這也意味着對象A被垃圾回收後,對象B也會被回收。
這個功能相較於下面的Histogram更強調對象的引用關係,此外還可以通過Group By Class/Group By Package/Group By ClassLoader來進一步的聚合對象。
MAT的各種圖標中會頻繁的出現Shallow Heap Size和Retained Heap Size這兩個名詞,其含義如下:
- Shallow Heap Size:這個對象自身在堆中的大小
- Retained Heap Size:這個對象被垃圾回收後會釋放的堆內存大小
上圖中,情況1裏對象A的Retained Heap Size = A的Shallow Heap Size + B的Shallow Heap Size +C的Shallow Heap Size,情況2裏對象A的Retained Heap Size = A的Shallow Heap Size + B的Shallow Heap Size。
(2)使用方法
①上圖為Group By Class的dominator tree。剛打開dominator tree時默認是不進行group的,此時可以排查單個大對象,排查完單個大對象後,需要將對象Group一下才能進行下一步的排查。
② 從上圖可以看出,這個堆內的對象內存佔用比較分散,説明導致問題的原因可能不止一個,這種情況下只能結合自身業務逐個排查內存佔用排在前面的對象。
③ 對這些可疑的對象,右鍵類,選擇List objects → with outgoing references展開對象列表,查看這類對象具體存了什麼,判斷這些對象的值是否可以再分類。
④ 根據對象的值,判斷對象的業務含義,確定是哪段代碼創建的對象。
⑤ 結合代碼,思考這類對象是在新生代還是老年代,如果能確定都在新生代,那這些對象一般不會導致老年代快速增長。
⑥如果在老年代,需要確定其是怎麼從新生代晉升的,內存佔用是否有上限,上限是多少,一般多長時間能達到上限,再確定有沒有問題。
2.1.2 Histogram
功能
histogram可以顯示出各類對象的Shallow Heap Size和Retained Heap Size,Retained Heap Size默認不展示,需要點擊菜單欄的Calculate Retained Size進行計算,堆較大時計算耗時較長。
這張表一般和Dominator Tree結合使用,我們能看到char[]佔用了較大的內存,但由於Dominator Tree裏聚合好的char[]都是頂層支配者,上層不會再有引用,有時無法直接確定這些對象曾經被誰持有過,這時可以通過Histogram查看同類對象,找到相似的並且reachable的對象來確定這類對象是誰創建的。但是這一步其實可以通過oql解決,所以這張表在排查過程中的使用率其實沒有Histogram高。
2.1.3 OQL
功能
MAT提供的一種類似SQL的查詢語句,可以對對象進行過濾。這篇官方文章裏給了很多查詢語句樣例:https://wiki.eclipse.org/MemoryAnalyzer/OQL,這裏就簡單列一些筆者排查過程中用過的語句,不再贅述:
// 字符串模糊匹配
SELECT * FROM char[] b where toString(b) LIKE ".*traceId.*"
// 查找地址>0x700000000的對象
SELECT * FROM java.lang.Object t WHERE toHex(t.@objectAddress) >= "0x700000000"
// 查找長度等於73並且retained heap size>1000B 的對象
SELECT * FROM java.lang.Object[] a where a.@length=73 and a.@retainedHeapSize>1000
// 查找長度等於65536並且上層有引用的對象
SELECT * FROM char[] a where a.@length=65536 and (inbounds(a).size()>0)
2.2 GC日誌
GC日誌是記錄Java虛擬機中垃圾回收活動的日誌文件。在GC日誌中,可以看到包括垃圾回收的時間、類型(如新生代GC、老年代GC等)、回收週期、回收停頓時間、回收前後堆的使用情況等信息。GC日誌打印的信息可以通過以下啓動項控制:
三、 案例介紹
在這篇文章中,我們將聚焦於一些具體的案例,涉及到大量被Dubbo的FutureAdapter引用的對象、Jackson的BufferRecycler導致的大量char[65536]以及對象晉升年齡閾值過小等問題。通過這些案例,我們將探討這些具體問題的引起原因以及解決方案。
3.1 大量被Dubbo的FutureAdapter引用的對象
(1)分析過程
從上圖中,我們可以看到dubbo FutureAdapter佔用了230M左右的內存,前面的PSWMS對象雖然也佔用了230M左右的內存,但這是業務使用的本地緩存相關對象,其內存佔用是在預期範圍內的,因此優先分析FutureAdapter。先右鍵List objects→with outgoing references展開對象列表。
發現其中有大量大小几乎一致的FutureAdapter,一個佔用內存328KB左右,大小和內容幾乎一致的對象約有550多個,總共佔用內存200M左右。
FutureAdapter被用來執行Dubbo的異步調用,項目使用的dubbo版本為2.7.18。dubbo的同步調用本質上是一個異步轉同步的過程,發起異步調用將CompletableFuture對象放到ThreadLocal的FutureContext裏,然後立刻調用CompletableFuture.get方法阻塞獲取返回值,獲取到返回值後,dubbo不會主動清理FutureContext,因此該線程的ThreadLocal裏會有一條FutureContext→ FutureAdapter→Result的引用,如下圖:
然而,那550多個FutureAdapter均為不可達對象,意味着其不被ThreadLocal引用,在下次GC時會被回收,不過我們無法直接確定這些對象是在老年代還是新生代,有可能這些對象都在新生代,下次young gc時就會被回收,不會晉升到老年代,更不會導致老年代增長。
但由於這個列表裏的數據有明確的業務含義,可以找到對應的業務接口,此接口單機峯值qps約為2,響應時間約100ms,每被調用一次,就會創建一個該對象列表,此時該機器的young gc頻率約為10s一次。假設這些對象都在新生代沒有晉升老年代,那麼這些對象在新生代最大的存活數量約為((接口響應時間 + 兩次young gc間隔) 對象創建速度) = (0.1s + 10s) 2 ≈ 20,而堆裏有550多個,如果這些對象沒有晉升到老年代的話數量上對不上,所以可以推測出這些對象在老年代裏,需要等下次Full GC時才會被回收。
那麼這些不可達的FutureAdapter為什麼會在老年代?每次執行dubbo調用,dubbo都會用這次調用的FutureAdapter替換掉上次調用時存在FutureContext裏的FutureAdapter,上次調用的FutureAdapter不再被GC Root引用,在下次GC時被回收。當一個線程相對頻繁的執行dubbo調用時,FutureAdapter會被young gc回收,不會晉升到老年代。但在本例中,該dubbo調用被放到了corePoolSize=150,maxPoolSize=500的業務通用線程池中執行,該線程池會執行其他不需要調用dubbo服務的任務,並且該線程池的使用率並不高,這就意味着一個線程調用完dubbo服務後可能要過一段時間才能執行下一次dubbo調用。
由於這個原因導致FutureAdapter被放入ThreadLocal後,在新生代停留過長時間,最終晉升到老年代,這個"過長時間"對於此項目來説是6次young gc的間隔時長,這個時長的獲取方法會在後續説明。
(2)解決方案
對於此業務來説,這個dubbo調用可以改為查詢本地緩存,直接解決了問題。除此之外還有其他解決方案,需要結合自身業務選擇合適的方案。
- 直接使用dubbo的異步調用,而不是在上層再創建一個線程池來進行調用。
- 合理設置線程池的大小,提高線程的利用率。
- 寫一個Dubbo Filter,每次同步調用完後清理FutureContext(影響面可能較大,需自行評估風險)。
3.2. Jackson的BufferRecycler導致的大量char[65536]
(1)分析過程
從dominator tree中我們能看到char[]也佔用了相當大的一部分內存,展開char[],發現其中包含大量的char[65536],使用oql統計得知不被gc root引用的有1600個,佔用內存200M左右,被gc root引用的有500個,這種char[65536]裏存儲的數據均為http接口返回值反序列化後的字符。其被gc root引用時的鏈路如下,均被ThreadLocal裏的BufferRecycler引用:
在Jackson庫中,BufferRecycler的主要作用是管理緩衝區的重用,可以減少頻繁的內存分配和釋放,從而降低垃圾回收的負擔,提高性能。但從堆上看,這些char數組裏不可達的數量遠大於可達的數量(1600:500),説明其複用率並不高,與其設計的目標不符,需要查看源碼才能搞清原因。
項目使用jackson的ObjectMapper.
writeValueAsString()方法對http接口返回值進行了反序列化,使用的Jackson版本為2.10,該方法完整的執行流程如下圖:
簡單來説,jackson在反序列化時,會將反序列化的結果存儲在多段char[]裏,每當最後一個char[]空間不夠存放結果時,就新建一個char[],大小為最後使用的char[]的1.5倍,但不超過65536,反序列化結束後將char[]列表拼接起來就得到了結果,然後線程會將最後使用的那個char[]存放到ThreadLocal。此線程下次反序列化時,會從ThreadLocal取出這個char[]進行復用。這樣的一個複用邏輯會有一個問題,參考下圖:
圖中,_segements是當前反序列化使用過的char[]列表,currentSegement是當前正在使用的char[]。一個char[]的大小最大為65536。在第二次反序列化大對象時至少會創建一個新的大小為65536的char[](上一次的char[]是65536,再創建一個新char[]其大小仍不能超過65536)。可以看到在第一次反序列化結束後和第二次反序列化結束後,雖然ThreadLocal裏存放的char[]大小都是65536,但其實它們已經不是同一個對象了。這樣的一個替換是沒有必要的,完全可以一直複用同一個char[]。
當業務所有http接口的返回值都大且流量也大時,每次保存在ThreadLocal裏的char[65536]雖然會在下次反序列化結束時被替換導致其失去引用,但由於其在新生代只存活了一次接口請求的時間,所以不會晉升到老年代,可以被young gc回收。但是我們項目用來處理http請求的線程池都是同一個,這些接口的返回值只有一部分超過了65536,在小於的時候ThreadLocal裏的char[]不會被替換,當這個char[]在ThreadLocal裏停留一段時間後,就會晉升到老年代,從而導致老年代內存增長。
(2)解決方案
-
關閉Jackson的USE\_THREAD\_LOCAL_
FOR\_BUFFER\_RECYCLING,關閉該開關會在每次反序列化時創建一個BufferRecycler,而不是複用ThreadLocal裏的BufferRecycler,這樣可能導致young gc頻率提高。
- 升級Jackson版本,請參考此issue,2.17版本的jackson在調用releaseByteBuffer時會避免較小或者相同大小的char數組替換原有數組。
由於項目使用的jackson版本是2.10,直接升級到2.17的版本跨度較大,可能帶來不必要的風險,因此採用了方案1,上線後,young gc頻率沒有明顯增加。方案2的issue裏有提到使用2.16版本引入的RecyclerPool代替基於ThreadLocal的實現,這也是解決方案之一。
3.3 對象晉升年齡閾值過小
(1)背景知識
java對象從新生代晉升到老年代有多種原因,在本項目中,對象的主要晉升原因是在新生代長期存活,這個長期具體是多久有以下兩個判斷條件:
-
對象晉升年齡閾值:
可通過-XX:MaxTenuringThreshold啓動項進行配置,對於CMS,默認值是6。此參數定義了對象在年輕代存活的最大年齡,如果一個對象在年輕代經過N次GC後依然存活,它將會被晉升到老年代。
-
動態年齡判定:
在survivor區中小於或等於某年齡的的所有對象大小的總和大於survivor空間的一定比例時,大於或等於該年齡的對象就直接進入老年代,這個比例可以通過-XX:TargetSurvivorRatio啓動項控制,默認值為50,代表50%。
一個對象的年齡滿足上述兩個條件之一時,就會晉升到老年代,具體的晉升年齡可以通過在啓動項裏添加-XX:+PrintTenuringDistribution獲取,添加該參數後的gc日誌如下圖:
其中區域2的(max 6)代表-XX:MaxTenuringThreshold啓動項配置的值,也就是説對象到達這個年齡一定會晉升,而new threshold 6代表對象實際晉升的年齡,上圖代表這次young gc因對象到達年齡閾值會導致9946864 bytes的對象晉升。
區域1代表動態年齡判定所需的空間大小,也就是(survivor空間大小 x targetSurvivotRatio)。此項目堆的單個survivor空間為200M,所以只要在survivor區中小於或等於某年齡的所有對象大小的總和大於200Mx50%,大於或等於該年齡的對象就會晉升,。
而下圖的對象是因為動態年齡判定才晉升的,這次young gc因動態年齡判定會導致38660424 bytes的對象晉升:
(2)分析過程
優化前+調參前:
在進行dubbo和jackson以及其他業務代碼上的優化前,我們保存了當時的gc日誌,可以看到大部分對象都是因為年齡到達6晉升的,每次young gc約有10M~16M左右的對象晉升,顯然對象的晉升年齡閾值太小,需要調大。
優化後+調參前:
在調整 JVM 參數之前,我們決定先着手進行業務上的優化。因為直接進行參數調整可能會治標不治本,無法消除潛在的隱患。在完成業務代碼上的優化後,可以看到此時由於年齡達到6這一閾值晉升的對象大小從最開始的10M~16M降為了4M以下,意味着在新生代長期存活的對象數量明顯減少了,但仍然有優化空間。
(3)解決方案
在完成業務代碼上的優化後,我們對 JVM 參數進行了調整。將-XX:MaxTenuringThreshold參數改為15,-XX:TargetSurvivorRatio改為75%(實際上,通過調參後的gc日誌我們能確定對於這個項目來説,50%也已經夠用,因為年齡1到15的對象佔用的總內存只有38M左右,遠遠小於單個survivor空間的50%),以延長對象在新生代的存活時間。我們可以觀察到,儘管會有一些對象存活到年齡15的閾值才晉升,但是這部分對象的總大小變小了,大部分情況下都是小於2M。這部分對象通過添加監控的方式判斷大概率是被移除(大小不足被淘汰,過期等原因)的caffeine本地緩存。
在經過上述一系列優化措施以及一些其他問題的修復後,該工程的 Full GC 頻率從最初的每天 120 次,總耗時 1 分鐘到 1.5 分鐘,成功降低到每天約 30 次左右,總耗時控制在 15 秒到 25 秒之間。
① GC次數優化曲線
② GC總耗時優化曲線
總的來説,進行GC優化時,可以使用以下工具分析當前內存/GC情況:
- 先用jamp生成dump文件,再使用MAT進行分析,找到可能引發問題的對象。
- 使用內存分配火焰圖找到哪些代碼在頻繁的分配內存。
- 使用GC日誌分析GC情況,瞭解GC頻率/觸發GC的原因等信息。
使用這些工具找到問題後,可以修改對應的業務代碼或者調整JVM相關參數,以優化Full GC頻率。