淺談JVM內(nèi)存分配與垃圾回收

大家好,我是微塵,最近又去翻了周志明老師的《深入理解Java虛擬機》這本書。已經(jīng)看了很多遍了,每次都感覺似乎看懂了,但沒過多久就忘了。這次翻了第三章的垃圾收集器與內(nèi)存分配策略,感覺有了新的認識,整理一下分享出來。

內(nèi)容有點多,并且我沒怎么配圖,一方面是懶,一方面是我想如果在沒有圖的情況下你都能看懂,那肯定是真正的懂了。就像是上學(xué)的時候做的練習(xí)冊,即便沒有后面那幾頁寫著"略"的參考答案你也能把題目做好做完,那才是真的牛批。

以下是正文

Java技術(shù)體系中所提倡的自動內(nèi)存管理最終應(yīng)該可以歸結(jié)為自動化的解決兩個問題,即給對象動態(tài)分配內(nèi)存和回收分配給對象的內(nèi)存。通常情況下Java對象在JVM堆上分配內(nèi)存,但也可以在JVM堆外分配內(nèi)存。這是因為JVM堆作為最主要的存儲對象實例的內(nèi)存區(qū),同時也是垃圾回收(GC)的重點區(qū)域。GC的頻率和效率就很可能成為虛擬機性能上的瓶頸。為了降低GC的頻率和提升GC的效率,逃逸分析、棧上分配等優(yōu)化技術(shù)就出現(xiàn)了,JVM堆區(qū)便不再是Java對象動態(tài)內(nèi)存空間分配的唯一選擇。扯的有點遠了,先了解一下就行。

從生命周期的角度上來看,存儲在JVM堆中的對象大致可以分為兩類。一類是生命周期較短的瞬時對象,它伴隨這線程的啟動而創(chuàng)建,隨著線程的運行結(jié)束而消亡。另一類是是生命周期較長的對象,能夠在每次GC中存活下來,甚至某些極端情況下與JVM的生命周期保持一致。因此對于不同生命周期的對象應(yīng)該采取不同的垃圾收集策略,于是分代收集算法應(yīng)運而生。

在這樣的情況下JVM堆被分為新生代和老年代,其中新生代默認占 1/3堆空間,老年代默認占 2/3堆空間。這時就可以根據(jù)各個年代中生命周期特點采用最適合的垃圾回收算法。比如新生代中絕大多數(shù)的對象為上面講到的瞬時對象,就適合采用基于復(fù)制算法的垃圾收集器進行回收。老年代中的對象通常由新生代中長生命周期的對象晉升進去的,就適合采用基于標記-清除算法或者標記-整理算法的垃圾收集器進行回收。

image.png

在JVM堆中,新生代是給新對象分配內(nèi)存空間最多的地方,自然也是回收垃圾對象最頻繁的地方。在新生代中的垃圾回收動作叫做Young GC,也有的叫Minor GC。前面講到新生代適合采用基于復(fù)制算法的垃圾收集器進行垃圾對象回收。復(fù)制算法的原理是將內(nèi)存容量劃分為大小相同的兩塊(以下稱為AB塊),每次只使用其中一塊。當(dāng)A塊用完了之后新生代就觸發(fā)一次Minor GC,將A塊中的存活對象復(fù)制到B塊,然后將A塊清理干凈好給之后新創(chuàng)建的對象騰出內(nèi)存空間。

基于這個原理,新生代被劃分出Eden區(qū)、From Survivor區(qū)、To Survivor區(qū)。默認情況下Eden區(qū)和Survivor區(qū)的內(nèi)存空間占比為8:1:1,可以通過-XX:SurvivorRatio參數(shù)調(diào)整Eden區(qū)的比例。這個參數(shù)我是不太不建議修改,因為JVM堆中絕大部分(98%以上)的對象都是朝生夕死,只有少部分對象能夠存活下來,所以8:1:1的比例算是比較保守的了。

