JVM元數據區的內存泄漏之謎

一、問題描述

某天,SRE部門觀察到一個非常重要的應用裏面有一臺服務器的業務處理時間(Transaction time)在某個時間點變爲平時的3倍。雖然只持續了短暫的2秒,但是如果觀察其一週的指標曲線,就會發現在這一週之內,同應用的其它服務器也出現過類似現象。

進一步分析,發現該應用服務器在那個時間點之後立馬被重啓了。從公司的PaaS平臺上看,並沒有發現人工或者自動化運維機器人引起重啓。看來這是一次非正常重啓

如圖1所示,從JVM的系統日誌中,我們發現了重啓的原因: 無法申請到更多的原生內存,也就是說:內存被用光了

JVM 系統日誌:

圖1(點擊可查看大圖)

內存被用光之後,JVM發生了OOM(Out of memory,內存溢出),系統配置的自動重啓服務就把這個應用重啓了。

二、初步分析

從上面的日誌中,雖然可以明顯看到是原生內存被耗光,卻不能找出更明確的耗光原因。在同應用的其他服務器上,如果觀察內存的使用情況,能明顯看到空閒內存逐漸變少。

從下圖2可以看到,在一個擁有8G內存的服務器上,只空閒了288M。其中,JVM的年輕代佔用260M左右,老年代佔用1.3G左右,而元數據區竟然使用了2G多的內存,這在一般的Java應用程序裏面很少見

JVM內存佔用情況及系統內存空閒情況:

圖2(點擊查看大圖)

所以我們鎖定了可能出問題的方向:元數據區

爲了查明是何原因導致佔據如此之多的內存,SRE偵探們請出了好幫手:heap dump分析

我們通過jcmd命令做了一個heap dump。遺憾的是,似乎沒有從中發現什麼有用的信息。

但是細心的SRE偵探不會放過任何蛛絲馬跡。

三、進一步分析

我們注意到這個heap dump其實非常小,只有285M左右。從上述JVM的內存佔用信息來看,這個heap dump的大小實在是不太匹配

一個可能的原因是: 有些內存在做heap dump的過程中被釋放了。因爲使用jcmd默認參數做heap dump之前會做一次Full GC,然後內存就基本只剩下活着的對象了。Full GC也會對元數據區裏面的內存做回收,所以很有可能一些原本佔用元數據區內存的數據被回收了。

我們對另外一臺服務器使用帶-all參數的jcmd命令(jcmd <pid> GC.heap_dump -all), 進行了一次不做Full GC的heap dump。如下圖3所示,這次的heap dump相對大很多。

兩次heap dump文件:

圖3(點擊可查看大圖)

由於元數據區和永久代裏面大部分都是與Class相關的元數據信息,加之所有的類都是由各種ClassLoader加載的,所以我們首先分析ClassLoader。

我們使用MAT對heap裏面的ClassLoader進行分析。運用MAT的ClassLoader Explorer工具後,發現這份heap dump裏面,竟然有17萬多的ClassLoader。具體如圖4所示:

使用MAT的Class Loader Explorer分析:

圖4(點擊可查看大圖)

其中非常可疑的就是:

如圖5所示,通過OQL查詢可以看到,這個ClassLoader非常之多(17萬+),並且很多都是Unreachable(不可達),也就是說如果這時候發生一次Full GC,它們將會被回收掉。

使用OQL查詢特定的ClassLoader:

圖5(點擊可查看大圖)

那麼接下來的問題就是:

爲什麼會產生這麼多的TransletClassLoader呢?

對這些TransletClassLoader以及相關聯的類/實例進行分析,並沒有發現有用的線索。

即使對上面沒有標明Unreachable的TransletClassLoader進行分析,也會發現它關聯的其他類/實例很快也變成了Unreachable。從這個方向進行追蹤,很難發現到底是哪一步創建了這些TransletClassLoader。

不過,在JVM因爲內存溢出而退出的時候,它生成了一個hs_err_pid21618.log日誌文件,該文件詳細記錄了系統崩潰時間點的所有Java線程棧和native線程棧,以及當時操作系統的一些其他信息

導致系統崩潰的線程棧,就在所有線程棧的最上方,可以告訴我們當時這個線程正在做什麼。如下圖6所示:

JVM崩潰時產生的hs_err_pid文件片段:

圖6(點擊可查看大圖)

線程棧的最底層是一些native代碼,從中我們可以看出這正是爲元數據區申請內存的線程。下面的Java線程棧告訴了我們這個請求是從哪裏一步步進入到某個方法。其中正有我們上面查到的:

儘管這和heap dump分析中的一致,但並不能代表heap dump中的那些都是該類請求造成的,不過也確實給了我們一些提示。

接下來,我們可以根據這個線程棧給的提示,通過重現的方式去驗證猜想

四、場景重現

根據上面線程棧提供的信息,我們找出了對應的請求。根據它運行的路徑,我們找出了一種特定的請求及其參數和請求數據。同時我們把相關的應用代碼在本地以調試(debug)模式啓動。在TemplatesImpl$TransletClassLoader.defineClass()方法上添加斷點,發現該類請求每次都創建一個新的ClassLoader,並且這個ClassLoader還會創建新的Class

除此之外,我們還對調試服務器大量發出這種特殊請求, 用來觀察 JVM 內存各個區域的使用情況.

如圖7所示,通過jcmd<pid>GC.heap_info命令去查看其元數據空間的使用情況,能明顯觀察到它在慢慢增長。如果中間強制做一次Full GC,能看到元數據空間被大量釋放。

觀察短時間內元數據空間的變化及Full GC後空間的變化:

圖7(點擊可查看大圖)

五、代碼分析

對業務邏輯代碼分析發現,當這種特定的請求進來之後,處理過程中會每次創建一個ClassLoader,而這個ClassLoader在僅僅創建一個相關的類之後,就永遠不會被使用。久而久之,這個ClassLoader及相關的Class會越來越多。而元數據區默認使用操作系統所有可用內存,直到內存完全被耗盡。

六、解決方案

01 臨時方案

通過設置JVM參數“-XX:MaxMetaspaceSize =***M”,可以在元數據區快要到達這個大小的時候,讓JVM去做Full GC來回收元數據,這樣就不會導致OOM。這也是爲什麼相同的代碼在使用Java 7的應用裏沒有發生該問題的原因。因爲Java 7使用的是PermGen(永久代),必須設置MaxPermSize來限定它的最大值。但缺點是該臨時方案中的Full GC會導致較長時間的業務停頓。

02 長期方案

每次都創建一個ClassLoader的代碼是不正確的,通過重用,讓所有的請求共用一份ClassLoader

七、總結

從Java 8開始的元數據區默認是沒有最大空間限制的,這是因爲在Java代碼穩定運行前,很難確定需要加載多少類,因此也就很難確定元數據區的大小。

正因爲沒有設置最大值,所以會有耗盡內存的潛在可能。當應用程序大概知道需要使用多少元數據的時候,爲了讓元數據區內存保持在合理的大小範圍之內,不至於耗盡所有可用內存,可以設置以下參數:

同時,如果有自定義的ClassLoader,一定要注意是否會創建N個實例,從而造成元數據空間的不斷消耗。

本文轉載自公衆號eBay技術薈(ID:eBayTechRecruiting)

原文鏈接

https://mp.weixin.qq.com/s/h8ayDuk7SSQ-0-jNmGmydw

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章