前言
JVM的堆外內(nèi)存泄露的定位一直是個比較棘手的問題安拟。此次的Bug查找從堆內(nèi)內(nèi)存的泄露反推出堆外內(nèi)存癞志,同時對物理內(nèi)存的使用做了定量的分析锣笨,從而實錘了Bug的源頭驻啤。筆者將此Bug分析的過程寫成博客菲驴,以饗讀者。
由于物理內(nèi)存定量分析部分用到了linux kernel虛擬內(nèi)存管理的知識骑冗,讀者如果有興趣了解請看ulk3(《深入理解linux內(nèi)核第三版》)
內(nèi)存泄露Bug現(xiàn)場
一個線上穩(wěn)定運行了三年的系統(tǒng)赊瞬,從物理機遷移到docker環(huán)境后,運行了一段時間贼涩,突然被監(jiān)控系統(tǒng)發(fā)出了某些實例不可用的報警巧涧。所幸有負(fù)載均衡,可以自動下掉節(jié)點遥倦,如下圖所示:
登錄到對應(yīng)機器上后谤绳,發(fā)現(xiàn)由于內(nèi)存占用太大,觸發(fā)OOM,然后被linux系統(tǒng)本身給kill了缩筛。
應(yīng)急措施
緊急在出問題的實例上再次啟動應(yīng)用消略,啟動后,內(nèi)存占用正常瞎抛,一切Okay艺演。
奇怪現(xiàn)象
當(dāng)前設(shè)置的最大堆內(nèi)存是1792M,如下所示:
-Xmx1792m -Xms1792m -Xmn900m -XX:PermSi
ze=256m -XX:MaxPermSize=256m -server -Xss512k
查看操作系統(tǒng)層面的監(jiān)控桐臊,發(fā)現(xiàn)內(nèi)存占用情況如下圖所示:
上圖藍色的線表示總的內(nèi)存使用量胎撤,發(fā)現(xiàn)一直漲到了4G后,超出了系統(tǒng)限制豪硅。
很明顯哩照,有堆外內(nèi)存泄露了。
查找線索
gc日志
一般出現(xiàn)內(nèi)存泄露懒浮,筆者立馬想到的就是查看當(dāng)時的gc日志飘弧。
本身應(yīng)用所采用框架會定時打印出對應(yīng)的gc日志,遂查看砚著,發(fā)現(xiàn)gc日志一切正常次伶。對應(yīng)日志如下:
查看了當(dāng)天的所有g(shù)c日志,發(fā)現(xiàn)內(nèi)存始終會回落到170M左右稽穆,并無明顯的增加冠王。要知道JVM進程本身占用的內(nèi)存可是接近4G(加上其它進程,例如日志進程就已經(jīng)到4G了),進一步確認(rèn)是堆外內(nèi)存導(dǎo)致舌镶。
排查代碼
打開線上服務(wù)對應(yīng)對應(yīng)代碼柱彻,查了一圈,發(fā)現(xiàn)沒有任何地方顯式利用堆外內(nèi)存餐胀,其沒有依賴任何額外的native方法哟楷。關(guān)于網(wǎng)絡(luò)IO的代碼也是托管給Tomcat,很明顯否灾,作為一個全世界廣泛流行的Web服務(wù)器卖擅,Tomcat不大可能有堆外內(nèi)存泄露。
進一步查找
由于在代碼層面沒有發(fā)現(xiàn)堆外內(nèi)存的痕跡墨技,那就繼續(xù)找些其它的信息惩阶,希望能發(fā)現(xiàn)蛛絲馬跡。
Dump出JVM的Heap堆
由于線上出問題的Server已經(jīng)被kill扣汪,還好有其它幾臺断楷,登上去發(fā)現(xiàn)它們也 占用了很大的堆外內(nèi)存,只是還沒有到觸發(fā)OOM的臨界點而已崭别。于是就趕緊用jmap dump了兩臺機器中應(yīng)用JVM的堆情況冬筒,這兩臺留做現(xiàn)場保留不動统刮,然后將其它機器迅速重啟,以防同時被OOM導(dǎo)致服務(wù)不可用账千。
使用如下命令dump:
jmap -dump:format=b侥蒙,file=heap.bin [pid]
使用MAT分析Heap文件
挑了一個heap文件進行分析,堆的使用情況如下圖所示:
一共用了200多M匀奏,和之前gc文件打印出來的170M相差不大鞭衩,遠遠沒有到4G的程度。
不得不說MAT是個非常好用的工具娃善,它可以提示你可能內(nèi)存泄露的點:
這個cachedBnsClient類有12452個實例论衍,占用了整個堆的61.92%。
查看了另一個heap文件坯台,發(fā)現(xiàn)也是同樣的情況。這個地方肯定有內(nèi)存泄露瘫寝,但是也占用了130多M蜒蕾,和4G相差甚遠。
查看對應(yīng)的代碼
系統(tǒng)中大部分對于CachedBnsClient的調(diào)用焕阿,都是通過注解Autowired的咪啡,這部分實例數(shù)很少。
唯一頻繁產(chǎn)生此類實例的代碼如下所示:
@Override
public void fun() {
BnsClient bnsClient = new CachedBnsClient();
// do something
return ;
}
此CachedBnsClient僅僅在方法體內(nèi)使用暮屡,并沒有逃逸到外面撤摸,再看此類本身
public class CachedBnsClient {
private ConcurrentHashMap<String, List<String>> authCache = new ConcurrentHashMap<String褒纲, List<String>>();
private ConcurrentHashMap<String准夷, List<URI>> validUriCache = new ConcurrentHashMap<String, List<URI>>();
private ConcurrentHashMap<String莺掠, List<URI>> uriCache = new ConcurrentHashMap<String衫嵌, List<URI>>();
......
}
沒有任何static變量,同時也沒有往任何全局變量注冊自身汁蝶。換言之渐扮,在類的成員(Member)中论悴,是不可能出現(xiàn)內(nèi)存泄露的掖棉。
當(dāng)時只粗略的過了一過成員變量,回過頭來細(xì)想膀估,還是漏了不少地方的幔亥。
更多信息
由于代碼排查下來,感覺這塊不應(yīng)該出現(xiàn)內(nèi)存泄露(但是事實確是如此的打臉)察纯。這個類也沒有顯式用到堆外內(nèi)存帕棉,而且只占了130M针肥,和4G比起來微不足道,還是先去追查主要矛盾再說香伴。
使用jstack dump線程信息
現(xiàn)場信息越多慰枕,越能找出蛛絲馬跡。先用jstack把線程信息dump下來看下即纲。
這一看具帮,立馬發(fā)現(xiàn)了不同,除了正常的IO線程以及框架本身的一些守護線程外低斋,竟然還多出來了12563多個線程蜂厅。
"Thread-5" daemon prio=10 tid=0x00007fb79426e000 nid=0x7346 waiting on condition [0x00007fb7b5678000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at com.xxxxx.CachedBnsClient$1.run(CachedBnsClient.java:62)
而且這些正好是運行再CachedBnsClient的run方法上面!這些特定線程的數(shù)量正好是12452個膊畴,和cachedBnsClient數(shù)量一致!
再次check對應(yīng)代碼
原來剛才看CachedBnsClient代碼的時候遺漏掉了一個關(guān)鍵的點!
public CachedBnsClient(BnsClient client) {
super();
this.backendClient = client;
new Thread() {
@Override
public void run() {
for (; ; ) {
refreshCache();
try {
Thread.sleep(60 * 1000);
} catch (InterruptedException e) {
logger.error("出錯"掘猿, e);
}
}
}
......
}.start();
}
這段代碼是CachedBnsClient的構(gòu)造函數(shù),其在里面創(chuàng)建了一個無限循環(huán)的線程唇跨,每隔60s啟動一次刷新一下里面的緩存!
找到關(guān)鍵點
在看到12452個等待在CachedBnsClient.run的業(yè)務(wù)的一瞬間筆者就意識到稠通,肯定是這邊的線程導(dǎo)致對外內(nèi)存泄露了。下面就是根據(jù)線程大小計算其泄露內(nèi)存量是不是確實能夠引起OOM了买猖。
發(fā)現(xiàn)內(nèi)存計算對不上
由于我們這邊設(shè)置的Xss是512K采记,即一個線程棧大小是512K,而由于線程共享其它MM單元(線程本地內(nèi)存是是現(xiàn)在線程棧上的)政勃,所以實際線程堆外內(nèi)存占用數(shù)量也是512K唧龄。進行如下計算:
12563 * 512K = 6331M = 6.3G
整個環(huán)境一共4G,加上JVM堆內(nèi)存1.8G(1792M)奸远,已經(jīng)明顯的超過了4G既棺。
(6.3G + 1.8G)=8.1G > 4G
如果按照此計算,應(yīng)用應(yīng)用早就被OOM了懒叛。
怎么回事呢丸冕?
為了解決這個問題,筆者又思考了好久薛窥。如下所示:
Java線程底層實現(xiàn)
JVM的線程在linux上底層是調(diào)用NPTL(Native Posix Thread Library)來創(chuàng)建的胖烛,一個JVM線程就對應(yīng)linux的lwp(輕量級進程,也是進程诅迷,只不過共享了mm_struct佩番,用來實現(xiàn)線程),一個thread.start就相當(dāng)于do_fork了一把罢杉。
其中趟畏,我們在JVM啟動時候設(shè)置了-Xss=512K(即線程棧大小),這512K中然后有8K是必須使用的滩租,這8K是由進程的內(nèi)核棧和thread_info公用的赋秀,放在兩塊連續(xù)的物理頁框上利朵。如下圖所示:
眾所周知,一個進程(包括lwp)包括內(nèi)核棧和用戶棧猎莲,內(nèi)核棧+thread_info用了8K绍弟,那么用戶態(tài)的棧可用內(nèi)存就是:
512K-8K=504K
如下圖所示:
Linux實際物理內(nèi)存映射
事實上linux對物理內(nèi)存的使用非常的摳門著洼,一開始只是分配了虛擬內(nèi)存的線性區(qū)晌柬,并沒有分配實際的物理內(nèi)存,只有推到最后使用的時候才分配具體的物理內(nèi)存郭脂,即所謂的請求調(diào)頁年碘。如下圖所示:
查看smaps進程內(nèi)存使用信息
使用如下命令,查看
cat /proc/[pid]/smaps > smaps.txt
實際物理內(nèi)存使用信息展鸡,如下所示:
7fa69a6d1000-7fa69a74f000 rwxp 00000000 00:00 0
Size: 504 kB
Rss: 92 kB
Pss: 92 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 92 kB
Referenced: 92 kB
Anonymous: 92 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
7fa69a7d3000-7fa69a851000 rwxp 00000000 00:00 0
Size: 504 kB
Rss: 152 kB
Pss: 152 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 152 kB
Referenced: 152 kB
Anonymous: 152 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
搜索下504KB屿衅,正好是12563個,對了12563個線程莹弊,其中Rss表示實際物理內(nèi)存(含共享庫)92KB涤久,Pss表示實際物理內(nèi)存(按比例共享庫)92KB(由于沒有共享庫,所以Rss==Pss)忍弛,以第一個7fa69a6d1000-7fa69a74f000線性區(qū)來看响迂,其映射了92KB的空間,第二個映射了152KB的空間细疚。如下圖所示:
挑出符合條件(即size是504K)的幾十組看了下蔗彤,基本都在92K-152K之間,再加上內(nèi)核棧8K
(92+152)/2+8K=130K疯兼,由于是估算然遏,取整為128K,即反映此應(yīng)用平均線程棧大小吧彪。
注意待侵,實際內(nèi)存有波動的原因是由于環(huán)境不同,從而走了不同的分支姨裸,導(dǎo)致棧上的增長不同秧倾。
重新進行內(nèi)存計算
JVM一開始申請了
-Xmx1792m -Xms1792m
即1.8G的堆內(nèi)內(nèi)存,這里是即時分配傀缩,一開始就用物理頁框填充那先。
12563個線程,每個線程棧平均大小128K扑毡,即:
128K * 12563=1570M=1.5G的對外內(nèi)存
取個整數(shù)128K胃榕,就能反映出平均水平盛险。再拿這個128K * 12563 =1570M = 1.5G瞄摊,加上JVM的1.8G勋又,就已經(jīng)達到了3.3G,再加上kernel和日志傳輸進程等使用的內(nèi)存數(shù)量换帜,確實已經(jīng)接近了4G楔壤,這樣內(nèi)存就對應(yīng)上了!(注:用于定量內(nèi)存計算的環(huán)境是一臺內(nèi)存用量將近4G惯驼,但還沒OOM的機器)
為什么在物理機上沒有應(yīng)用Down機
筆者登錄了原來物理機蹲嚣,應(yīng)用還在跑,發(fā)現(xiàn)其同樣有堆外內(nèi)存泄露的現(xiàn)象祟牲,其物理內(nèi)存使用已經(jīng)達到了5個多G!幸好物理機內(nèi)存很大隙畜,而且此應(yīng)用發(fā)布還比較頻繁,所以沒有被OOM说贝。
Dump了物理機上應(yīng)用的線程议惰,
一共有28737個線程,其中28626個線程等待在CachedBnsClient上乡恕。
同樣用smaps查看進程實際內(nèi)存信息言询,其平均大小依舊為
128K,因為是同一應(yīng)用的原因
繼續(xù)進行物理內(nèi)存計算
1.8+(28737 * 128k)/1024K =(3.6+1.8)=5.4G
進一步驗證了我們的推理傲宜。
這么多線程應(yīng)用為什么沒有卡頓
因為基本所有的線程都睡眠在
Thread.sleep(60 * 1000);//一次睡眠60s
上运杭。所以僅僅占用了內(nèi)存,實際占用的CPU時間很少函卒。
總結(jié)
查找Bug的時候辆憔,現(xiàn)場信息越多越好,同時定位Bug必須要有實質(zhì)性的證據(jù)报嵌。例如內(nèi)存泄露就要用你推測出的模型進行定量分析躁愿。在定量和實際對不上的時候,深挖下去沪蓬,你會發(fā)現(xiàn)不一樣的風(fēng)景!
看完三件事??
如果你覺得這篇內(nèi)容對你還蠻有幫助彤钟,我想邀請你幫我三個小忙:
點贊,轉(zhuǎn)發(fā)跷叉,有你們的 『點贊和評論』逸雹,才是我創(chuàng)造的動力。
關(guān)注公眾號 『 java爛豬皮 』云挟,不定期分享原創(chuàng)知識梆砸。
同時可以期待后續(xù)文章ing??