確確的說給新對象分配內(nèi)存空間最多的地方是新生代中的Eden區(qū),這個很好理解,Eden區(qū)占到了新生代80%的內(nèi)存空間,是最有可能拿得出連續(xù)的內(nèi)存空間的。當(dāng)Eden區(qū)中的可用連續(xù)內(nèi)存空間不足以分配給新對象時,新生代就會觸發(fā)一次Minor GC來回收這里面的垃圾對象。

這里面的細節(jié)需要注意一下

第一次Minor GC的時候Eden區(qū)中存活的對象會被復(fù)制到From Survivor區(qū),然后清空Eden區(qū),這時To Survivor區(qū)是空的。之后的每一次Minor GC,則是將Eden區(qū)和From Survivor區(qū)中的存活對象一起復(fù)制到To Survivor區(qū)中,然后清空Eden區(qū)和From Survivor區(qū)的內(nèi)存空間。最后,F(xiàn)rom Survivor區(qū)和To Survivor區(qū)角色上會進行對換。即原本被清空內(nèi)存空間的From Survivor區(qū)會變成了To Survivor區(qū),而原本接收了Eden區(qū)和From Survivor區(qū)中存活對象的To Survivor區(qū)變成了From Survivor區(qū)。

聽起來似乎有些奇怪,這樣一來From Survivor區(qū)不是顯得有些多余嗎?每一次Minor GC要從兩個區(qū)中把存活對象找出來復(fù)制到Sruvivor To區(qū)中,然后一起被清空,直接一個To Survivor區(qū)不行嗎?

那么我們就來看看假如將From Survivor區(qū)和To SurvivorTo區(qū)合并成Survivor區(qū)會發(fā)生什么情況!

第一次Minor GC的時候,Eden區(qū)的可用連續(xù)內(nèi)存空間不足,而Survivor區(qū)是空的。Minor GC時垃圾收集器將Eden區(qū)中的存活對象按照順序復(fù)制到Survivor區(qū)中,然后清空Eden區(qū)。

下一次Minor GC的時候,Eden區(qū)中有大量的對象死去,只有少量的存活下來。Survivor區(qū)中原本接收了上一次Minor GC存活下來的對象。到了這一步同樣有大量的對象死去,僅存在少量的存活對象了。我們都知道接下來Minor GC的時候垃圾收集器要將Eden區(qū)中的存活對象要復(fù)制到Survivor區(qū)中,那么Survivor區(qū)中的存活對象應(yīng)該如何處理呢?按照空間分配擔(dān)保機制直接復(fù)制到老年代中,然后先清空Survivor區(qū)嗎?這樣存活下來的對象晉升進入老年代的門檻將大大降低,導(dǎo)致老年代的可用連續(xù)內(nèi)存很快用完,觸發(fā)老年代的Major GC。直接把Eden區(qū)中的存活對象復(fù)制到Survivor區(qū)中呢?這樣的話Survivor區(qū)中的垃圾對象一直不回收,將持續(xù)占用著寶貴的內(nèi)存空間,直到Survivor區(qū)內(nèi)存不足了,導(dǎo)致內(nèi)存泄漏,那Survivor區(qū)不就形同擺設(shè)了嗎?

所以我的理解就是,兩個Survivor區(qū)的存在可以起到一個調(diào)節(jié)和緩沖的作用?;谛律薪^大部分的對象都是朝生夕死這樣的事實,將這些瞬時對象暫時留在新生代中,同時盡可能扼殺在新生代中,避免頻繁或者延遲老年代的Major GC。在老年代中的垃圾回收動作叫做Old GC,也有的叫Major GC,不過老年代的Major GC通常伴隨著新生代一次Minor GC,所以老年代的垃圾回收動作通常也稱為Full GC??傊褪荅den區(qū)永遠存放上一次Minor GC后創(chuàng)建的新對象,F(xiàn)rom Survivor區(qū)永遠存放著歷史上從Eden區(qū)以及曾經(jīng)是From Survivor區(qū)中存活下來的對象,而To Survivor區(qū)則在Minor GC之后永遠都是空的。

