在高并發下,Java程式的GC問題屬于很典型的一類問題,帶來的影響往往會被進一步放大,不管是「GC頻率過快」還是「GC耗時太長」,由于GC期間都存在Stop The World問題,因此很容易導致服務超時,引發性能問題,
我們團隊負責的廣告系統承接了比較大的C端流量,平峰期間的請求量基本達到了上千QPS,過去也遇到了很多次GC相關的線上問題,
這篇文章,我再分享一個更棘手的Young GC耗時過長的線上案例,同時會整理下YGC相關的知識點,希望讓你有所識訓,內容分成以下2個部分:
- 從一次YGC耗時過長的案例說起
- YGC的相關知識點總結
從一次YGC耗時過長的案例說起
今年4月份,我們的廣告服務在新版本上線后,收到了大量的服務超時告警,通過下面的監控圖可以看到:超時量突然大面積增加,1分鐘內甚至達到了上千次介面超時,下面詳細介紹下該問題的排查程序,
檢查監控
收到告警后,我們第一時間查看了監控系統,立馬發現了YoungGC耗時過長的例外,我們的程式大概在21點50左右上線,通過下圖可以看出:在上線之前,YGC基本幾十毫秒內完成,而上線后YGC耗時明顯變長,最長甚至達到了3秒多,
由于YGC期間程式會Stop The World,而我們上游系統設定的服務超時時間都在幾百毫秒,因此推斷:是因為YGC耗時過長引發了服務大面積超時,
按照GC問題的常規排查流程,我們立刻摘掉了一個節點,然后通過以下命令dump了堆記憶體檔案用來保留現場,
jmap -dump:format=b,file=heap pid
最后對線上服務做了回滾處理,回滾后服務立馬恢復了正常,接下來就是長達1天的問題排查和修復程序,
確認JVM配置
用下面的命令,我們再次檢查了JVM的引數
ps aux | grep "applicationName=adsearch" -Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:ParallelGCThreads=5 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80
可以看到堆記憶體為4G,新生代和老年代均為2G,新生代采用ParNew收集器,
再通過命令 jmap -heap pid 查到:新生代的Eden區為1.6G,S0和S1區均為0.2G,
本次上線并未修改JVM相關的任何引數,同時我們服務的請求量基本和往常持平,因此猜測:此問題大概率和上線的代碼相關,
檢查代碼
再回到YGC的原理來思考這個問題,一次YGC的程序主要包括以下兩個步驟:
1、從GC Root掃描物件,對存活物件進行標注
2、將存活物件復制到S1區或者晉升到Old區
根據下面的監控圖可以看出:正常情況下,Survivor區的使用率一直維持在很低的水平(大概30M左右),但是上線后,Survivor區的使用率開始波動,最多的時候快占滿0.2G了,而且,YGC耗時和Survivor區的使用率基本成正相關,因此,我們推測:應該是長生命周期的物件越來越多,導致標注和復制程序的耗時增加,
再回到服務的整體表現:上游流量并沒有出現明顯變化,正常情況下,核心介面的回應時間也基本在200ms以內,YGC的頻率大概每8秒進行1次,
很顯然,對于區域變數來說,在每次YGC后就能夠馬上被回收了,那為什么還會有如此多的物件在YGC后存活下來呢?
我們進一步將懷疑物件鎖定在:程式的全域變數或者類靜態變數上,但是diff了本次上線的代碼,我們并未發現代碼中有引入此類變數,
對dump的堆記憶體檔案進行分析
代碼排查沒有進展后,我們開始從堆記憶體檔案中尋找線索,使用MAT工具匯入了第1步dump出來的堆檔案后,然后通過Dominator Tree視圖查看到了當前堆中的所有大物件,
立馬發現NewOldMappingService這個類所占的空間很大,通過代碼定位到:這個類位于第三方的client包中,由我們公司的商品團隊提供,用于實作新舊類目轉換(最近商品團隊在對類目體系進行改造,為了兼容舊業務,需要進行新舊類目映射),
進一步查看代碼,發現這個類中存在大量的靜態HashMap,用于快取新舊類目轉換時需要用到的各種資料,以減少RPC呼叫,提高轉換性能,
原本以為,非常接近問題的真相了,但是深入排查發現:這個類的所有靜態變數全部在類加載時就初始化完資料了,雖然會占到100多M的記憶體,但是之后基本不會再新增資料,并且,這個類早在3月份就上線使用了,client包的版本也一直沒變過,
經過上面種種分析,這個類的靜態HashMap會一直存活,經過多輪YGC后,最終晉升到老年代中,它不應該是YGC持續耗時過長的原因,因此,我們暫時排除了這個可疑點,
分析YGC處理Reference的耗時
團隊對于YGC問題的排查經驗很少,不知道再往下該如何分析了,基本掃光了網上可查到的所有案例,發現原因集中在這兩類上:
1、對存活物件標注時間過長:比如多載了Object類的Finalize方法,導致標注Final Reference耗時過長;或者String.intern方法使用不當,導致YGC掃描StringTable時間過長,
2、長周期物件積累過多:比如本地快取使用不當,積累了太多存活物件;或者鎖競爭嚴重導致執行緒阻塞,區域變數的生命周期變長,
針對第1類問題,可以通過以下引數顯示GC處理Reference的耗時-XX:+PrintReferenceGC,添加此引數后,可以看到不同型別的 reference 處理耗時都很短,因此又排除了此項因素,
再回到長周期物件進行分析
再往后,我們添加了各種GC引數試圖尋找線索都沒有結果,似乎要黔驢技窮,沒有思路了,綜合監控和種種分析來看:應該只有長周期物件才會引發我們這個問題,
折騰了好幾個小時,最終峰回路轉,一個小伙伴重新從MAT堆記憶體中找到了第二個懷疑點,
從上面的截圖可以看到:大物件中排在第3位的ConfigService類進入了我們的視野,該類的一個ArrayList變數中竟然包含了270W個物件,而且大部分都是相同的元素,
ConfigService這個類在第三方Apollo的包中,不過源代碼被公司架構部進行了二次改造,通過代碼可以看出:問題出在了第11行,每次呼叫getConfig方法時都會往List中添加元素,并且未做去重處理,
我們的廣告服務在apollo中存盤了大量的廣告策略配置,而且大部分請求都會呼叫ConfigService的getConfig方法來獲取配置,因此會不斷地往靜態變數namespaces中添加新物件,從而引發此問題,
至此,整個問題終于水落石出了,這個BUG是因為架構部在對apollo client包進行定制化開發時不小心引入的,很顯然沒有經過仔細測驗,并且剛好在我們上線前一天發布到了中央倉庫中,而公司基礎組件庫的版本是通過super-pom方式統一維護的,業務無感知,
解決方案
為了快速驗證YGC耗時過長是因為此問題導致的,我們在一臺服務器上直接用舊版本的apollo client 包進行了替換,然后重啟了服務,觀察了將近20分鐘,YGC恢復正常,
最后,我們通知架構部修復BUG,重新發布了super-pom,徹底解決了這個問題,
02 YGC的相關知識點總結
通過上面這個案例,可以看到YGC問題其實比較難排查,相比FGC或者OOM,YGC的日志很簡單,只知道新生代記憶體的變化和耗時,同時dump出來的堆記憶體必須要仔細排查才行,
另外,如果不清楚YGC的流程,排查起來會更加困難,這里,我對YGC相關的知識點再做下梳理,方便大家更全面的理解YGC,
YGC的相關知識點總結
5個問題重新認識新生代
YGC 在新生代中進行,首先要清楚新生代的堆結構劃分,新生代分為Eden區和兩個Survivor區,其中Eden:from:to = 8:1:1 (比例可以通過引數 –XX:SurvivorRatio 來設定 ),這是最基本的認識,
為什么會有新生代?
如果不分代,所有物件全部在一個區域,每次GC都需要對全堆進行掃描,存在效率問題,分代后,可分別控制回收頻率,并采用不同的回收演算法,確保GC性能全域最優,
為什么新生代會采用復制演算法?
新生代的物件朝生夕死,大約90%的新建物件可以被很快回收,復制演算法成本低,同時還能保證空間沒有碎片,雖然標記整理演算法也可以保證沒有碎片,但是由于新生代要清理的物件數量很大,將存活的物件整理到待清理物件之前,需要大量的移動操作,時間復雜度比復制演算法高,
為什么新生代需要兩個Survivor區?
為了節省空間考慮,如果采用傳統的復制演算法,只有一個Survivor區,則Survivor區大小需要等于Eden區大小,此時空間消耗是8 * 2,而兩塊Survivor可以保持新物件始終在Eden區創建,存活物件在Survivor之間轉移即可,空間消耗是8+1+1,明顯后者的空間利用率更高,
新生代的實際可用空間是多少?
YGC后,總有一塊Survivor區是空閑的,因此新生代的可用記憶體空間是90%,在YGC的log中或者通過 jmap -heap pid 命令查看新生代的空間時,如果發現capacity只有90%,不要覺得奇怪,
Eden區是如何加速記憶體分配的?
HotSpot虛擬機使用了兩種技術來加快記憶體分配,分別是bump-the-pointer和TLAB(Thread Local Allocation Buffers),
由于Eden區是連續的,因此bump-the-pointer在物件創建時,只需要檢查最后一個物件后面是否有足夠的記憶體即可,從而加快記憶體分配速度,
TLAB技術是對于多執行緒而言的,在Eden中為每個執行緒分配一塊區域,減少記憶體分配時的鎖沖突,加快記憶體分配速度,提升吞吐量,
新生代的4種回收器
SerialGC(串行回收器),最古老的一種,單執行緒執行,適合單CPU場景,
ParNew(并行回收器),將串行回收器多執行緒化,適合多CPU場景,需要搭配老年代CMS回收器一起使用,
ParallelGC(并行回收器),和ParNew不同點在于它關注吞吐量,可設定期望的停頓時間,它在作業時會自動調整堆大小和其他引數,
G1(Garage-First回收器),JDK 9及以后版本的默認回收器,兼顧新生代和老年代,將堆拆成一系列Region,不要求記憶體塊連續,新生代仍然是并行收集,
上述回收器均采用復制演算法,都是獨占式的,執行期間都會Stop The World.
YGC的觸發時機
當Eden區空間不足時,就會觸發YGC,結合新生代物件的記憶體分配看下詳細程序:
1、新物件會先嘗試在堆疊上分配,如果不行則嘗試在TLAB分配,否則再看是否滿足大物件條件要在老年代分配,最后才考慮在Eden區申請空間,
2、如果Eden區沒有合適的空間,則觸發YGC,
3、YGC時,對Eden區和From Survivor區的存活物件進行處理,如果滿足動態年齡判斷的條件或者To Survivor區空間不夠則直接進入老年代,如果老年代空間也不夠了,則會發生promotion failed,觸發老年代的回收,否則將存活物件復制到To Survivor區,
4、此時Eden區和From Survivor區的剩余物件均為垃圾物件,可直接抹掉回收,
此外,老年代如果采用的是CMS回收器,為了減少CMS Remark階段的耗時,也有可能會觸發一次YGC,這里不作展開,
YGC的執行程序
YGC采用的復制演算法,主要分成以下兩個步驟:
1、查找GC Roots,將其參考的物件拷貝到S1區
2、遞回遍歷第1步的物件,拷貝其參考的物件到S1區或者晉升到Old區
上述整個程序都是需要暫停業務執行緒的(STW),不過ParNew等新生代回收器可以多執行緒并行執行,提高處理效率,
YGC通過可達性分析演算法,從GC Root(可達物件的起點)開始向下搜索,標記出當前存活的物件,那么剩下未被標記的物件就是需要回收的物件,
可作為YGC時GC Root的物件包括以下幾種:
1、虛擬機堆疊中參考的物件
2、方法區中靜態屬性、常量參考的物件
3、本地方法堆疊中參考的物件
4、被Synchronized鎖持有的物件
5、記錄當前被加載類的SystemDictionary
6、記錄字串常量參考的StringTable
7、存在跨代參考的物件
8、和GC Root處于同一CardTable的物件
其中1-3是大家容易想到的,而4-8很容易被忽視,卻極有可能是分析YGC問題時的線索入口,
另外需要注意的是,針對下圖中跨代參考的情況,老年代的物件A也必須作為GC Root的一部分,但是如果每次YGC時都去掃描老年代,肯定存在效率問題,在HotSpot JVM,引入卡表(Card Table)來對跨代參考的標記進行加速,
Card Table,簡單理解是一種空間換時間的思路,因為存在跨代參考的物件大概占比不到1%,因此可將堆空間劃分成大小為512位元組的卡頁,如果卡頁中有一個物件存在跨代參考,則可以用1個位元組來標識該卡頁是dirty狀態,卡頁狀態進一步通過寫屏障技術進行維護,
遍歷完GC Roots后,便能夠找出第一批存活的物件,然后將其拷貝到S1區,接下來,就是一個遞回查找和拷貝存活物件的程序,
S1區為了方便維護記憶體區域,引入了兩個指標變數:_saved_mark_word和_top,其中_saved_mark_word表示當前遍歷物件的位置,_top表示當前可分配記憶體的位置,很顯然,_saved_mark_word到_top之間的物件都是已拷貝但未掃描的物件,
貝到S1區,_top也會往前移動,直到_saved_mark_word追上_top,說明S1區所有物件都已經遍歷完成,
有一個細節點需要注意的是:拷貝物件的目標空間不一定是S1區,也可能是老年代,如果一個物件的年齡(經歷的YGC次數)滿足動態年齡判定條件便直接晉升到老年代中,物件的年齡保存在Java物件頭的mark word資料結構中(如果大家對Java并發鎖熟悉,肯定了解這個資料結構,不熟悉的建議查閱資料了解下,這里不做展開),
最后的話
這篇文章通過線上案例分析并結合原理講解,詳細介紹了YGC的相關知識,從YGC實戰角度出發,再簡單總結一下:
1、首先要清楚YGC的執行原理,比如年輕代的堆記憶體結構、Eden區的記憶體分配機制、GC Roots掃描、物件拷貝程序等,
2、YGC的核心步驟是標注和復制,絕部分YGC問題都集中在這兩步,因此可以結合YGC日志和堆記憶體變化情況逐一排查,同時dump的堆記憶體檔案需要仔細分析,
看完三件事??
如果你覺得這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:
-
點贊,轉發,有你們的 『點贊和評論』,才是我創造的動力,
-
關注公眾號 『 java爛豬皮 』,不定期分享原創知識,
-
同時可以期待后續文章ing??
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/135907.html
標籤:其他
