揪出一個(gè)導(dǎo)致GC慢慢變長(zhǎng)的JVM設(shè)計(jì)缺陷
今天要給大家分享的內(nèi)容和 YGC(Young GC)有關(guān),是我最近碰到的一個(gè)案例,希望將排查思路分享給大家,如果大家后面碰到類似的問(wèn)題,可以直接作為一個(gè)經(jīng)驗(yàn)來(lái)排查。
我之前里寫過(guò)幾篇 YGC 的文章,也許其中有人已經(jīng)看過(guò)了,沒(méi)看過(guò)的可以去看看,那兩個(gè)坑在這里就不再描述,大家可以直接當(dāng)經(jīng)驗(yàn)使用。
Java 堆分為新生代和老生代,YGC 其實(shí)就是針對(duì)新生代的垃圾回收,對(duì)象都是優(yōu)先在新生代分配的,因此當(dāng)新生代內(nèi)存不夠分配的時(shí)候就會(huì)觸發(fā)垃圾回收,正常情況下可能觸發(fā)一次 YGC 就可以解決問(wèn)題并正常分配的,當(dāng)然也有極端情況可能要進(jìn)行大掃除,對(duì)整個(gè)堆進(jìn)行回收,也就是我們說(shuō)的 Full GC,這種情況發(fā)生就會(huì)比較悲劇了。
這里再提一下,YGC 也是會(huì) STW(stop the world) 的,也就是會(huì)暫停整個(gè)應(yīng)用,不要覺(jué)得 YGC 發(fā)生頻繁不是問(wèn)題。
說(shuō)實(shí)話我比較不喜歡排查 YGC 的問(wèn)題,因?yàn)?YGC 的日志太簡(jiǎn)單了,正常情況下只能知道新生代內(nèi)存從多少變到了多少,花了多長(zhǎng)時(shí)間,再無(wú)其它信息了。
所以當(dāng)有人來(lái)咨詢?yōu)槭裁次业某绦?YGC 越來(lái)越長(zhǎng)的問(wèn)題的時(shí)候,我其實(shí)是抗拒的,不過(guò)也無(wú)奈,總得嘗試去幫人家解決,包括前面說(shuō)的那兩個(gè)問(wèn)題,也是費(fèi)了不少精力查出來(lái)的,希望大家珍惜。。。
有些時(shí)候你越想逃避,偏偏就會(huì)找上你,YGC 的問(wèn)題最近說(shuō)實(shí)話找我的挺多的,不過(guò)有好些都是踩過(guò)的坑,所以能順利幫人家解決,但是今天要說(shuō)的這個(gè)問(wèn)題是之前從未碰到過(guò)的,是一個(gè)全新的問(wèn)題,所以也費(fèi)了我不少精力,也因?yàn)槠渌麊?wèn)題要查被拖延了幾天。
這個(gè)問(wèn)題最終排查下來(lái)其實(shí)是 JVM 本身設(shè)計(jì)上面的一個(gè)缺陷,我改天也會(huì)提到 openjdk 社區(qū)去和大家一起討論下這個(gè)設(shè)計(jì),希望能徹底***這個(gè)問(wèn)題。
這個(gè)問(wèn)題現(xiàn)象也很明顯,就是發(fā)現(xiàn) YGC 的時(shí)間越來(lái)越長(zhǎng),從 20ms 慢慢增加到100ms+,甚至還一直在漲。
不過(guò)這個(gè)增長(zhǎng)過(guò)程還是挺緩慢的,其實(shí) YGC 時(shí)間在幾十毫秒我個(gè)人認(rèn)為算正?,F(xiàn)象,沒(méi)必要去深究,再說(shuō)了還是經(jīng)過(guò)壓測(cè)了一個(gè)晚上才漲上來(lái)的,所以平時(shí)應(yīng)該也不是啥問(wèn)題吧,不過(guò)這次正巧趕上年中大促,所以大家對(duì)時(shí)間也比較敏感,便接手來(lái)排查這個(gè)案例了。
首先排除了之前碰到的幾種情況,然后我要同事加了一個(gè)我們 alijdk 特定的參數(shù),可以打印 YGC 過(guò)程里具體各個(gè)階段的耗時(shí)情況,可惜的是并沒(méi)有找出問(wèn)題,因?yàn)槲覀兟┑袅艘恍c(diǎn),導(dǎo)致沒(méi)有直接定位出來(lái)。
于是我懷疑那些沒(méi)跟蹤到的邏輯,首先懷疑的就是引用這塊的處理,所以叫同事加上了 -XX:+PrintReferenceGC 這個(gè)參數(shù),這個(gè)參數(shù)會(huì)打印各種引用的處理時(shí)間,大概如下:
點(diǎn)擊下面圖片進(jìn)入小程序查看PrintReferenceGC參數(shù)詳情
從當(dāng)時(shí)的那個(gè)日志里,我發(fā)現(xiàn)了一個(gè)現(xiàn)象,就是隨著 YGC 時(shí)間的增長(zhǎng),JNI Weak Reference 的處理耗時(shí)也在不斷增長(zhǎng),所以基本就定位到了 YGC 增長(zhǎng)的直接原因。
JNI Weak Reference 到底是什么呢?大家都知道 Java 層面有各種引用,包括 SoftReference,WeakReference 等,其中 WeakReference 可以保證在 GC 的時(shí)候不會(huì)阻礙其引用對(duì)象的回收,同樣的在 native 代碼里,我們也可以做類似的事情,有個(gè)叫做 JNIHandles::make_weak_global 的方法來(lái)達(dá)到這樣的效果。
于是我開(kāi)始修改 JVM,嘗試打印一些信息出來(lái),不知道大家注意過(guò),我們 dump 線程的時(shí)候,使用 jstack 命令,***一條輸出里會(huì)看到類似 JNI global references: 328 的日志,這里其實(shí)就是打印了 JNI 里的兩種全局引用總數(shù),分別是 _global_handles 和 _weak_global_handles。
于是嘗試將這兩種情況分開(kāi)來(lái),看具體哪種有多少個(gè),于是改了***個(gè)版本,從修改之后的輸出來(lái)看,_global_handles 的引用個(gè)數(shù)基本穩(wěn)定不變,但是 _weak_global_handles 的變化卻比較明顯。
至此也算佐證了 JNI Weak Reference的問(wèn)題,于是我想再次修改 JVM,打印了這些 JNI Weak Reference 引用的具體對(duì)象是什么對(duì)象。
在每次我執(zhí)行 jstack 時(shí),就會(huì)順帶把那些對(duì)象都打印出來(lái),當(dāng)然那個(gè)時(shí)候是為了性能,畢竟程序還跑在線上,不敢動(dòng)太大,比如要是大量輸出日志不可控,那就麻煩了,所以就借助 jstack 來(lái)手動(dòng)觸發(fā)這個(gè)邏輯。
從輸出來(lái)看,看到了大量的下面的內(nèi)容:
于是詢問(wèn)同事是不是存在大量的 Java 對(duì) JavaScript 的調(diào)用,被告知確實(shí)有使用,那問(wèn)題點(diǎn)基本算定位到了,我馬上要同事針對(duì)他們的用法寫一個(gè)簡(jiǎn)單的 demo 出來(lái)復(fù)現(xiàn)下問(wèn)題。
沒(méi)想到很快就寫好,而且真的很容易復(fù)現(xiàn),大概邏輯如下:
于是我開(kāi)始 debug,最終確認(rèn)和上面的 demo 完全等價(jià)于下面的 demo。
所以大家直接運(yùn)行上面的 demo 就能復(fù)現(xiàn)問(wèn)題,JVM 參數(shù)如下:
- -Xmx300M -Xms300M -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintReferenceGC
對(duì)了,運(yùn)行平臺(tái)是 JDK 8,JDK 6 是不存在這個(gè)問(wèn)題的,因?yàn)?invokedynamic 指令以及 nashorn 是在 JDK 6 里不存在的。
上面的 demo 看起來(lái)是不是沒(méi)毛病,但是卻真的會(huì)讓你的 GC 越來(lái)越慢,通過(guò)對(duì) JVM 進(jìn)行 debug 的方式抓出了下面的類似堆棧。
在 JDK 層面的棧如下:
最上面的 resolve 方法是一個(gè) native 方法,這個(gè)方法發(fā)現(xiàn)可以直接調(diào)用到上面提到的 JNIHandles::make_weak_global 方法。
JNIHandles::make_weak_global 方法其實(shí)就是創(chuàng)建了一個(gè) JNI Weak Reference。
在這里我要稍微描述下了,因?yàn)樘爆嵕筒粶?zhǔn)備貼代碼了。
JVM 里有個(gè)數(shù)據(jù)結(jié)構(gòu)叫做 JNIHandleBlock,之前提到了 global_handles 和 _weak_global_handles,其實(shí)他們都是一個(gè) JNIHandleBlock 鏈表。
可以想象下里面有個(gè) next 字段鏈到下一個(gè) JNIHandleBlock,同時(shí)里面還有一個(gè)數(shù)組 _handle[],長(zhǎng)度是 32,當(dāng)我們要分配一個(gè) JNI Weak Reference 的時(shí)候,就相當(dāng)于在這個(gè) JNIHandleBlock 鏈表里找一個(gè)空閑的位置(就是那些 _handle 數(shù)組),如果發(fā)現(xiàn)每個(gè) JNIHandleBlock 的 _handle 數(shù)組都滿了,就會(huì)創(chuàng)建一個(gè)新的 JNIHandleBlock,然后加到鏈里,注意這個(gè)鏈可以***長(zhǎng),所以問(wèn)題就來(lái)了,假如我們上層代碼不斷觸發(fā)底層調(diào)用 JNIHandles::make_weak_global 來(lái)創(chuàng)建一個(gè) JNI Weak Reference,那是不是意味著這個(gè) JNIHandleBlock 鏈會(huì)不斷增長(zhǎng),那會(huì)不會(huì)無(wú)窮增長(zhǎng)呢,答案是肯定的,既然有創(chuàng)建 JNI Weak Reference 的 API,是不是也存在銷毀 JNI Weak Reference 的 API?
當(dāng)然是存在的,可以看到有 JNIHandles::destroy_weak_global 方法,這個(gè)實(shí)現(xiàn)其實(shí)很簡(jiǎn)單,就是相當(dāng)于設(shè)計(jì)一個(gè)標(biāo)記,表示這個(gè)數(shù)組里的這個(gè)位置是可以重用的了,在 GC 發(fā)生的時(shí)候,如果發(fā)現(xiàn)這個(gè)坑被標(biāo)記了,于是就將這個(gè)坑加入到一個(gè) free_list 里,當(dāng)我們下面再想要分配一個(gè) JNI Weak Reference 的時(shí)候,就可以有機(jī)會(huì)從 free_list 里去分配一個(gè)重用了。
但是這個(gè) api 是在什么情況下才能調(diào)用的呢,其實(shí)只有在類卸載的時(shí)候才會(huì)去調(diào)用這個(gè) api,那到底是什么類被卸載了,那就是調(diào)用了 MethodHandles.lookup() 這個(gè)方法的那個(gè)類,從我們上面的 demo 來(lái)看,就是 MHTest 這個(gè)主類本身,從同事給我的 demo 來(lái)看,其實(shí)是 jdk.nashorn.internal.runtime.Context 這個(gè)類,但是這個(gè)類其實(shí)是被 ext_classloader 加載的,也就是說(shuō)這個(gè)類根本就不會(huì)被卸載,不能卸載那問(wèn)題就嚴(yán)重了,意味著 GC 發(fā)生的時(shí)候并不能將那些引用對(duì)象已經(jīng)死掉的坑置空,這樣在我們需要再次分配 JNI Weak Reference 的時(shí)候,沒(méi)有機(jī)會(huì)來(lái)重用那些坑,最終的結(jié)果就是不斷地創(chuàng)建新的 JNIHandleBlock 加到鏈表里,導(dǎo)致鏈表越來(lái)越長(zhǎng),然而 GC 的時(shí)候是會(huì)去不斷掃描這個(gè)鏈表的,因此看到 GC 的時(shí)候也會(huì)越來(lái)越長(zhǎng)。
那還有一個(gè)問(wèn)題,假如說(shuō)調(diào)用 MethodHandles.lookup() 的類真的被卸載了還存在這個(gè)問(wèn)題嗎,答案是 GC 時(shí)間不會(huì)再惡化了,但是之前已經(jīng)達(dá)到的惡化結(jié)果已經(jīng)無(wú)法再修復(fù)了。
所以,這算是一個(gè) JVM 設(shè)計(jì)上的缺陷吧,只要 Java 層面能觸發(fā)不斷調(diào)用到JNIHandles::make_weak_global,那這個(gè)問(wèn)題將會(huì)立馬重現(xiàn)。
其實(shí)解決方案我也想了一個(gè),就是在遍歷這些 JNIHandleBlock 的時(shí)候,如果發(fā)現(xiàn)對(duì)應(yīng)的_handle數(shù)組全是空的話,那就直接將 JNIHandleBlock 回收掉,這樣在 GC 發(fā)生的過(guò)程中并不會(huì)掃描到很多的 JNIHandleBlock 而耗時(shí)掉。
至于同事的那個(gè)問(wèn)題的解決方案,其實(shí)也簡(jiǎn)單,對(duì)于同一個(gè) JavaScript 腳本,不要每次都去調(diào)用 eval 方法,可以緩存起來(lái),這樣就減少了不斷去觸發(fā)調(diào)用 JNIHandles::make_weak_global 的動(dòng)作從而可以避免 JNIHandleBlock 不斷增長(zhǎng)的問(wèn)題。
【本文是51CTO專欄作者李嘉鵬的原創(chuàng)文章,轉(zhuǎn)載請(qǐng)通過(guò)微信公眾號(hào)(你假笨,id:lovestblog)聯(lián)系作者本人獲取授權(quán)】