天天看點

JVM源碼分析之謹防JDK8重複類定義造成的記憶體洩漏

如今jdk8成了主流,大家都緊鑼密鼓地進行着更新,享受着jdk8帶來的各種便利,然而有時候更新并沒有那麼順利?比如說今天要說的這個問題。我們都知道jdk8在記憶體模型上最大的改變是,放棄了perm,迎來了metaspace的時代。如果你對metaspace還不熟,之前我寫過一篇介紹metaspace的文章,大家有興趣的可以看看我前面的那篇文章。

我們之前一般在系統的jvm參數上都加了類似<code>-xx:permsize=256m -xx:maxpermsize=256m</code>的參數,更新到jdk8之後,因為perm已經沒了,如果還有這些參數jvm會抛出一些警告資訊,于是我們會将參數進行更新,比如直接将<code>permsize</code>改成<code>metaspacesize</code>,<code>maxpermsize</code>改成<code>maxmetaspacesize</code>,但是我們後面會發現一個問題,經常會看到<code>metaspace</code>的<code>outofmemory</code>異常或者gc日志裡提示<code>metaspace</code>導緻的<code>full gc</code>,此時我們不得不将<code>maxmetaspacesize</code>以及<code>metaspacesize</code>調大到512m或者更大,幸運的話,發現問題解決了,後面沒再出現oom,但是有時候也會很不幸,仍然會出現oom。此時大家是不是非常疑惑了,代碼完全沒有變化,但是加載類貌似需要更多的記憶體?

之前我其實并沒有仔細去想這個問題,碰到這類oom的問題,都覺得主要是metaspace記憶體碎片的問題,因為之前幫人解決過類似的問題,他們建構了成千上萬個類加載器,确實也是因為metsapce碎片的問題導緻的,因為metaspace并不會做壓縮,解決的方案主要是調大<code>metaspacesize</code>和<code>maxmetaspacesize</code>,并将它們設定相等。然後這次碰到的問題并不是這樣,類加載個數并不多,然而卻抛出了metaspace的outofmemory異常,并且full gc一直持續着,而且從jstat來看,metaspace的gc前後使用情況基本不變,也就是gc前後基本沒有回收什麼記憶體。

通過我們的記憶體分析工具看到的現象是同一個類加載器居然加載了同一個類多遍,記憶體裡有多份類執行個體,這個我們可以通過加上<code>-verbose:class</code>的參數也能得到驗證,要輸出如下日志,那隻有在不斷定義某個類才會輸出,于是想建構出這種場景來,于是簡單地寫了個demo來驗證

代碼很簡單,就是通過反射直接調用classloader的defineclass方法來對某個類做重複的定義。

其中在jdk7下跑的jvm參數設定的是:

在jdk8下跑的jvm參數是:

大家可以通過<code>jstat -gcutil &lt;pid&gt; 1000</code>看看jdk7和jdk8下有什麼不一樣,結果你會發現jdk7下perm的使用率随着fgc的進行gc前後不斷發生着變化,而metsapce的使用率到一定階段之後gc前後卻一直沒有變化

jdk7下的結果:

jdk8下的結果:

重複類定義,從上面的demo裡已經得到了證明,當我們多次調用classloader的defineclass方法的時候哪怕是同一個類加載器加載同一個類檔案,在jvm裡也會在對應的perm或者metaspace裡建立多份klass結構,當然一般情況下我們不會直接這麼調用,但是反射提供了這麼強大的能力,有些人還是會利用這種寫法,其實我想直接這麼用的人對類加載的實作機制真的沒有全弄明白,包括這次問題發生的場景其實還是吸納進jdk裡的jaxp/jaxws,比如它就存在這樣的代碼實作<code>com.sun.xml.bind.v2.runtime.reflect.opt.injector</code>裡的inject方法就存在直接調用的情況:

不過從2.2.2這個版本開始這種實作就改變了

是以大家如果還是使用<code>jaxb-impl-2.2.2</code>以下版本的請注意啦,更新到jdk8可能會存在本文說的問題。

那重複類定義會帶來什麼危害呢?正常的類加載都會先走一遍緩存查找,看是否已經有了對應的類,如果有了就直接傳回,如果沒有就進行定義,如果直接調用類定義的方法,在jvm裡會建立多份臨時的類結構執行個體,這些相關的結構是存在perm或者metaspace裡的,也就是說會消耗perm或metaspace的記憶體,但是這些類在定義出來之後,最終會做一次限制檢查,如果發現已經定義了,那就直接抛出linkageerror的異常

這樣這些臨時建立的結構,隻能等待gc的時候去回收掉了,因為它們不可達,是以在gc的時候會被回收,那問題來了,為什麼在perm下能正常回收,但是在metaspace裡不能正常回收呢?

這裡我主要拿我們目前最常用的gc算法cms gc舉例。

在jdk7 cms下,perm的結構其實和old的記憶體結構是一樣的,如果perm不夠的時候我們會做一次full gc,這個full gc預設情況下是會對各個分代做壓縮的,包括perm,這樣一來根據對象的可達性,任何一個類都隻會和一個活着的類加載器綁定,在标記階段将這些類标記成活的,并将他們進行新位址的計算及移動壓縮,而之前因為重複定義生成的類結構等,因為沒有将它們和任何一個活着的類加載器關聯(有個叫做systemdictionary的hashtable結構來記錄這種關聯),進而在壓縮過程中會被回收掉。

在jdk8下,metaspace是完全獨立分散的記憶體結構,由非連續的記憶體組合起來,在metaspace達到了觸發gc的門檻值的時候(和maxmetaspacesize及metaspacesize有關),就會做一次full gc,但是這次full gc,并不會對metaspace做壓縮,唯一解除安裝類的情況是,對應的類加載器必須是死的,如果類加載器都是活的,那肯定不會做解除安裝的事情了

從上面貼的代碼我們也能看出來,jdk7裡會對perm做壓縮,然後jdk8裡并不會對metaspace做壓縮,進而隻要和那些重複定義的類相關的類加載一直存活,那将一直不會被回收,但是如果類加載死了,那就會被回收,這是因為那些重複類都是在和這個類加載器關聯的記憶體塊裡配置設定的,如果這個類加載器死了,那整塊記憶體會被清理并被下次重用。

在沒看gc源碼的情況下,有什麼辦法來證明perm在fgc下的回收是因為壓縮而導緻那些重複類被回收呢?大家可以改改上面的測試用例,将最後那個死循環改一下:

在system.gc那裡設定個斷點,然後再通過<code>jstat -gcutil &lt;pid&gt; 1000</code>來看perm的使用率是否發生變化,另外你再加上<code>-xx:+ explicitgcinvokesconcurrent</code>再重複上面的動作,你看看輸出是怎樣的,為什麼這個可以證明,大家可以想一想,哈哈