又抓了一個導致頻繁GC的鬼--陣列動態擴容

PerfMa發表於2020-05-28

概述

本週有個同事過來諮詢一個比較詭異的gc問題,大概現象是,系統一直在做cms gc,但是老生代一直不降下去,但是執行一次jmap -histo:live之後,也就是主動觸發一次full gc之後,通過jstat -gcutil來看老生代一下就降下去了,初看下理論上不太可能,因為full gc也會對old做回收,於是我要同事針對他們的場景寫了一個簡單的demo出來,然後果然還真能重現,不過他的demo設定的Heap有32G,於是我通過慢慢調整,最終在很小的記憶體下也能重現出來

Demo

測試程式碼如下:
image.png
正如我上面註釋裡寫的JVM引數,控制新生代200M,老生代300M,老生代使用率達到90%的時候觸發CMS GC,大家可以跑跑看,這種情況下會發現不斷做CMS GC,但是老生代就是不降下去,但是隻要你主動觸發一次Full GC,老生代立馬就會回收。
當allocateMemory方法執行完之後,期待的結果是gc之後List及裡面的byte陣列都應該被回收掉,可是事實並不是這樣的

初步定位

這段程式碼非常簡單,我翻來覆去地看著這段程式碼,試圖想改變點什麼,能讓問題出現峰迴路轉,我不斷地控制for迴圈的次數和每次分配的記憶體大小,最終我將目標轉移到那個ArrayList上,List裡有個陣列,在add過程中如果發現陣列不夠了,於是會進行擴容,那擴容就是建立新的陣列,將老的物件放到新陣列裡,那我試想要是不做擴容會不會有問題?於是我開始調整ArrayList的初始化大小,當我調到一定大小,保證在add過程中不會做擴容,問題真出現了反轉,居然能正常回收了,比如上面的demo,將陣列長度設定為len,那結果就完全不一樣了,老生代很快就被回收了
那目標能鎖定到陣列擴容了

陣列擴容

ArrayList裡的陣列擴容,使用的是System.arrayCopy呼叫,這是一個native方法,在java層面建立一個新的長度的陣列,然後將老陣列和新陣列都傳進去,在native裡將老陣列裡的元素指標拷貝到新陣列裡,其實做的是淺拷貝,反覆看native這塊實現,也基本解釋不通那個現象,一度懷疑我對GC的理解了,是不是有哪些細節沒有注意到。
經過我記憶體dump分析,發現上面Demo裡的List物件確實被回收了,但是List裡的陣列沒有被回收,這個陣列裡的byte陣列都沒有被回收

原來是這個鬼

帶著百思不得其解的疑惑和我們組同事討論,看看還有沒有其他可能的沒考慮到疑惑點,開始也都覺得疑惑,後來傳勝突然想到會不會是存在跨代引用的問題,於是回過來仔細再想想每個步驟,好像還真有可能,因為傳給System.arrayCopy的新陣列是在java層面構建傳進來的,在新生代分配的可能性最大,這樣再加上拷貝僅僅是淺拷貝,那麼老生代裡的byte陣列因為存在新生代裡新陣列的引用,那僅僅做CMS GC就不可能回收這些老生代的物件了,因為CMS GC的一個gc root就是新生代裡的物件

那何解

至此終於抓出了那個鬼,於是想應對策略,既然這樣,只要保證在cms gc回收old之前做一次ygc就能保證新生代裡的那個新陣列被回收而沒有指向老生代那些byte陣列,那麼這些陣列就能正常被cms gc回收了,所以加上-XX:+CMSScavengeBeforeRemark即可解此問題。

 

一起來學習吧:

PerfMa KO 系列課之 JVM 引數【Memory篇】

實戰:OOM 後我如何分析解決的

相關文章