需要注意的是,極端情況下可能Eden區(qū)和From Survivor區(qū)中存活的對象比較多(超過10%),To Survivor區(qū)中沒有足夠的連續(xù)內(nèi)存空間(有且僅有10%)分配給存活下來的對象。這時無法從To Survivor分配到內(nèi)存空間的存活對象將直接晉升進入老年代,這是JVM內(nèi)存空間分配擔(dān)保機制的體現(xiàn)。

實際上,在新生代觸發(fā)Minor GC之前,虛擬機會先比較一下老年代的可用連續(xù)內(nèi)存空間大小與新生代中所有對象(包括可回收對象)的總大小。如果大于的話,那么新生代可以放心的進行Minor GC。這樣做主要是考慮到虛擬機在運行一段時間后,新生代和老年代中都存在著大量的對象。在極端情況下,可能新生代中所有的對象都是存活對象,甚至這些存活對象中最小的連To Survivor區(qū)都無法為其分配內(nèi)存空間。按照計劃這些存活對象是將全部晉升進入老年代。所以如果的確老年代的可用連續(xù)內(nèi)存空間足以分配這些存活對象的話,新生代的Minor GC就正常。

否則虛擬機還會去比較老年代中可用連續(xù)內(nèi)存空間大小與歷史上從新生代晉升進入老年代的對象的平均大小。如果小于,那很顯然當(dāng)前老年代的可用的連續(xù)內(nèi)存空間已經(jīng)不多了,虛擬機將不得不在老年代中觸發(fā)一次Major GC來回收垃圾對象釋放出內(nèi)存空間。否則虛擬機會很不負責(zé)任的認為老年代中的可用連續(xù)內(nèi)存足以分配給接下來新生代的Minor GC之后存活下來的對象,于是冒險的觸發(fā)Minor GC從新生代中回收垃圾對象。

冒險可能帶來的后果就是事實上老年代的可用內(nèi)存空間不多,分配不了,那么新生代的Minor GC就會失敗。最后老年代再不情愿的通過Major GC進行垃圾對象的回收。老年代的垃圾收集器通常是基于標記-整理算法實現(xiàn)的,它的原理是將內(nèi)存空間的存活對象移動到一端,然后清空端邊界以外的空間,相比標記-清除算法的原地清除可以保證不會產(chǎn)生內(nèi)存碎片,提高內(nèi)存空間利用率。

可能有的人會懷疑如此冒險的做法的意義,但其實老年代在觸發(fā)Major GC之前,極有可能仍然存儲著很多存活的對象或者大對象,同時老年代的內(nèi)存空間比較大,前面講過老年代的Major GC通常還會伴隨著一次新生代的Minor GC,所以回收垃圾對象的速度特別慢,大概是Minor GC的十倍以上。試想一下,業(yè)務(wù)執(zhí)行過程中在極端情況下突然暫停了幾秒鐘是什么體驗。所以這么冒險是希望避免頻繁或者延遲老年代的Major GC,同時盡可能的將垃圾對象扼殺再新生代中,從而提高代碼的執(zhí)行效率。所以我認為這是一個很合理的設(shè)計。

上面的內(nèi)容涉及到了虛擬機自動內(nèi)存管理中分配內(nèi)存的時候遵循的兩種策略,一個是對象優(yōu)先在Eden區(qū)中分配內(nèi)存,一個是內(nèi)存空間分配擔(dān)保機制。

既然是優(yōu)先在Eden區(qū)中分配內(nèi)存,那就應(yīng)該有偏偏不在Eden區(qū)中分配內(nèi)存的。JVM提供了-XX:PretenureSizeThreshold參數(shù),用于設(shè)置當(dāng)對象大于某個容量值時就直接進入老年代,而無需在新生代中折騰一段時間有幸存活下來后再晉升進入老年代。不過它的默認值是0,即無論多大對象都在Eden區(qū)中創(chuàng)建。如果一個很長很長的字符串對象或者數(shù)組仍然從Eden區(qū)中給他分配內(nèi)存空間的話,Eden區(qū)的可用連續(xù)內(nèi)存可能很快就不夠分配了,這時新生代就要進行Minor GC,導(dǎo)致新生代的Minor GC過于頻繁。甚至說如果這個大對象在每次Minor GC之后還是存活下來,那么前面說到了Eden區(qū)存活下來的對象會復(fù)制到To Survivor區(qū),然后To Survivor變成From Survivor,再存活下來就繼續(xù)重復(fù)復(fù)制。于是這個大對象就在Survivor區(qū)中不斷的復(fù)制來復(fù)制去,導(dǎo)致Minor GC效率大大降低。

