假笨說-謹防JDK8重複類定義造成的內存泄漏

概述
如今JDK8成了主流,大家都緊鑼密鼓地進行著升級,享受著JDK8帶來的各種便利,然而有時候升級並沒有那麼順利?比如說今天要說的這個問題。我們都知道JDK8在內存模型上最大的改變是,放棄了Perm,迎來了Metaspace的時代。如果你對Metaspace還不熟,之前我寫過一篇介紹Metaspace的文章,大家有興趣的可以看看我前麵的那篇文章。
我們之前一般在係統的JVM參數上都加了類似-XX:PermSize=256M -XX:MaxPermSize=256M
的參數,升級到JDK8之後,因為Perm已經沒了,如果還有這些參數JVM會拋出一些警告信息,於是我們會將參數進行升級,比如直接將PermSize
改成MetaspaceSize
,MaxPermSize
改成MaxMetaspaceSize
,但是我們後麵會發現一個問題,經常會看到Metaspace
的OutOfMemory
異常或者GC日誌裏提示Metaspace
導致的Full GC
,此時我們不得不將MaxMetaspaceSize
以及MetaspaceSize
調大到512M或者更大,幸運的話,發現問題解決了,後麵沒再出現OOM,但是有時候也會很不幸,仍然會出現OOM。此時大家是不是非常疑惑了,代碼完全沒有變化,但是加載類貌似需要更多的內存?
之前我其實並沒有仔細去想這個問題,碰到這類OOM的問題,都覺得主要是Metaspace內存碎片的問題,因為之前幫人解決過類似的問題,他們構建了成千上萬個類加載器,確實也是因為Metsapce碎片的問題導致的,因為Metaspace並不會做壓縮,解決的方案主要是調大MetaspaceSize
和MaxMetaspaceSize
,並將它們設置相等。然後這次碰到的問題並不是這樣,類加載個數並不多,然而卻拋出了Metaspace的OutOfMemory異常,並且Full GC一直持續著,而且從jstat來看,Metaspace的GC前後使用情況基本不變,也就是GC前後基本沒有回收什麼內存。
通過我們的內存分析工具看到的現象是同一個類加載器居然加載了同一個類多遍,內存裏有多份類實例,這個我們可以通過加上-verbose:class
的參數也能得到驗證,要輸出如下日誌,那隻有在不斷定義某個類才會輸出,於是想構建出這種場景來,於是簡單地寫了個demo來驗證
Demo
代碼很簡單,就是通過反射直接調用ClassLoader的defineClass方法來對某個類做重複的定義。
其中在JDK7下跑的JVM參數設置的是:
在JDK8下跑的JVM參數是:
大家可以通過jstat -gcutil <pid> 1000
看看JDK7和JDK8下有什麼不一樣,結果你會發現JDK7下Perm的使用率隨著FGC的進行GC前後不斷發生著變化,而Metsapce的使用率到一定階段之後GC前後卻一直沒有變化
JDK7下的結果:
JDK8下的結果:
重複類定義
重複類定義,從上麵的Demo裏已經得到了證明,當我們多次調用ClassLoader的defineClass方法的時候哪怕是同一個類加載器加載同一個類文件,在JVM裏也會在對應的Perm或者Metaspace裏創建多份Klass結構,當然一般情況下我們不會直接這麼調用,但是反射提供了這麼強大的能力,有些人還是會利用這種寫法,其實我想直接這麼用的人對類加載的實現機製真的沒有全弄明白,包括這次問題發生的場景其實還是吸納進JDK裏的jaxp/jaxws,比如它就存在這樣的代碼實現com.sun.xml.bind.v2.runtime.reflect.opt.Injector
裏的inject方法就存在直接調用的情況:
不過從2.2.2這個版本開始這種實現就改變了
所以大家如果還是使用jaxb-impl-2.2.2
以下版本的請注意啦,升級到JDK8可能會存在本文說的問題。
重複類定義帶來的影響
那重複類定義會帶來什麼危害呢?正常的類加載都會先走一遍緩存查找,看是否已經有了對應的類,如果有了就直接返回,如果沒有就進行定義,如果直接調用類定義的方法,在JVM裏會創建多份臨時的類結構實例,這些相關的結構是存在Perm或者Metaspace裏的,也就是說會消耗Perm或Metaspace的內存,但是這些類在定義出來之後,最終會做一次約束檢查,如果發現已經定義了,那就直接拋出LinkageError的異常
這樣這些臨時創建的結構,隻能等待GC的時候去回收掉了,因為它們不可達,所以在GC的時候會被回收,那問題來了,為什麼在Perm下能正常回收,但是在Metaspace裏不能正常回收呢?
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做壓縮,從而隻要和那些重複定義的類相關的類加載一直存活,那將一直不會被回收,但是如果類加載死了,那就會被回收,這是因為那些重複類都是在和這個類加載器關聯的內存塊裏分配的,如果這個類加載器死了,那整塊內存會被清理並被下次重用。
如何證明壓縮能回收Perm裏的重複類
在沒看GC源碼的情況下,有什麼辦法來證明Perm在FGC下的回收是因為壓縮而導致那些重複類被回收呢?大家可以改改上麵的測試用例,將最後那個死循環改一下:
在System.gc那裏設置個斷點,然後再通過jstat -gcutil <pid> 1000
來看Perm的使用率是否發生變化,另外你再加上-XX:+ ExplicitGCInvokesConcurrent
再重複上麵的動作,你看看輸出是怎樣的,為什麼這個可以證明,大家可以想一想,哈哈
最後更新:2017-04-11 19:32:01