淺談JVM記憶體分配與垃圾回收

程式設計師微塵發表於2022-01-01

大家好,我是微塵,最近又去翻了周志明老師的《深入理解Java虛擬機器》這本書。已經看了很多遍了,每次都感覺似乎看懂了,但沒過多久就忘了。這次翻了第三章的垃圾收集器與記憶體分配策略,感覺有了新的認識,整理一下分享出來。
內容有點多,並且我沒怎麼配圖,一方面是懶,一方面是我想如果在沒有圖的情況下你都能看懂,那肯定是真正的懂了。就像是上學的時候做的練習冊,即便沒有後面那幾頁寫著"略"的參考答案你也能把題目做好做完,那才是真的牛批。

以下是正文

Java技術體系中所提倡的自動記憶體管理最終應該可以歸結為自動化的解決兩個問題,即給物件動態分配記憶體和回收分配給物件的記憶體。通常情況下Java物件在JVM堆上分配記憶體,但也可以在JVM堆外分配記憶體。這是因為JVM堆作為最主要的儲存物件例項的記憶體區,同時也是垃圾回收(GC)的重點區域。GC的頻率和效率就很可能成為虛擬機器效能上的瓶頸。為了降低GC的頻率和提升GC的效率,逃逸分析、棧上分配等優化技術就出現了,JVM堆區便不再是Java物件動態記憶體空間分配的唯一選擇。扯的有點遠了,先了解一下就行。

從生命週期的角度上來看,儲存在JVM堆中的物件大致可以分為兩類。一類是生命週期較短的瞬時物件,它伴隨這執行緒的啟動而建立,隨著執行緒的執行結束而消亡。另一類是是生命週期較長的物件,能夠在每次GC中存活下來,甚至某些極端情況下與JVM的生命週期保持一致。因此對於不同生命週期的物件應該採取不同的垃圾收集策略,於是分代收集演算法應運而生。
在這樣的情況下JVM堆被分為新生代和老年代,其中新生代預設佔 1/3堆空間,老年代預設佔 2/3堆空間。這時就可以根據各個年代中生命週期特點採用最適合的垃圾回收演算法。比如新生代中絕大多數的物件為上面講到的瞬時物件,就適合採用基於複製演算法的垃圾收集器進行回收。老年代中的物件通常由新生代中長生命週期的物件晉升進去的,就適合採用基於標記-清除演算法或者標記-整理演算法的垃圾收集器進行回收。

在JVM堆中,新生代是給新物件分配記憶體空間最多的地方,自然也是回收垃圾物件最頻繁的地方。在新生代中的垃圾回收動作叫做Young GC,也有的叫Minor GC。前面講到新生代適合採用基於複製演算法的垃圾收集器進行垃圾物件回收。複製演算法的原理是將記憶體容量劃分為大小相同的兩塊(以下稱為AB塊),每次只使用其中一塊。當A塊用完了之後新生代就觸發一次Minor GC,將A塊中的存活物件複製到B塊,然後將A塊清理乾淨好給之後新建立的物件騰出記憶體空間。
基於這個原理,新生代被劃分出Eden區、From Survivor區、To Survivor區。預設情況下Eden區和Survivor區的記憶體空間佔比為8:1:1,可以通過-XX:SurvivorRatio引數調整Eden區的比例。這個引數我是不太不建議修改,因為JVM堆中絕大部分(98%以上)的物件都是朝生夕死,只有少部分物件能夠存活下來,所以8:1:1的比例算是比較保守的了。

確確的說給新物件分配記憶體空間最多的地方是新生代中的Eden區,這個很好理解,Eden區佔到了新生代80%的記憶體空間,是最有可能拿得出連續的記憶體空間的。當Eden區中的可用連續記憶體空間不足以分配給新物件時,新生代就會觸發一次Minor GC來回收這裡面的垃圾物件。
這裡面的細節需要注意一下
第一次Minor GC的時候Eden區中存活的物件會被複制到From Survivor區,然後清空Eden區,這時To Survivor區是空的。之後的每一次Minor GC,則是將Eden區和From Survivor區中的存活物件一起復制到To Survivor區中,然後清空Eden區和From Survivor區的記憶體空間。最後,From Survivor區和To Survivor區角色上會進行對換。即原本被清空記憶體空間的From Survivor區會變成了To Survivor區,而原本接收了Eden區和From Survivor區中存活物件的To Survivor區變成了From Survivor區。
聽起來似乎有些奇怪,這樣一來From Survivor區不是顯得有些多餘嗎?每一次Minor GC要從兩個區中把存活物件找出來複製到Sruvivor To區中,然後一起被清空,直接一個To Survivor區不行嗎?

