Categories
程式開發

頻繁操作本地緩存導致YGC耗時過長


某天,某位群友在JVM討論群裡發來一張GC log的圖片。

其中主要的問題是YGC過長,每次耗時約為200ms。

頻繁操作本地緩存導致YGC耗時過長 1

使用的JVM參數如下:

-Xmn2048m -Xms4096m -Xmx4096m -XX:+PrintGC -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=128M

指定年輕代內存為2g,初始JVM內存為4g,最大JVM內存為4g。

這個問題引起了群友們的關注。

從GC log和JVM參數可以看出,GC算法使用默認的Parallel Scanvenge。

可以看到Eden區大小為1536M,兩個Survivor區大小為256M。

得出-XX:SurvivorRatio = 6。

此外可以看到在GC時,desired survivor size 268435456 bytes = 256M,得出-XX:TargetSurvivorRatio = 100。

默認情況下,-XX:SurvivorRatio = 8,-XX:SurvivorRatio = 50。

然而並未設置這兩個參數,一直懷疑是配置沒有生效。

一時沒有想到辦法,有群友建議試著調整下MaxGCPauseMills 或者GCTimeRatio 參數,然後效果都不好。

之後的某天,嘗試使用jmap -heap pid打印應用的堆棧信息。

頻繁操作本地緩存導致YGC耗時過長 2

發現雖然寫著SurvivorRatio = 8,但是E:S0:S1的比例並非是8:1:1。

於是開始尋找原因。

找到來自R大的回答:

HotSpot VM裡,ParallelScavenge系的GC(UseParallelGC / UseParallelOldGC)默認行為是SurvivorRatio如果不顯式設置就沒啥用。顯式設置到跟默認值一樣的值則會有效果。

因為ParallelScavenge系的GC最初設計就是默認打開AdaptiveSizePolicy的,它會自動、自適應的調整各種參數。

於是推薦群友嘗試使用CMS,讓這些配置固定下來,不做自適應調整。但設置之後,發現YGC效果依舊不好。

頻繁操作本地緩存導致YGC耗時過長 3

顯示發生4次YGC,耗時1.145s,平均耗時約286ms,情況反而更糟,回頭再次分析GC log。發現日誌中有這麼一行:new threshold 7 (max 15)JVM中有個參數為晉升年齡閾值(-XX:MaxTenuringThreshold),默認值為15。意思為在YGC時,超過該年齡的對象會被晉升到老年代。但GC log中顯示該閾值被修改成了7。

在年輕代對象晉升時,有一個判斷條件如下:

動態年齡判斷,大於等於某個年齡的對象超過了survivor空間一半,大於等於某個年齡的對象直接進入老年代。

得出,在某次YGC時,Survivor區中,年齡超過7的對象佔用了Survivor空間一半以上。而正常情況下,年輕代對象朝生夕死。網絡服務處理請求為毫秒級,YGC幾秒甚至十幾秒才發生一次,多數年輕對象活不過1代。於是,猜測該群友使用了本地緩存。

在得到肯定的回復後,詳細詢問了群友使用本地緩存的方法。自行實現了一個本地緩存,類似於HashMap。別的服務會每一分鐘推送緩存數據用於同步。在同步的時候不做diff操作,直接put。

舉例:

緩存中保存Person類。

[email protected]
2class Person {
3
4 private String name;
5
6 private Integer age;
7}

緩存內容可能為:

1{
2 "jjs":{
3 "age":27,
4 "name":"jjs"
5 }
6}

緩存同步涉及兩種操作:新增和覆蓋。

兩種操作均直接使用put操作實現,無論當前緩存key是否已經存在。

這樣的操作方法在業務上完全沒有問題。

但對於GC而言,每次緩存同步需要new很多新的對象,並且這些對像都將一直存活,直到被覆蓋,或者晉升到老年代。

這些緩存對象首先會被分配到年輕代,在YGC時候,這些對像都會被標記為存活。

得到YGC耗時過長原因一:

年輕代中有太多存活的對象,增加了標記時間。

此外,HashMap是數組加鍊錶的結構,使用Node結構用於保存key、value。

HashMap的Node結構如下:

1static class Node implements Map.Entry {
2 final int hash;
3 final K key;
4 V value;
5 Node next;
6}

每次put生成的node節點,很可能(hash衝突)被掛在已有node節點的next域上。已有node為緩存,長期存活,很有可能位於老年代。

那麼,就形成了老年代對像對年輕代對象的引用。

在YGC中,需要掃描Card Table中的dirty區域來識別被老年代對象引用的年輕代對象。正常情況下,這種情形並不多。但在本文例子中,會大量存在。

得到YGC耗時過長原因二:

YGC又需要花費大量的時間在掃描Card Table上,總結原因是操作本地緩存太頻繁導致了YGC耗時過長。

回顧YGC的大致過程:

頻繁操作本地緩存導致YGC耗時過長 4

從根節點開始掃描年輕代對象,直到掃描到下個引用為非年輕代對象。 (可以避免YGC掃描整個堆。)

掃描老年代dirty區域,即可掃描到被老年代對象引用的年輕代對象。 (老年代被分為不同的塊,Card Table字節數組中每個字節表示老年代中的一塊。新分配對象時,觸發寫屏障,存在有老年代對象引用年輕代對象時,將對應的卡表設置為dirty。)

將Eden和From區中的對象複製到To區。如果To區已滿,則直接複製到老年代。

YGC耗時過長問題的排查還是應該從兩個點出發:

YGC時存活的年輕代對象太多。老年代對象引用年輕代對象的情況太多。

解決方案

修改代碼需要一定的時間,群友採用了一種短期的辦法。修改了推送的周期。原來每一分鐘推送一次。 YGC下降到18-25ms,但在緩存推送時,YGC時間仍然達到200ms。兩次緩存推送之間的對像都符合朝生夕死的弱分代假設,YGC時間正常。

後續修改思考/建議:

分批推送緩存,並且在接到推送的緩存時做diff操作,盡量修改已有對象而非新建對象。此舉可減少長壽對像生成。即使使用分批推送,在應用啟動時,還是需要全量加載緩存。仍舊會面臨應用剛啟動時,YGC耗時過長的問題。重新規劃應用。因為經常變化的數據並不適合放在緩存中。使用Redis緩存。 Redis的響應時間為毫秒級,甚至只需幾毫秒,並且無需考慮分佈式下緩存同步問題。使用CMS垃圾回收算法。因為默認和Parallel Scanvenge算法配合的老年代回收算法是Serial Old。該算法需要標記清理壓縮,STW時間較長。

看完三件事❤️

如果你覺得這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:

點贊,轉發,有你們的『點贊和評論』,才是我創造的動力。關注公眾號『 java爛豬皮 』,不定期分享原創知識。同時可以期待後續文章ing🚀

頻繁操作本地緩存導致YGC耗時過長 5

作者:阿菜出處:https://club.perfma.com/article/1578279