1.系統(tǒng)背景
這是當(dāng)時(shí)開(kāi)發(fā)中遇到的一個(gè)真實(shí)場(chǎng)景,也是大部分人在開(kāi)發(fā)項(xiàng)目中有可能會(huì)遇到的一些場(chǎng)景,該系統(tǒng)主要是做大數(shù)據(jù)相關(guān)計(jì)算分析的,日處理數(shù)據(jù)量在上億的規(guī)模。這里我們重點(diǎn)針對(duì)JVM內(nèi)存的管理來(lái)進(jìn)行模型分析,數(shù)據(jù)的來(lái)源獲取主要是MYSQL數(shù)據(jù)庫(kù)以及其他數(shù)據(jù)源里提取大量的數(shù)據(jù),通過(guò)加載到JVM內(nèi)存的過(guò)程我們來(lái)一起分析出現(xiàn)的問(wèn)題以及如何優(yōu)化解決(如下圖所示):
2.生產(chǎn)環(huán)境
這是一套分布式運(yùn)行系統(tǒng),生產(chǎn)環(huán)境部署了多臺(tái)服務(wù)器(每臺(tái)4核8G配置),每臺(tái)機(jī)器大概每分鐘負(fù)責(zé)執(zhí)行100次數(shù)據(jù)提取和計(jì)算,每次提取大概1萬(wàn)條左右的數(shù)據(jù)到內(nèi)存計(jì)算,平均每次計(jì)算需要耗費(fèi)10秒左右時(shí)間。 JVM內(nèi)存總共分配了4G,堆內(nèi)存占3G,其中新生代和老年代分別是1.5G的內(nèi)存空間
3.過(guò)程分析
按照上述的背景和實(shí)際生產(chǎn)環(huán)境,那每次1萬(wàn)條數(shù)據(jù)會(huì)占用多少的內(nèi)存空間呢?這里每條數(shù)據(jù)較大,平均包含20個(gè)字段,可以認(rèn)為每條數(shù)據(jù)大概在1KB左右。那么1萬(wàn)條數(shù)據(jù)對(duì)應(yīng)就是10MB大小。那么運(yùn)行多久就會(huì)導(dǎo)致新生代塞滿(mǎn)呢?
新生代總共分配1.5G,那么Eden區(qū)分配就是1.2G,S1和S2區(qū)分別是150MB;如下圖:
現(xiàn)在我們可以來(lái)手動(dòng)計(jì)算下了,1次往Eden區(qū)里填充10MB對(duì)象,1分鐘讀取100次,也就是差不多1個(gè)G,那也就是1分鐘左右的時(shí)候我們的Eden區(qū)就差不多填滿(mǎn)了,這個(gè)時(shí)候如果觸發(fā)Minor GC,我們通過(guò)上文學(xué)習(xí)知道,JVM在執(zhí)行Minor GC之前是會(huì)進(jìn)行一步檢查動(dòng)作的:老年代可用內(nèi)存空間是否大于新生代全部對(duì)象?如果是第一次運(yùn)行到這兒,那么我們的老年代是空的,也就是有1.5G的空間,完全是夠用的。
這里觸發(fā)Minor GC進(jìn)行回收,但是問(wèn)題在于如何回收呢?我們重點(diǎn)來(lái)看每次任務(wù)計(jì)算的耗時(shí)是10S,這里差不多有80次的任務(wù)都已經(jīng)執(zhí)行完畢了,還有大概20個(gè)任務(wù)正在計(jì)算中,也就是對(duì)應(yīng)還有200MB的對(duì)象在引用著,這部分對(duì)象是不會(huì)被回收的,而我們的幸存者區(qū)域最大也就是150MB無(wú)法存放下200MB,那么根據(jù)我們講過(guò)的空間擔(dān)保機(jī)制,這200MB對(duì)象會(huì)直接進(jìn)入到老年代!
由于每一分鐘就會(huì)將Eden區(qū)填滿(mǎn)觸發(fā)Minor GC,也就是每分鐘就會(huì)有200MB對(duì)象進(jìn)入到老年代,那當(dāng)老年代的內(nèi)存占用的越多后會(huì)發(fā)生什么事情呢?比如兩分鐘過(guò)去了,這時(shí)占用400MB,那老年代可用空間就只剩1.1G了,那第三分鐘觸發(fā)Minor GC的時(shí)候,一判斷發(fā)現(xiàn),老年代剩余空間已小于Eden區(qū)所有對(duì)象1.2G大小了,則會(huì)走另一條分支的判斷了,我們可以根據(jù)下圖再來(lái)回顧下:
先看參數(shù):-XX:-HandlePromotionFailure是否設(shè)置,當(dāng)然一般都會(huì)設(shè)置,此時(shí)會(huì)判斷老年代連續(xù)空間是否大于歷史平均晉升老年代對(duì)象的大小,那歷史晉升對(duì)象大小都在200MB,很明顯大于,那么JVM會(huì)直接進(jìn)行冒險(xiǎn)操作,觸發(fā)Minor GC的執(zhí)行,而本次冒險(xiǎn)是成功的!新生代依然繼續(xù)晉升200MB對(duì)象到老年代。
那么當(dāng)系統(tǒng)運(yùn)行到第7分鐘的時(shí)候,這時(shí)進(jìn)入到老年代的對(duì)象有1.4G了,剩余空間僅剩100MB!如下圖:
系統(tǒng)運(yùn)行到這兒,發(fā)現(xiàn)老年代剩余空間已經(jīng)比歷史平均晉升對(duì)象大小都要小了,這時(shí)會(huì)直接觸發(fā)Full GC!假設(shè)老年代空間都可以被回收,那么這時(shí)老年代對(duì)象就完全清除,接著會(huì)繼續(xù)進(jìn)行Minor GC,200MB對(duì)象繼續(xù)進(jìn)入老年代,又開(kāi)始重復(fù)循環(huán)執(zhí)行了。
那么按照以上的運(yùn)行分析,我們可以得出一個(gè)結(jié)論就是:系統(tǒng)平均運(yùn)行7、8分鐘左右就會(huì)觸發(fā)一次Full GC的執(zhí)行!而每次一旦Full GC執(zhí)行,就會(huì)嚴(yán)重影響到系統(tǒng)的運(yùn)行效率,加上該系統(tǒng)的Full GC頻率較高,給用戶(hù)帶來(lái)的使用感受是非常糟糕的!
4.JVM優(yōu)化
像真實(shí)開(kāi)發(fā)中大家也有很大幾率會(huì)遇到類(lèi)似這樣的情況,我們應(yīng)該減少Full GC的次數(shù)以及降低它出現(xiàn)的頻率,甚至不觸發(fā)Full GC,那么如何進(jìn)行優(yōu)化呢?這也是考驗(yàn)一個(gè)Java程序員的價(jià)值體現(xiàn)。
針對(duì)類(lèi)似的計(jì)算系統(tǒng),每次Minor GC的時(shí)候,必然會(huì)有一部分?jǐn)?shù)據(jù)沒(méi)處理完畢,但是按照現(xiàn)有的內(nèi)存模型,我們的幸存者區(qū)域只有150MB是無(wú)法滿(mǎn)足200MB對(duì)象的存放,因此有必要調(diào)整我們的內(nèi)存比例。
解決方案:
3GB的堆內(nèi)存大小,我們直接分配2G給新生代,1G給老年代,這樣Survivor區(qū)的大小就有200MB了每次剛好能存放下MinorGC過(guò)后存活的對(duì)象了。如下圖所示:
只要每次Minor GC時(shí)200MB存活對(duì)象可以存放進(jìn)Survivor區(qū),那么等下一次Minor GC時(shí)這部分對(duì)象對(duì)應(yīng)的計(jì)算任務(wù)也已經(jīng)結(jié)束,也可以直接進(jìn)行回收。
那么接下來(lái)我們還是在繼續(xù)模擬跑一次,當(dāng)Eden區(qū)內(nèi)存已經(jīng)裝滿(mǎn),此時(shí)S0區(qū)也有200MB對(duì)象,這是觸發(fā)Minor GC的執(zhí)行,200MB正在執(zhí)行的任務(wù)對(duì)象(存活對(duì)象)直接轉(zhuǎn)移到S1區(qū),回收清空掉Eden區(qū)和S0區(qū),如下圖:
那么通過(guò)以上的分析也不免看出,基本上很少會(huì)有對(duì)象進(jìn)入到老年代,我們也成功的將幾分鐘一次的Full GC降低到幾個(gè)小時(shí)一次,大幅度提升了系統(tǒng)的性能,避免了Full GC對(duì)系統(tǒng)運(yùn)行的影響!
當(dāng)然這里其實(shí)還有一個(gè)細(xì)節(jié)點(diǎn):就是動(dòng)態(tài)年齡對(duì)象規(guī)則!如果在Survivor空間中相同年齡所有對(duì)象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對(duì)象就可以直接進(jìn)入老年代,無(wú)須等到-XX:MaxTenuringThreshold中要求的年齡。這里需要結(jié)合自己公司的實(shí)際系統(tǒng)分析到底有多少對(duì)象是根據(jù)動(dòng)態(tài)年齡規(guī)則進(jìn)入到了老年代,如果要避免因?yàn)檫@項(xiàng)規(guī)則進(jìn)入老年代,從而觸發(fā)Full GC也可以嘗試調(diào)整Eden區(qū)和Survivor區(qū)的比例,調(diào)整survivor區(qū)的大小。