那麼我們就來看看假如將From Survivor區和To SurvivorTo區合併成Survivor區會發生什麼情況!
第一次Minor GC的時候,Eden區的可用連續記憶體空間不足,而Survivor區是空的。Minor GC時垃圾收集器將Eden區中的存活物件按照順序複製到Survivor區中,然後清空Eden區。
下一次Minor GC的時候,Eden區中有大量的物件死去,只有少量的存活下來。Survivor區中原本接收了上一次Minor GC存活下來的物件。到了這一步同樣有大量的物件死去,僅存在少量的存活物件了。我們都知道接下來Minor GC的時候垃圾收集器要將Eden區中的存活物件要複製到Survivor區中,那麼Survivor區中的存活物件應該如何處理呢?按照空間分配擔保機制直接複製到老年代中,然後先清空Survivor區嗎?這樣存活下來的物件晉升進入老年代的門檻將大大降低,導致老年代的可用連續記憶體很快用完,觸發老年代的Major GC。直接把Eden區中的存活物件複製到Survivor區中呢?這樣的話Survivor區中的垃圾物件一直不回收,將持續佔用著寶貴的記憶體空間,直到Survivor區記憶體不足了,導致記憶體洩漏,那Survivor區不就形同擺設了嗎?
所以我的理解就是,兩個Survivor區的存在可以起到一個調節和緩衝的作用。基於新生代中絕大部分的物件都是朝生夕死這樣的事實,將這些瞬時物件暫時留在新生代中,同時儘可能扼殺在新生代中,避免頻繁或者延遲老年代的Major GC。在老年代中的垃圾回收動作叫做Old GC,也有的叫Major GC,不過老年代的Major GC通常伴隨著新生代一次Minor GC,所以老年代的垃圾回收動作通常也稱為Full GC。總之就是Eden區永遠存放上一次Minor GC後建立的新物件,From Survivor區永遠存放著歷史上從Eden區以及曾經是From Survivor區中存活下來的物件,而To Survivor區則在Minor GC之後永遠都是空的。

需要注意的是,極端情況下可能Eden區和From Survivor區中存活的物件比較多(超過10%),To Survivor區中沒有足夠的連續記憶體空間(有且僅有10%)分配給存活下來的物件。這時無法從To Survivor分配到記憶體空間的存活物件將直接晉升進入老年代,這是JVM記憶體空間分配擔保機制的體現。
實際上,在新生代觸發Minor GC之前,虛擬機器會先比較一下老年代的可用連續記憶體空間大小與新生代中所有物件(包括可回收物件)的總大小。如果大於的話,那麼新生代可以放心的進行Minor GC。這樣做主要是考慮到虛擬機器在執行一段時間後,新生代和老年代中都存在著大量的物件。在極端情況下,可能新生代中所有的物件都是存活物件,甚至這些存活物件中最小的連To Survivor區都無法為其分配記憶體空間。按照計劃這些存活物件是將全部晉升進入老年代。所以如果的確老年代的可用連續記憶體空間足以分配這些存活物件的話,新生代的Minor GC就正常。
否則虛擬機器還會去比較老年代中可用連續記憶體空間大小與歷史上從新生代晉升進入老年代的物件的平均大小。如果小於,那很顯然當前老年代的可用的連續記憶體空間已經不多了,虛擬機器將不得不在老年代中觸發一次Major GC來回收垃圾物件釋放出記憶體空間。否則虛擬機器會很不負責任的認為老年代中的可用連續記憶體足以分配給接下來新生代的Minor GC之後存活下來的物件,於是冒險的觸發Minor GC從新生代中回收垃圾物件。
冒險可能帶來的後果就是事實上老年代的可用記憶體空間不多,分配不了,那麼新生代的Minor GC就會失敗。最後老年代再不情願的通過Major GC進行垃圾物件的回收。老年代的垃圾收集器通常是基於標記-整理演算法實現的,它的原理是將記憶體空間的存活物件移動到一端,然後清空端邊界以外的空間,相比標記-清除演算法的原地清除可以保證不會產生記憶體碎片,提高記憶體空間利用率。
可能有的人會懷疑如此冒險的做法的意義,但其實老年代在觸發Major GC之前,極有可能仍然儲存著很多存活的物件或者大物件,同時老年代的記憶體空間比較大,前面講過老年代的Major GC通常還會伴隨著一次新生代的Minor GC,所以回收垃圾物件的速度特別慢,大概是Minor GC的十倍以上。試想一下,業務執行過程中在極端情況下突然暫停了幾秒鐘是什麼體驗。所以這麼冒險是希望避免頻繁或者延遲老年代的Major GC,同時儘可能的將垃圾物件扼殺再新生代中,從而提高程式碼的執行效率。所以我認為這是一個很合理的設計。

