JVM是如何分配和回收內存?有實例!


上一篇博客我簡單介紹了下如何手動計算一個Java對象到底占用多少內存?今天就想聊下這個內存JVM到底是是如何分配和回收的。

Java整體來說還是一個GC比較友好的語言,無論是分代的垃圾收集,還是基於GC Roots的可達性算法都是業界普遍的經典做法,關於Java的內存區域划分以及GC的一些基本知識,我這里就不贅述了,可以看我之前的博客:http://zhanjindong.info/category/note/dsbj/

《深入理解Java虛擬機第2版》這本書非常值得一看,最近幾篇讀博客都算這本書的讀書筆記吧。本人文筆很爛,所以都是記流水賬枯燥乏味的文章。進入正文之前還是要交代下環境:以下內容都是基於HotSpot虛擬機Server模式,垃圾收集器用的是默認的Serial和Serial Old。

 

JVM內存分配和回收策略

話說一圖勝千言,本也打算畫張活動圖就了事了:

但是畫完發現:一畫圖更麻煩,太大了看的累(想看的可以在新建窗口放大了看),其次感覺還是說不清楚(畫的不對的地方歡迎批評),最后覺得還是文字描述一下整個流程:

1、當JVM給一個對象分配內存的時候,如果啟動了本地線程分配緩存,將按線程優先在TLAB上分片,TLAB只是起緩存作用減少高並發下CAS帶來的性能損失,跟GC的分代沒有沖突。

2、當分配一個對象的時候會優先在Eden區域分配,如果Eden有足夠的空間,那么內存分配很順利的結束,不會觸發任何GC操作;

3、當Eden區域空間不足的時候,會嘗試着進行一次Minor GC,之所以說嘗試是因為在進行Minor GC之前,虛擬機會檢查老年代最大可用的連續空間是否大於新生代所有對象空間總和,如果是的那么可以保證這次Minor GC順利進行;否則,虛擬機會檢查HandlePromotionFailure這個參數是否設置為允許擔保失敗,如果允許那么虛擬機會根據經驗值(這個經驗值是歷次晉升到老年代對象的平均大小)來決定是否嘗試這次GC,如果小於或者JVM覺得不能冒險,那么會進行一次Full GC;

4、Minor GC時會采用復制算法將所有存活的對象復制到Survivor空間中(既包括Eden區域存活的對象,也包括另外一個Survivor存活下來的對象),如果這時發現Survivor空間不足,那么這些存活對象會直接進入老年代,這就是“空間分配”擔保,前面說到冒險,是因為老年代的空間仍有可能不夠,這時還是要進行一次Full GC,但是除了極端情況,大部分時候通過擔保還是能有效避免頻繁的Full GC的,如果Full GC后仍然沒有足夠空間,那只能拋出OutOfMemoryError;

5、對象在Eden空間出生,經過第一次Minor GC后能夠順利的被轉移到Survivor的話,那么它的GC年齡就變成1,以后每在Survivor中熬過一次Minor GC,年齡就增加1,直到超過一定程度(-XX:MaxTenuringThreshold,默認15歲)則晉升到老年代;

6、規則是死的,人是活的,虛擬機開發人員還想到了一個“動態對象年齡判定”算法:如果Survivor區域中相同年齡所有對象大小總和超過Survivor空間的一半,年齡大於等於該年齡的對象就可以直接進去老年代;

7、對象也可以直接分配在老年代,這主要是針對那些大對象,因為大對象的內存分配代價比較大(需要連續的內存空間),所以JVM提供了-XX:PretenureSizeThreshold這個參數。

 

為什么有兩塊survivor區域

我一開始很納悶HotSpot虛擬機為什么要搞出兩個Survivor區域內,只用一塊有何不妥嗎?最后在Stack Overflow找到一個答案:

http://stackoverflow.com/questions/10695298/java-gc-why-two-survivor-regions 按照里面的說法是為了減少虛擬機對內存碎片的處理,我想了半天我的理解是:

因為survivor中的對象在達到“老年”(-XX:MaxTenuringThreshold)之前肯定有對象已經變成“垃圾”了,這時候必須要對其進行回收,如果只使用一個survivor的話,那么要不容忍survivor存在內存碎片,要么要對其進行內存整理,出於和對Eden區域同樣的考慮,所以實際上對Survivor的GC也是基於復制算法的,不過是從一個Survivor到另外一個Survivor(這也是GC日志中為什么叫from space和to space),所以Survivor的兩個區是對稱的,沒有先后關系,所以Survivor區中可能同時存在從Eden復制過來對象,以及從前一個Survivor復制過來的對象,某一次GC結束時肯定會有一個Survivor是空的。

 

實例說明

以上都是理論,下面結合一小段代碼簡單演示下上面的內容,這段代碼引自《深入理解Java虛擬機第2版》3.6.3節,我簡單展開說明下。為了方便解釋,我先把設置的虛擬機參數貼出來:

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=5242880
  • -XX:+UseSerialGC這里使用默認的Serial GC進行說明;
  • -verbose:gc是為了打印出GC日志;
  • -Xms初始Java堆為20M;
  • -Xmx20M JVM最大使用的堆為20M不可擴展;
  • -Xmn10M,新生代的內存空間為10M;
  • -XX:SurvivorRatio=8 Eden與兩個Survivor的比例是8:1:1;
  • XX:PretenureSizeThreshold直接在老年代區域分配對象的閾值為5M,其實可以不設置,這里為了明確變量。

測試代碼如下:

我們一步步來看,首先只執行48,49兩行:

allocation1 = new byte[_1MB / 2];
allocation2 = new byte[6 * _1MB]; 

通過GC日志發現沒有發生任何GC,Eden區域夠用(注意:關於一個Java對象導致占用多大內存,參看我前一篇博客。),接着執行51,52兩行:

allocation2 = null; 
allocation3 = new byte[2 * _1MB];

可以看到了引發了一次GC,這是一次Minor GC,同時可以看到使用的垃圾收集器是默認的(Def,關於GC日志的理解,可以參看《深入理解Java虛擬機第2版》一書)。引發GC的原因是Eden已經沒有足夠的內存容納allocation3對象,發生GC之后allocation2對象占用的內存空間被回收了,而allocation1“幸存”下來被轉移到了from survivor區域。

接下來我們再執行57,58,59三行代碼:

allocation4 = new byte[4 * _1MB]; 
allocation3 = allocation4 = null; 
allocation5 = new byte[2 * _1MB]; 

第59行代碼引發了第二次GC,仍然是一次Minor GC,可以看到allocation3和allocation4都被回收了,allocation5被順利的分配到了Eden空間,但是為什么from space變成了0%而老年代區域卻變成了6%,這6%應該是allocation1占用的,但是為什么跑到老年代了呢?顯然它的“年齡”還沒有到15歲啊。

 

啊哈!還記得嗎JVM很聰明,它會“動態對象年齡判定”,從上一張圖可以看到,Survivor區域已經使用超過了50%(67%),而且顯然是同一年齡的對象(就一個對象嘛),所以在第二次GC的時候它晉升到了老年代,大家可以把allocation1對象分配為256kb再試試。

Ok,到目前為止都是Minor GC,想Stop-The-World很簡單,我們直接分配一個很大的對象試試:

allocation6 = new byte[20*_1MB];

不僅Full GC了,而且內存溢出了,因為我們設置了-Xmx20M。同時可以看到JVM被逼急了在不同區域進行了GC,首先在新生代(Eden+Survivor0)將內存全部回收,導致對象晉升到老年代,其次在老年代和“永久代(HotSpot)”也都進行了GC,但是一點收獲都沒有,最后只能OOM。關於JVM的一些其他規則,比如大對象的分配,以及其他虛擬機的分配策略,就留給有興趣的同學自己試試了。

 

今天就寫到這,最后祝大家“六一快樂”……靠,看了下時間已經不是6.1,童年已經過去了!

PS:文章同步發布在我的個人博客http://zhanjindong.info/2014/06/02/jvm-memory-and-gc/

 

 


免責聲明!

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



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