某天下午正在專心寫代碼,釘釘告警羣突然瘋狂發送FullGC告警信息,筆者趕緊登陸對應機器進行排查,執行如下命令,

PID=$(jps -lv | grep xx)
jstat -gccause 2000 ${PID}

顯示如下:

很明顯,FullGC的原因是Perment Generation space已滿,執行:

jmap -heap ${PID}

輸出如下:

Perment Generation已經使用95%。

當前使用的jdk版本:Java1.6.0_29-b11JVM參數如下:

永久代最大內存為96M,這個區域存儲了class位元組碼以及一些常量信息,要溢出除非是以下幾種情況:

  1. 該區域設置過小,根本無法裝載應用的所需的所有class
  2. 應用大量動態生成class,如頻繁編譯jsp或使用動態代理生成許多proxy
  3. 應用自定義classloader,頻繁load class
  4. 應用大量生成字元串,並調用string.intern()

出問題的服務一個class平均大小在10k左右,96M可以載入9800多個class,JVM6在初始化是會載入2000多個類,應用本身只有99個class

#遞歸統計文件數
ls -lR | grep "^-" |wc -l

加上引用的類在4000個左右,再加上一些字元串常量,大約在60M左右,因此不可能是第一個原因,該服務是基於Spring MVC的純後臺服務,只有一個jsp管理頁面,不管是Spring的AOP還是其他一些動態代理,都是在程序啟動就生成好了的,因此可排除第二個原因,剩下3, 4,在應用的代碼層面,沒有顯示調string.intern(),也沒有顯示自定義classloader,但無法排除引用的代碼裏是否有,jmap 列印的信息看,per區的確是佔用99%,究竟是什麼數據消耗內存呢,jvm既然有命令可以看各個區的消耗,應該有命令可以查看永久帶的信息

jmap --help
-permstat to print permanent generation statistics #此參數可以查看永久帶的使用情況

執行該命令,首先列印的是字元串佔用的空間

20658 intern Strings occupying 2174792bytes

接下輸出滿屏的groovy/lang/GroovyClassLoader,該GroovyClassLoader只有3個instance,卻有4800多條紀錄,也就是說這些相同的classloader在不斷load class到虛擬機,直到per區消耗完。直覺告訴我,肯定是某個代碼使用static方式引用了GroovyClassLoader,並不斷觸發該類load class,疑點已經找到,為了不影響線上業務,先重啟服務,接下來去代碼裏搜關鍵字:groovy,沒有結果,正準備通過maven列印依賴關係【mvndependency:tree】查找線索時,突然想到有個工具servlet裏使用groovy動態執行一些java代碼,groovy是將腳本動態編譯後load到虛擬機執行的,自然會產生許多class,問題應該就是這裡了,梳理下整個事情來龍去脈:

  1. 代碼裏創建了一個static GroovyShell ,GroovyShell持有GroovyClassLoader
  2. 每次傳一些groovy代碼過來,調用GroovyShell.eval(xxx)執行
  3. GroovyShell動態編譯腳本,再調用GroovyClassLoader load到虛擬機執行。
  4. 當腳本執行完,此前動態生成class就沒有什麼用了,但位元組碼還駐留在per區,並且不會被卸載,隨著eval調用次數增多, Per區內存就一點點的被消耗完。

為什麼這些無用的位元組碼沒被JVM回收呢,這得從class卸載機制說起,JVM的Per區沒有單獨的Garbage collector,這個區域只是在老年代FullGC時順帶回收的,一個class只有在滿足以下條件時,才能被JVM 卸載

  1. 該類所有的實例已經被回收
  2. 該類的ClassLoder已經被回收
  3. 該類對應的Java.lang.Class對象沒有任何對方被引用

上述代碼中,因為GroovyClassLoader被GroovyShell引用,而GroovyShell被應用代碼static引用,整個應用運行期間,該引用鏈一直都在,無法滿足條件2,所以即使我們腳本已經執行完,但動態生成的位元組碼還會一直駐留JVM直到內存溢出。因為java並沒有提供卸載class的介面,所以我們只能想辦法滿足上述3個條件,讓JVM在必要時卸載class,解決思路就是要打破上述引用鏈,方案如下:

  1. 將GroovyShell由static改為局部變數
  2. 將GroovyShell放到WeakReference裏,既能避免重複創建,又支持JVM卸載class

由於問題代碼只是一個運維型工具類,時間上不是很敏感,直接採用第一種方案。在本地測試,以下代碼執行循環到6000多次時Per區內存溢出,用JConsole觀察,載入的類直線上升,JVM加:XX:+TraceClassLoading -XX:+TraceClassUnloading,控制檯只列印load lcass,不見unload class日誌,信息如下:

而改成局部變數後,控制檯出現unload class日誌,程序一直運行直到完成,再無per區溢出。

許多運行在JVM上的腳本語言為我們帶來很多便利,如在不重啟服務的情況下,修改服務的運行數據,清空緩存等,但若使用不當,則會帶來致命BUG,使用時應小心謹慎。在java項目中,不管是從工程的可維護性還是運行性能來講,都不建議大量使用腳本。

Note:Java8去掉Per gen,改為metaspace,並且把stirng常量也挪到heap裏,metaspace採用直接內存,會自動調節大小,該區域大小隻受限於物理內存,因此不會再有PerGen溢出問題,

擴展閱讀:jmap命令詳解 JVM老年代 jstat命令 GC cause參數解析


推薦閱讀:
相關文章