當(dāng)然,新生代中存活下來的對象也不可能在Survivor區(qū)中一直復(fù)制來復(fù)制去不消停。事實上,新對象在Eden區(qū)中被創(chuàng)建的時候,JVM會給每一個對象定義一個對象年齡計數(shù)器(Age)。當(dāng)對象經(jīng)過第一次Minor GC從Eden區(qū)存活下來進入From Survivor區(qū)的時候,Age就會被設(shè)置為1,即一歲。當(dāng)之后的每次Minor GC仍然能夠存活下來從From Survivor去復(fù)制到To Survivor區(qū)的時候,Age就+1,直到成年(默認是15歲,可通過XX:MaxTenuringThreshold參數(shù)進行設(shè)置)的時候就晉升進入老年代。也就是說,被判定為長生命周期的對象也將晉升進入老年代。這個跟年齡有關(guān),而跟對象大小無關(guān)。

不過也不一定非得到15歲后才能晉升進入老年代,因為當(dāng)From Survivor區(qū)中相同年齡的對象的大小總和超過From Survivor區(qū)內(nèi)存空間大小的一半時,這些存活對象無論大小就會晉升進入老年代,這是動態(tài)對象年齡判定的體現(xiàn)。

到這里Java虛擬機的自動內(nèi)存管理實現(xiàn)動態(tài)內(nèi)存分配和垃圾回收基本講的七七八八了,從中我們最起碼可以了解到:

1、由于對象的生命周期長短的特點,分代收集算法應(yīng)運而生,于是將JVM堆分成了新生代和老年代,其中新生代的垃圾收集器采用復(fù)制算法,老年代采用標記-清除算法或標記-整理算法;

2、新生代的垃圾收集器基于復(fù)制算法實現(xiàn),于是新生代又可以分為Eden區(qū)、From Survivor區(qū)、To SurvivorTo區(qū),并且新生代Minor GC后From Survivor區(qū)和To SurvivorTo區(qū)會交換角色;

3、動態(tài)內(nèi)存分配遵循優(yōu)先在Eden區(qū)中分配內(nèi)存、大對象直接進入老年代、存活時間長的對象晉升進入老年代、動態(tài)對象年齡判定,空間分配擔(dān)保機制五大策略;

好像沒了吧!但其實完整的JVM內(nèi)存分配與垃圾回收的知識體系遠不止這些,如下圖所示,標記垃圾對象沒講,垃圾收集器沒講,感興趣的可以自己去翻翻書,有空的話我再專門整理一下。

image.png

另外,本篇只是輕描淡寫的講了內(nèi)存分配的思路、設(shè)計原理、遵循的策略以及垃圾對象在什么場景下回收,如何回收等。更深層一點的知識點如對象是如何創(chuàng)建的,誰去創(chuàng)建的,分配內(nèi)存的時候具體是怎么分配的都沒講到,也是有空的時候?qū)iT講一講。

相比周志明老師的《深入理解Java虛擬機》以及網(wǎng)上的博客那種分點式、按類型式的去科普。我在看的時候感覺每一個點都能看懂,但就是結(jié)合不起來,于是沒過幾天就忘光了?;蛘呖吹揭恍╆P(guān)于JVM的問題的時候,不知道如何去講述。

所以本篇嘗試打破這種傳統(tǒng)的方式,根據(jù)我自己的理解,把整一個思路用文字的形式串聯(lián)起來進行表達?;蛟S你看完再回過頭去看書,會進一步的理解這些知識點。當(dāng)然這里面也肯定有我理解錯誤的地方,所以如果你有什么不同的見解,希望不吝賜教,感謝!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容