深入理解Java虛擬機器總結一垃圾收集器與記憶體分配策略(二)
深入理解Java虛擬機器總結一垃圾收集器與記憶體分配策略(二)
- 垃圾回收概述
- 如何判定物件為垃圾物件
- 垃圾回收演算法
- 垃圾收集器詳解
- 記憶體分配策略
垃圾回收概述
如何判定物件為垃圾物件
- 引用計數法: 在物件中新增一個引用計數器,當有地方引用這個物件的時候,引用計數器的值就+1,當引用失效的時候,計數器的值就-1;
簡單的驗證
注意要檢視JVM的內容,要在執行的時候新增相關的引數:
下面進行下圖的過程:
程式碼如下:
public class ReferenceCountingGC {
private static class Clazz{
public Object val;
}
public static void main(String[] args) {
Clazz A = new Clazz();
Clazz B = new Clazz();
//內部相互引用
A.val = B;
B.val = A;
//斷開外部的引用
A = null;
B = null;
//這裡可以回收,說明不是使用的引用計數法
System.gc(); //通知垃圾回收器回收
//使用的parallel 垃圾收集器 (沒有使用引用計數法)
}
}
看到執行結果:
在建構函式中新增記憶體(建立一個20MB的位元組陣列):
public class ReferenceCountingGC {
private static class Clazz{
public Object val;
public Clazz() {
byte[] bytes = new byte[20 * 1024 * 1024]; //20MB
}
}
public static void main(String[] args) {
Clazz A = new Clazz();
Clazz B = new Clazz();
//內部相互引用
A.val = B;
B.val = A;
//斷開外部的引用
A = null;
B = null;
//這裡可以回收,說明不是使用的引用計數法
System.gc(); //通知垃圾回收器回收
}
}
執行效果:
- 第二種判定垃圾物件的方法: 可達性分析法,這個可以解決上面引用計數法不能判定堆內部垃圾物件的問題;
如圖: 上面的四個物件存活,下面的三個物件為垃圾物件:
引用分類
測試:
import java.lang.ref.WeakReference;
import java.util.WeakHashMap;
/**
* 強與弱引用
*/
public class Refclassify {
public static void main(String[] args) {
System.out.println("---------------測試強引用-----------------");
//強引用: 字串常量池 (不能回收)
String str = "abc";
//弱引用管理 str 物件
WeakReference<String>wr = new WeakReference<String>(str);
System.out.println("GC執行前: " + wr.get());
str = null; // 斷開引用
//通知回收
System.gc();
System.runFinalization();
System.out.println("GC執行後: " + wr.get());
System.out.println("---------------測試弱引用-----------------");
//注意這裡是new String
String str2 = new String("abc");
WeakReference<String>wr2 = new WeakReference<String>(str2);
System.out.println("GC執行前: " + wr2.get());
str2 = null;
System.gc();
System.runFinalization();
System.out.println("GC執行後: " + wr2.get()); //弱引用管理--> 被回收
System.out.println("-------------WeakHashMap----------------");//鍵為弱型別,GC執行被回收
WeakHashMap<String,String>map = new WeakHashMap<>();
map.put("a","a1");
map.put("b","b1");
//下面兩個會被回收 如果map中佔用記憶體很大,希望執行後被回收,就可以使用這個
map.put(new String("c"),"c1");
map.put(new String("d"),"d1");
System.out.println(map.size());
System.gc();
System.runFinalization();
System.out.println(map.size());
}
}
執行結果:
生存還是死亡(finalize()方法)
總結:
- 物件可以在被GC時 自我拯救;
- 這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次;
/**
* 此程式碼演示了兩點:
* (1) 物件可以在被GC時 自我拯救;
* (2) 這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次;
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed !");
FinalizeEscapeGC.SAVE_HOOK = this; //最後的自救 --> 把自己(this關鍵字)賦值給某個類變數或者物件的成員變數
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
/**
* 第一次成功拯救自己
*/
SAVE_HOOK = null; //沒用的物件
System.gc(); // 通知回收
//在finalize()中拯救物件 --> 因為finalize()方法優先順序很低,所以暫停0.5秒以等待它
Thread.sleep(500);
if(SAVE_HOOK != null){
System.out.println("yes,I'm still alive !");
}else {
System.out.println("no ,I'm dead !");
}
/**
* 下面的程式碼和上面的完全相同,但是這次卻自救失敗,
* 因為任何一個物件的finalize()方法都只會被系統自動呼叫一次
* 如果進行下一次回收,它的finalize()方法不會再次執行;
*/
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
System.out.println("yes,I'm still alive !");
}else {
System.out.println("no ,I'm dead !");
}
}
}
執行結果:
回收方法區
垃圾回收演算法
標記清除演算法
標記清除演算法執行過程如下:
複製演算法
兩塊的複製過程:
拓展,關於新生代,老年代和持久代,具體看這篇部落格。
- ① 新生代(Young Generation):大多數物件在新生代中被建立,其中很多物件的生命週期很短。每次新生代的垃圾回收(又稱Minor GC)後只有少量物件存活,所以選用複製演算法,只需要少量的複製成本就可以完成回收。新生代內分三個區:一個Eden區,兩個Survivor區(一般而言),大部分物件在Eden區中生成。當Eden區滿時,還存活的物件將被複制到兩個Survivor區(中的一個)。當這個Survivor區滿時,此區的存活且不滿足“晉升”條件的物件將被複制到另外一個Survivor區。物件每經歷一次Minor GC,年齡加1,達到“晉升年齡閾值”後,被放到老年代,這個過程也稱為“晉升”。顯然,“晉升年齡閾值”的大小直接影響著物件在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,“晉升年齡閾值”通過引數MaxTenuringThreshold設定,預設值為15。
- ② 老年代(Old Generation):在新生代中經歷了N次垃圾回收後仍然存活的物件,就會被放到年老代,該區域中物件存活率高。老年代的垃圾回收(又稱Major GC)通常使用“標記-清理”或“標記-整理”演算法。整堆包括新生代和老年代的垃圾回收稱為Full GC(HotSpot VM裡,除了CMS之外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。
- ③ 永久代(Perm Generation):主要存放元資料,例如Class、Method的元資訊,與垃圾回收要回收的Java物件關係不大。相對於新生代和年老代來說,該區域的劃分對垃圾回收影響比較小。
標記整理演算法
分代收集演算法
垃圾收集器詳解
各個垃圾收集器的聯絡
下圖展示了7中垃圾收集器,如果有連線表示可以同時使用,上面是新生代,下面是老年代:
注意,這些收集器都有下面的原則:
- 單執行緒與多執行緒收集的不同:單執行緒指的是垃圾收集器只使用一個執行緒進行收集,而多執行緒使用多個執行緒;
- 序列與併發:序列指的是垃圾收集器與使用者程式交替執行,這意味著在執行垃圾收集的時候需要停頓使用者程式;併發指的是垃圾收集器和使用者程式同時執行。除了 CMS 和 G1 之外,其它垃圾收集器都是以序列的方式執行。
以下收集器圖片均來自這篇部落格。
Serial收集器
概括:
- 是單執行緒且序列的收集器;
- 它的優點是簡單高效,對於單個 CPU 環境來說,由於沒有執行緒互動的開銷,因此擁有最高的單執行緒收集效率;
- 現在依然是虛擬機器執行在 Client 模式下的預設新生代收集器;(因為在使用者的桌面應用場景下,分配給虛擬機器管理的記憶體一般來說不會很大。Serial 收集器收集幾十兆甚至一兩百兆的新生代停頓時間可以控制在一百多毫秒以內,只要不是太頻繁,這點停頓是可以接受的。)
ParNew收集器
概括:
- 它是 Serial 收集器的多執行緒版本;
- 是 Server 模式下的虛擬機器首選新生代收集器,除了效能原因外,主要是因為除了 Serial 收集器,只有它能與 CMS 收集器配合工作:
在JDK1.5 時期,HotSpot 推出了 CMS 收集器(Concurrent Mark Sweep),它是 HotSpot 虛擬機器中第一款真正意義上的併發收集器(收集執行緒和使用者執行緒同時執行)。不幸的是,CMS 作為老年代的收集器,卻無法與 JDK1.4.0 中已經存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5中使用 CMS 來收集老年代的時候,新生代只能選擇 ParNew 或者 Serial 收集器中的一個。
-
預設開啟的執行緒數量與 CPU 數量相同,可以使用 -XX:ParallelGCThreads 引數來設定執行緒數。
-
Parallel Scavenge 收集器以及後面提到的 G1 收集器都沒有使用傳統的 GC 收集器程式碼框架,而另外獨立實現,其餘集中收集器則共用了部分的框架程式碼。
併發和並行在垃圾收集器中的概念:
Parallel Scavenge收集器
概括:
- 新生代複製演算法,多執行緒收集器、吞吐量優先的收集器;
- 吞吐量指 CPU 用於執行使用者程式碼的時間佔總時間的比值 ;(吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間) );
Serial Old收集器
概括:
- Serial Old是Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,使用"標記整理演算法";
- 這個收集器的主要意義也是在於給Client模式下的虛擬機器使用;
- 在Server模式下,還有兩個用途: (1) . 在JDK1.5版本之前和Parallel Scavenge 收集器搭配使用;(2). 作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用;
Paralell Old收集器
概括:
- Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多執行緒和 ”標記-整理“ 演算法;
- 在注重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器;
CMS收集器
概括:
- CMS(Concurrent Mark Sweep),Mark Sweep 即是 標記 - 清除 演算法。主要優點:併發收集、低停頓,也稱之為併發低停頓收集器(Concurrent Low Pause Collection);
- 整個過程分為四個步驟: ① 初始標記(CMS initial Mark) (標記一下 GC Roots 能直接關聯到的物件,速度很快,需要停頓。); ② 併發標記(CMS concurrent mark)(時間最長);③重新標記(CMS remark)(需要停頓);④併發清除(CMS concurrent sweep);
- 可以注意到上面只有②和④過程是併發的,因為這兩個也是最佔時間的,所以這就是CMS的優點;
- 缺點:①吞吐量低:低停頓時間是以犧牲吞吐量為代價的,導致 CPU 利用率不夠高(對CPU資源非常敏感)。②無法處理浮動垃圾,可能出現 Concurrent Mode Failure。浮動垃圾是指併發清除階段由於使用者執行緒繼續執行而產生的垃圾,這部分垃圾只能到下一次 GC 時才能進行回收。由於浮動垃圾的存在,因此需要預留出一部分記憶體,意味著 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。如果預留的記憶體不夠存放浮動垃圾,就會出現 Concurrent Mode Failure,這時虛擬機器將臨時啟用 Serial Old 來替代 CMS。③標記 - 清除演算法導致的空間碎片,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件,不得不提前觸發一次 Full GC。
- 為了解決上面的由於標記清除演算法產生的空間碎片的問題:
①CMS 提供了一個開關引數-XX:+UseCMSCompactAtFullCollection(預設開啟),用於在 CMS 收集器頂不住要進行 Full GC 時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的。
②引數 -XX:CMSFullGCsBeforeCompaction 用於設定執行多少次不壓縮的 Full GC後,跟著來以此帶壓縮的,(預設值為0)。
G1收集器(參考: 文章一、文章二)
G1收集器運作步驟:
-
初始標記
-
併發標記
-
最終標記:為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程的 Remembered Set Logs 裡面,最終標記階段需要把 Remembered Set Logs 的資料合併到 Remembered Set 中。這階段需要停頓執行緒,但是可並行執行。
-
篩選回收:首先對各個 Region 中的回收價值和成本進行排序,根據使用者所期望的 GC 停頓時間來制定回收計劃。此階段其實也可以做到與使用者程式一起併發執行,但是因為只回收一部分 Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅度提高收集效率。
G1收集器的特點:
首先,G1的設計原則就是簡單可行的效能調優,其次,G1將新生代,老年代的物理空間劃分取消了。
-
取而代之的是,G1演算法將堆劃分為若干個區域(Region),它仍然屬於分代收集器。不過,這些區域的一部分包含新生代,新生代的垃圾收集依然採用暫停所有應用執行緒的方式,將存活物件拷貝到老年代或者Survivor空間。老年代也分成很多區域,G1收集器通過將物件從一個區域複製到另外一個區域,完成了清理工作。這就意味著,在正常的處理過程中,G1完成了堆的壓縮(至少是部分堆的壓縮),這樣也就不會有cms記憶體碎片問題的存在了。
-
在G1中,還有一種特殊的區域,叫Humongous區域。 如果一個物件佔用的空間超過了分割槽容量50%以上,G1收集器就認為這是一個巨型物件。這些巨型物件,預設直接會被分配在年老代,但是如果它是一個短期存在的巨型物件,就會對垃圾收集器造成負面影響。為了解決這個問題,G1劃分了一個Humongous區,它用來專門存放巨型物件。如果一個H區裝不下一個巨型物件,那麼G1會尋找連續的H分割槽來儲存。為了能找到連續的H區,有時候不得不啟動Full GC。(在java 8中,持久代也移動到了普通的堆記憶體空間中,改為元空間。)
-
每個 Region 都有一個 Remembered Set,用來記錄該 Region 物件的引用物件所在的 Region。通過使用 Remembered Set,在做可達性分析的時候就可以避免全堆掃描。
各種垃圾收集演算法的對比:
使用垃圾收集器常用的相關引數:
記憶體分配策略
Java技術體系中所說的自動記憶體管理歸結為解決了兩個問題:
- 給物件分配記憶體;
- 回收分配給物件的記憶體;
回收記憶體就是前面所講的回收演算法以及垃圾收集器,而物件分配記憶體,大方向講,就是在堆上分配(但也可能經過JIT編譯後被拆散為標量型別並間接的棧上分配),物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配緩衝,將按執行緒有限在TLAB上分配。
下面是幾條普遍的記憶體分配規則:
- 物件優先在Eden分配;
- 大物件直接進入老年代;
- 長期存活物件將進入老年代;
- 空間分配擔保;
- 動態物件年齡判定;
下面分配看這幾個分配規則。
物件優先在Eden分配
先看一個測試程式碼:
public class TestAllocation {
public static void main(String[] args){
byte[] b1 = new byte[2 * 1024 * 1024]; // 2MB
byte[] b2 = new byte[2 * 1024 * 1024];
byte[] b3 = new byte[2 * 1024 * 1024];
byte[] b4 = new byte[4 * 1024 * 1024];
}
}
先配置執行引數:
執行結果以及分析:
概括來說:
- 一般直接分配到Eden區域,但是如果Eden區域不夠,就進行Minor GC和分配擔保;
- 所以原來的6MB(b1、b2、b3)進入了分配擔保區(老年代中),然後新的b4繼續放入Eden區域;
另外,注意Minor GC和Full GC的區別:
大物件直接進入老年代
概括:
- 大物件是指需要連續記憶體空間的物件,最典型的大物件是那種很長的字串以及陣列。
- 大的物件不能一直放在新生代的Eden區域,因為這個區域是經常需要GC的部分,所以會降低效率,所以大的物件要放到老年代;
- 有一個預設的大小,當物件的大小超過這個值的時候,會進入老年代,也可以通過
-XX:PretenureSizeThreshold來設定這個值;大於此值的物件直接在老年代分配,避免在 Eden 區和 Survivor 區之間的大量記憶體複製。
在上面的例子的執行引數(堆大小為20MB)的環境下測試:
通過引數來修改這個預設值:
則此時7MB也會進入老年代:
長期存活物件將進入老年代
- 為物件定義年齡計數器,物件在 Eden 出生並經過 Minor GC 依然存活,將移動到 Survivor 中,年齡就增加 1 歲,增加到一定年齡則移動到老年代中。
- -XX:MaxTenuringThreshold 用來設定年齡的閾值(到了這個年齡就進入老年代)。
- 例子和上面差不多,只有設定引數不同,這裡不重複做了。
空間分配擔保
- 在發生 Minor GC 之前,虛擬機器先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果條件成立的話,那麼 Minor GC 可以確認是安全的。
- 如果不成立的話虛擬機器會檢視 HandlePromotionFailure(-XX:+HandlePromotionFailure(預設是開啟的)) 設定值是否允許擔保失敗;
- 如果允許那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC(儘管有風險);
- 如果小於,或者 HandlePromotionFailure 設定不允許冒險,那麼就要進行一次 Full GC。
動態物件年齡判定
- 虛擬機器並不是永遠地要求物件的年齡必須達到 MaxTenuringThreshold 才能晉升老年代;
- 如果在 Survivor 中相同年齡所有物件大小的總和大於 Survivor 空間的一半,則年齡大於或等於該年齡的物件可以直接進入老年代,無需等到 MaxTenuringThreshold 中要求的年齡。
關於逃逸分析以及棧上分配
堆的分配已經不是唯一:
- 逃逸分析:分析物件的作用域;
- 如果物件的作用域只是在方法體內(沒有發生逃逸),就可以不需要在堆上分配記憶體,而是可以在棧上分配記憶體;
看幾個逃逸和不逃逸的例子
public class StackAllocation {
public StackAllocation instance;
/**方法返回 StackAllocation物件,發生逃逸*/
public StackAllocation getInstance(){
return instance == null ? new StackAllocation() : instance;
}
/**為成員屬性賦值,也發生了逃逸*/
public void setInstance(){
this.instance = new StackAllocation();
}
/**引用成員變數,也發生了逃逸*/
public void use(){
StackAllocation s = getInstance();
}
/**物件的作用域僅在方法中有效,沒有發生逃逸*/
public void use2(){
StackAllocation s = new StackAllocation();
}
}