上面的內容涉及到了虛擬機器自動記憶體管理中分配記憶體的時候遵循的兩種策略,一個是物件優先在Eden區中分配記憶體,一個是記憶體空間分配擔保機制。
既然是優先在Eden區中分配記憶體,那就應該有偏偏不在Eden區中分配記憶體的。JVM提供了-XX:PretenureSizeThreshold引數,用於設定當物件大於某個容量值時就直接進入老年代,而無需在新生代中折騰一段時間有幸存活下來後再晉升進入老年代。不過它的預設值是0,即無論多大物件都在Eden區中建立。如果一個很長很長的字串物件或者陣列仍然從Eden區中給他分配記憶體空間的話,Eden區的可用連續記憶體可能很快就不夠分配了,這時新生代就要進行Minor GC,導致新生代的Minor GC過於頻繁。甚至說如果這個大物件在每次Minor GC之後還是存活下來,那麼前面說到了Eden區存活下來的物件會複製到To Survivor區,然後To Survivor變成From Survivor,再存活下來就繼續重複複製。於是這個大物件就在Survivor區中不斷的複製來複制去,導致Minor GC效率大大降低。

當然,新生代中存活下來的物件也不可能在Survivor區中一直複製來複制去不消停。事實上,新物件在Eden區中被建立的時候,JVM會給每一個物件定義一個物件年齡計數器(Age)。當物件經過第一次Minor GC從Eden區存活下來進入From Survivor區的時候,Age就會被設定為1,即一歲。當之後的每次Minor GC仍然能夠存活下來從From Survivor去複製到To Survivor區的時候,Age就+1,直到成年(預設是15歲,可通過XX:MaxTenuringThreshold引數進行設定)的時候就晉升進入老年代。也就是說,被判定為長生命週期的物件也將晉升進入老年代。這個跟年齡有關,而跟物件大小無關。
不過也不一定非得到15歲後才能晉升進入老年代,因為當From Survivor區中相同年齡的物件的大小總和超過From Survivor區記憶體空間大小的一半時,這些存活物件無論大小就會晉升進入老年代,這是動態物件年齡判定的體現。

到這裡Java虛擬機器的自動記憶體管理實現動態記憶體分配和垃圾回收基本講的七七八八了,從中我們最起碼可以瞭解到:
1、由於物件的生命週期長短的特點,分代收集演算法應運而生,於是將JVM堆分成了新生代和老年代,其中新生代的垃圾收集器採用複製演算法,老年代採用標記-清除演算法或標記-整理演算法;
2、新生代的垃圾收集器基於複製演算法實現,於是新生代又可以分為Eden區、From Survivor區、To SurvivorTo區,並且新生代Minor GC後From Survivor區和To SurvivorTo區會交換角色;
3、動態記憶體分配遵循優先在Eden區中分配記憶體、大物件直接進入老年代、存活時間長的物件晉升進入老年代、動態物件年齡判定,空間分配擔保機制五大策略;
好像沒了吧!但其實完整的JVM記憶體分配與垃圾回收的知識體系遠不止這些,如下圖所示,標記垃圾物件沒講,垃圾收集器沒講,感興趣的可以自己去翻翻書,有空的話我再專門整理一下。

另外,本篇只是輕描淡寫的講了記憶體分配的思路、設計原理、遵循的策略以及垃圾物件在什麼場景下回收,如何回收等。更深層一點的知識點如物件是如何建立的,誰去建立的,分配記憶體的時候具體是怎麼分配的都沒講到,也是有空的時候專門講一講。
相比周志明老師的《深入理解Java虛擬機器》以及網上的部落格那種分點式、按型別式的去科普。我在看的時候感覺每一個點都能看懂,但就是結合不起來,於是沒過幾天就忘光了。或者看到一些關於JVM的問題的時候,不知道如何去講述。
所以本篇嘗試打破這種傳統的方式,根據我自己的理解,把整一個思路用文字的形式串聯起來進行表達。或許你看完再回過頭去看書,會進一步的理解這些知識點。當然這裡面也肯定有我理解錯誤的地方,所以如果你有什麼不同的見解,希望不吝賜教,感謝!

相關文章