Chapter 3-9. GC - Memory Allocation Demo
在Java技術體系中所提倡的自動記憶體管理最終可以歸納為自動化地解決了下面這兩個問題:
給物件分配記憶體
回收分配給物件的記憶體
在前面的筆記裡面已經把很多基本的概念都帶過了, 這邊就要來實際操作看看前面提過的各種東西.
關於物件的記憶體分配, 粗略一點說, 就是要在Java Heap上分配, 物件主要分配在新生代的Eden區上面, 若啟動了前面提到過的TLAB, 那就會在TLAB上先分配. 當然也有少數情況是會直接分到老年代裡面的, 由於分配的規則並不是完全固定的, 這些都必須要取決於你當下用了哪種GC組合還有各種帶有不同效果的vm options.
我在這邊是按照"深入理解Java虛擬機"這本書提到的各種情境去做練習的, 但前面有提到過, 由於JDK版本不match的關係, 所以結果可能不會那麼的精準, 就當做個參考看看吧. 要注意的是, 我在範例中使用的GC組合是: Serial/SerialOld.
物件優先在Eden分配
我們知道, 新物件基本上都會先進Eden區, 然後Eden區沒有足夠空間的時候, JVM就會發動Minor GC. 範例程式如下:
package idv.java.jvm.gc.memoryallocate.eden;
/**
* @author Carl Lu
* VM args: -Xloggc:gclog-MinorGCDemo.log -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:-UseCompressedClassPointers -XX:-UseCompressedOops
*/
public class MinorGCDemo {
private static final int _1MB = 1024 * 1024;
public static void main(String args[]) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; //這裡會觸發一次Minor GC
}
}GC log如下:
不只這個範例, 在接下來的範例我會有幾個設定都是固定的, 像是這幾個:
-Xloggc:gclog-xxx.log: 這就只是指定log名稱而已, xxx大概就會跟class name一樣
-Xms20M: 限制Java Heap的最小size為20MB
-Xmx20M: 限制Java Heap的最大size為20MB
-Xmn10M: 把Java Heap中的10MB分給新生代, 所以綜合這三個參數, 表示說新生代跟老年代各佔10MB
-XX:+PrintGCDetails: 告訴JVM在發生GC的時候記得印出回收log
-XX:SurvivorRatio=8: 定義Eden/Survivor的記憶體區塊比例為8:1, 因為前面設定新生代佔了10MB, 所以這邊的新生代總可用空間(就是一個Eden + 一個Survivor)應該為(8 + 1) * 1MB = 9 MB = 9216 KB (這邊只會算一個Survivor, 另一個備用的那個Survivor同樣是1MB, 但它不會被列入當前正在使用的空間裡, 至於原因, 可以回去前面的章節看)
-XX:-UseCompressedClassPointers: 這預設會打開, 但這邊我不想要這個所以先關掉它.
-XX:-UseCompressedOops: 這預設會打開, 但這邊我不想要這個所以先關掉它.
大物件直接進入老年代
通常這邊講的大物件是說需要大量連續記憶體空間的Java物件, 譬如說很長的字串或是陣列. 大物件對JVM來說通常都是壞消息, 畢竟分配上比小物件麻煩, 還有一種更麻煩的是同時來了一群短命的大物件, 這種事情能避免的話還是避免吧. 大物件出現得越頻繁, 越容易提前觸發GC. 範例程式如下:
GC log如下:
關於這部分的說明我已經寫在commit message裡了, 可以點範例程式的連結去看, 這裡我只記錄一下以下這個參數:
-XX:PretenureSizeThreshold=3145728: 這個參數的意思是說, size大於這個threshold的物件就直接送進老年代. 原因是想要避免在Eden跟Survivor之間發生大量的記憶體複製(應該還記得新生代基本上都採用Copying演算法吧?). 然後為什麼要寫3145728這個數字呢? 因為3145728 = 3 * 1024 * 1024, 也就是3MB的意思, 原因是這個參數不能像-Xmx這類參數一樣直接寫個3MB就搞定. 最後, 這個參數只對Serial/ParNew這兩個collector有用, Parallel Scavenge不認得這個參數. 通常若你想要用這個參數的話, 可以考慮使用ParNew + CMS的組合.
長期存活的物件將進入老年代
這個範例參照書上的去做已經不準確了, 因為collector的演算法有更動過了, 但還是可以透過一些奇技淫巧把它弄出來, 範例程式如下:
GC log如下:
-XX:MaxTenuringThreshold=1的情況(一次就衝進去了):
-XX:MaxTenuringThreshold=7的情況(還沒到門檻的七次就衝進去了):
這邊要討論的參數是這個:
-XX:MaxTenuringThreshold: 基本上, JVM會給每個物件定義年齡, 若物件在Eden出生且能熬過第一次Minor GC, 然後又能被Survivor容納的話, 就可以進入Survivor, 且此時其年齡為1. 之後每在Survivor中熬過一次Minor GC, 就變老一歲, 而在早期的預設值中, 活到15歲的物件就可以進入老年代. 而這個參數就是用來定義這個年齡門檻的, 但...我發現這個在JDK8裡面的行為已經不太一樣了, 有興趣的請自行參考範例的commit history, 裡面有寫原因.
動態物件年齡判定
這個範例可以算是對前面一個範例出現的問題所產生的一個解釋, 範例程式如下:
GC log如下:
有發生promotion的情況:
沒發生promotion的情況(把程式的13跟14行註解掉就可以看到這個結果了):
關於動態物件年齡的判定: JVM並不是永遠地要求物件的年齡必須達到MaxTenuringThreshold才可以升級到tenured generation, 若在survivor空間中, 相同年齡的所有物件大小之總和大於survivor空間的一半(在上面的範例, 就是1MB/2 = 512KB), 年齡大於或等於該年齡的物件就可以直接進入tenured generation, 不需要等到MaxTenuringThreshold所要求的年齡.
空間分配擔保
這邊要講的東西比較複雜, 先貼範例程式:
GC log如下:
把-XX:+PrintTenuringDistribution打開的log:
把-XX:+PrintTenuringDistribution關掉的log(我覺得看起來比較乾淨):
所謂的空間分配擔保機制是這樣的: 在發生Minor GC之前, JVM會先檢查老年代最大可用的連續記憶體空間是否大於新生代所有物件的總記憶體空間, 若這個條件成立, 那麼Minor GC就可以確保是安全的. 反之, JVM就會去看HandlePromotionFailure的設定值(-XX:+HandlePromotionFailure是允許擔保失敗, 即要去handle promotion failure; -XX:-HandlePromotionFailure是不允許擔保失敗, 即不去handle promotion failure), 看其是否允許擔保失敗(Promotion Failure). 如果允許, 那麼會繼續檢查老年代最大可用的連續記憶體空間是否大於歷次晉升至老年代的物件之平均大小, 若有大於, 就嘗試進行一次Minor GC, 儘管這個Minor GC可能隱含著風險(這個風險的定義等等下面會詳細說明); 若是小於, 或著HandlePromotionFailure設置不準冒這種風險, 那這時就改成進行一次Full GC.
那風險是什麼? 我們知道新生代用的是copying演算法, 但為了記憶體的利用率, 我們只會使用兩個Survivor區塊的其中一區來作為備份, 所以如果出現了大量物件在Minor GC後還是活著的情況下, 講極端點, 全員生還(100%), 這樣就要老年代來擔保, 把Survivor無法容納的物件都送進老年代去. 這跟你去銀行貸款很像, 你的擔保人(老年代)要幫你擔保, 他本身口袋也要夠深(記憶體空間要夠), 然而, 實際會有多少物件會活下來在實際完成GC之前是不知道的, 所以只好取之前每一次回收時, 晉升到老年代的物件之容量平均大小來作為一個評估值, 並拿此評估值來與老年代的剩餘空間作比較, 看是否要發動Full GC來讓老年代釋放出更多空間.
其實這種取平均值的方式本身就是一種仰賴動態機率的手段, 這背後隱含了一個問題: 如果某次Minor GC存活後的物件暴增了, 且遠遠高於平均值, 那還是會出現Handle Promotion Failure. 如果是這樣, 就只好於失敗後發動Full GC了. 儘管如此, 通常還是會把HandlePromotionFailure這個開關打開, 畢竟這樣還是有很高的機率可以避免頻繁的出現Full GC.
好, 寫了這麼多, 但我覺得接下來的才是最重要的: 因為上面講的這一串都只適用於JDK6u24之前, 在JDK6u24之後, HandlePromotionFailure這個參數基本上已經不會再被使用了(不要打我), 雖然據說你在JDK的原始碼中還是看得到它, 但就是沒有被用到. 在新的JDK實作中, 上述的規則已經變更成: 只要老年代的連續記憶體空間大於新生代物件總大小或著歷次晉升物件的平均大小, 就會發動Minor GC, 反之則進行Full GC.
Last updated