淺談JVM內存分配與垃圾回收


大家好,我是微塵,最近又去翻了周志明老師的《深入理解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的問題的時候,不知道如何去講述。
所以本篇嘗試打破這種傳統的方式,根據我自己的理解,把整一個思路用文字的形式串聯起來進行表達。或許你看完再回過頭去看書,會進一步的理解這些知識點。當然這里面也肯定有我理解錯誤的地方,所以如果你有什么不同的見解,希望不吝賜教,感謝!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM