1. 前言 記憶體分配與回收策略 JVM堆的結構分析(新生代、老年代、永久代) 對象優先在Eden分配 大對象直接進入老年代 長期存活的對象將進入老年代 動態對象年齡判定 空間分配擔保 JVM堆的結構分析(新生代、老年代、永久代) 對象優先在Eden分配 大對象直接進入老年代 長期存活的對象將進入老年 ...
1. 前言
- 記憶體分配與回收策略
- JVM堆的結構分析(新生代、老年代、永久代)
- 對象優先在Eden分配
- 大對象直接進入老年代
- 長期存活的對象將進入老年代
- 動態對象年齡判定
- 空間分配擔保
2. 垃圾收集器與記憶體分配策略
Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決兩個問題:
- 給對象分配記憶體;
- 回收分配給對象的記憶體。
對象的記憶體分配,往大方向上講就是在堆上的分配,對象主要分配在新生代的Eden區上。少數也可能分配在老年代,取決於哪一種垃圾收集器組合,還有虛擬機中的相關記憶體的參數設置。下麵先介紹一下JVM中的年代劃分:新生代、老年代、永久代(JDK1.8後稱為元空間)。
2.1 JVM堆的結構分析(新生代、老年代、永久代)
HotSpot JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(分別叫from(S1)和to(S2)),具體可參下麵的JVM記憶體體系圖。Eden和Survival的預設分配比例為8:1。一般情況下,新創建的對象都會被分配到Eden區(一些大對象特殊處理,後面會說到),這些對象經過第一次Minor GC後,如果仍然存活,將會被移到Survivor區。對象在Survivor區中每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到年老代中。
因為年輕代中的對象基本都是朝生夕死的(80%以上),所以在年輕代的垃圾回收演算法使用的是複製演算法,複製演算法的基本思想就是將記憶體分為兩塊,每次只用其中一塊,當這一塊記憶體用完,就將還活著的對象複製到另外一塊上面。複製演算法不會產生記憶體碎片。
在GC開始的時候,對象只會存在於Eden區和名為“From”的Survivor區,Survivor區“To”是空的。緊接著進行GC,Eden區中所有存活的對象都會被覆制到“To”,而在“From”區中,仍存活的對象會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設置)的對象會被移動到年老代中,沒有達到閾值的對象會被覆制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名為To的Survivor區域是空的。Minor GC會一直重覆這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會將所有對象移動到年老代中。
在年輕代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命周期較長的對象。
永久代主要用於存放靜態文件,Java類、方法等。永久代對垃圾回收沒有顯著影響,但是有些應
用可能動態生成或者調用一些class,例如Hibernate 等,在這種時候需要設置一個比較大的持永久代空間來存放這些運行過程中新增的類。永久代大小通過-XX: MaxPermSize = <N> 進行設置。
2.2 對象在Eden上分配
大多數新生代對象都在Eden區中分配。當Eden區沒有足夠的空間進行分配時,虛擬機將發起一次Minor GC。
下麵做一個測試程式demo,詳細說明,新生代對象在Eden區的記憶體分配情況。嘗試分配3個2MB大小和一個4MB大小的對象,在運行時候通過VM參數設置(看代碼註釋),限制java堆大小為20MB,不可擴展,其中10M分配給新生代,10M分給老年代,需要註意的是Eden區與一個Survivor區的空間比例是8:1,從輸出結果也可以看出"eden space 8192K,from space 1024K,to space 1024K"的信息,新生代的總空間為9216KB(endn區+1個survivor區的總容量)。測試代碼如下:
public class Minor_GC { private static final int _1MB = 1024 * 1024; /* * VM 參數配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails */ 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]; // 出現一次GC回收 } }
輸出GC日誌如下:
上述參數可以看出: 執行main函數中,分配給allocation4對象時候發生了一次Minor GC(新生代回收),這次GC的結果是新生代記憶體7684k---->365k,然而堆上總記憶體的占用幾乎沒有改變,因為allocation1、allocation2、allocation3都存活,本次回收基本上沒有找到可回收的對象。分析如下:
- 新生代一共被分配10M,其中Enden:8M,survivor:2M(From:1M,To:1M);
- 給allocation4分配記憶體時,Eden已經被占用6M(allocation1、2、3共6M,所以剩下2M),所以記憶體已經不夠用了---->發生GC;
- 然而,6M放不進Survivor的From(只有1M),所以只能通過分配擔保機制提前轉移到老年代
這次GC結束後,Eden中有4M的allocation4對象(一共8M,被占用50%左右),survivor為空閑,老年代為6M(被allocation1、2、3占用),日誌中顯示為6146k,其中老年代採用Mark-sweep(標誌清除)回收的方法。
[註意]:區別新生代(Minor GC)和老年代(Full GC):
- 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大多數都具備朝生夕滅的特性,所以Minor GC非常的頻繁,一般回收速度也比較快;
- 老年代GC(Major GC/Full GC):指發生在老年代的垃圾回收動作,出現Major GC,經常會有至少一次的MinorGC(因為對象大多數都是先在Eden分配空間的,但是並非絕對)。Major GC回收的速度會比Minor GC慢十倍以上(因為Minor GC回收一般都是大面積的回收採用複製演算法;而Major GC沒有額外空間為他擔保,只能採用標記-清理方法),這兩者的回收思路是相反的,是一個空間換時間和時間換空間的關係。
2.2 大對象直接進入老年代
大對象是指需要大量記憶體空間的Java對象,最典型的大對象就是那種很長的字元串和數組(byte[ ]就是典型的大對象)。出現達對象很容易導致記憶體還有不少空間就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
虛擬機提供了一個-XX:pretenureSize Threshold()參數,令大於這個設置直的對象直接在老年代分配。這樣做的目的是避免Eden和Survivor區之間發生大量的記憶體複製(新生帶採用複製的方法完成GC)。下麵做個測試demo說明問題:
public class Major_GC { private static final int _1MB = 1024 * 1024; /* * VM 參數配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:PretenureSizeThreshold=3145728(等於3M) */ public static void main(String args[]){ byte[] allocation; allocation = new byte[4 * _1MB]; // 直接會分配到老年代 } }
運行後可以看到,記憶體會直接在老年代分配。[說明]:這裡不給出運行結果,以免產生誤導,因為在Parallel Scavenge收集器是不支持PretenureSizeThreshold這個參數的,得不到這樣的結論。
2.3 長期存活對象將進入老年代
Java虛擬機採用分代收集的思想來管理虛擬機記憶體。虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並且經過第一次Minor GC後仍然存活,並且能被Survivor的話,將被移動到Survivor空間中,並且對象年齡增加到一定程度(預設15歲),就會被晉升到老年代。對晉升到老年代的對象的閾值可以通過-XX:MaxTenuringThreshold設置。
下麵給出測試demo:
public class LongTimeExistObj { private static final int _1MB = 1024 * 1024; /* * VM 參數配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:MaxTenuringThreshold=1 * -XX:+PrintTenuringDistribution */ public static void main(String args[]){ byte[] allocation1,allocation2,allocation3; allocation1 = new byte[_1MB/4]; // 什麼時候進入老年代取決於-XX:MaxTenuringThreshold的設置 allocation2 = new byte[4 * _1MB]; allocation3 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; } }
測試結果如下所示:
2.4 動態對象年齡判定
虛擬並不是永遠都要求對象年齡必須達到MaxTenuringThreshold才能晉升為老年代的,如果在Survivor的空間相同年齡的所有對象大小總和大於Survivor空間的一半時,年齡大於或者等於該年齡的對象直接靜如老年代,無需要等到MaxTenuringThreshold中要求的年齡。
下麵做一個動態年齡測試demo:
public class LongTimeExistObj { private static final int _1MB = 1024 * 1024; /* * VM 參數配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:MaxTenuringThreshold=15 * -XX:+PrintTenuringDistribution */ @SuppressWarnings("unused") public static void main(String args[]){ byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[_1MB/4]; // 使得allocation1 + allocation2 > survivor空間的一半(0.5M) allocation2 = new byte[_1MB/4]; allocation3 = new byte[4 * _1MB]; allocation4 = new byte[4 * _1MB]; allocation4 = null; allocation4 = new byte[4 * _1MB]; } }
測試結果如下:
執行代碼結果中,可以看出:Survivor區占用空間仍然為0(from = 0,to = 0);而老年代的記憶體使用為5M,而其他對象都為4M,可以知道,alloccation1和allocation2都在沒有達到15歲的時候就提前進入了老年代。驗證了我們的結論---->在Survivor的空間相同年齡的所有對象大小總和大於Survivor空間的一半時,年齡大於或者等於該年齡的對象直接靜如老年代。
2.5 空間分配擔保
在發生Minor GC之前,虛擬機會先檢查老年代可用的連續空間是否大於所有新生代的總空間,如果大於的話,那麼這個GC就可以保證安全,如果不成立的,那麼可能會造成晉升老年代的時候記憶體不足。在這樣的情況下,虛擬機會先檢查HandlePromotionFailure設置值是否允許擔保失敗,如果是允許的,那麼說明虛擬機允許這樣的風險存在並堅持運行,然後檢查老年代的最大連續可用空間是否大於歷次晉升老年代對象的平均大小,如果大於的話,就執行Minor GC,如果小於,或者HandlePromotionFailure設置不允許冒險,那麼就會先進行一次Full GC將老年代的記憶體清理出來,然後再判斷。
上面提到的風險,是由於新生代因為存活對象採用複製演算法,但為了記憶體利用率,只使用其中的一個Survivor空間,將存活的對象備份到Survivor空間上,一旦出現大量對象在一次Minor GC以後依然存活(最壞的計劃就是沒有發現有對象死亡需要清理),那麼就需要老年代來分擔一部分記憶體,把在Survivor上分配不下的對象直接進入老年代,因為我們不知道實際上具體需要多大記憶體,我們只能估算一個合理值,這個值採用的方法就是計算出每次晉升老年代的平均記憶體大小作為參考,如果需要的話,那就提前進行一次Full GC.
取平均值在大多數情況下是可行的,但是因為記憶體分配的不確定性太多,保不定哪次運行突然出現某些大對象或者Minor GC以後多數對象依然存活,導致記憶體遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。這樣的情況下,擔保失敗是要付出代價的,大部分情況下都還是會將HandlePromotionFailure開關打開,畢竟失敗的幾率比較小,這樣的擔保可以避免Full GC過於頻繁,垃圾收集器頻繁的啟動肯定是不好的。
上面很繁瑣(詳細),實在看不下去就看圖吧:
文中關於新生代、老年代的概念部分內容參考了博文:https://www.cnblogs.com/E-star/p/5556188.html
本文參考書籍:《深入理解java虛擬機》