1. 程式人生 > 實用技巧 >13. 垃圾回收相關演算法

13. 垃圾回收相關演算法

1. 標記階段演算法

簡單來說,垃圾回收 分成兩步, 第一步找出垃圾,第二步進行回收,而標記階段使用的演算法,就是 為了找出誰是垃圾

  1. 在堆裡存放著幾乎所有的Java物件例項,在GC執行垃圾回收之前,首先需要區分出記憶體中哪些是存活物件,哪些是已經死亡的物件。
  2. 只有被標記為己經死亡的物件,GC才會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱為垃圾標記階段
  3. 那麼在JVM中究竟是如何標記一個死亡物件呢?簡單來說,當一個物件已經不再被任何的存活物件繼續引用時,就可以宣判為已經死亡。
  4. 判斷物件存活一般有兩種方式:引用計數演算法和可達性分析演算法。

1.1 引用計數演算法

  1. 引用計數演算法(Reference Counting)比較簡單,對每個物件儲存一個整型的引用計數器屬性。用於記錄物件被引用的情況。
  2. 對於一個物件A,只要有任何一個物件引用了A,則A的引用計數器就加1;當引用失效時,引用計數器就減1。只要物件A的引用計數器的值為0,即表示物件A不可能再被使用,可進行回收。

優點:

  • 實現簡單,垃圾物件便於辨識;
  • 判定效率高,回收沒有延遲性。

缺點:

  1. 它需要單獨的欄位儲存計數器,這樣的做法增加了儲存空間的開銷。
  2. 每次賦值都需要更新計數器,伴隨著加法和減法操作,這增加了時間開銷。
  3. 引用計數器有一個嚴重的問題,即無法處理迴圈引用的情況。這是一條致命缺陷,導致在Java的

因為第三點的嚴重性,JAVA 垃圾回收器中沒有使用這類演算法。

什麼是 迴圈引用

上面的圖中 , 物件一引用了物件二 , 物件二引用了物件三, 而物件三又重新指向了物件一,

而物件一是被外部引用的,所以它的計數器是2,

但是當外部的引用斷掉時, 計數器減1,仍然是1, 不會被清除,導致這三個物件 無法清除,造成記憶體洩漏

使用程式碼證明JAVA 中沒有使用引用計數演算法

public class RefCountGC {
    //這個成員屬性唯一的作用就是佔用一點記憶體
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;

        //顯式的執行垃圾回收行為,這裡發生GC,obj1和obj2能否被回收?
        // System.gc();
    }
}

上面程式碼的記憶體示意圖:

下面執行驗證一下

jvm引數: -XX:+PrintGCDetails 列印GC日誌

不進行垃圾回收時:使用了 16798k

手動進行GC: 只剩下了655k , 說明 這兩個物件確實被回收了

引用計數小結

  1. 引用計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的Python,它是同時支援引用計數和垃圾收集機制。
  2. 具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。
  3. Java並沒有選擇引用計數,是因為其存在一個基本的難題,也就是很難處理迴圈引用關係。Python如何解決迴圈引用?
    • 手動解除:很好理解,就是在合適的時機,程式碼中手動解除引用關係。
    • 使用弱引用weakref,weakref是Python提供的標準庫,旨在解決迴圈引用。

1.2 可達性分析演算法

可達性分析演算法:也可以稱為根搜尋演算法、追蹤性垃圾收集

  1. 相對於引用計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在引用計數演算法中迴圈引用的問題,防止記憶體洩漏的發生。
  2. 相較於引用計數演算法,這裡的可達性分析就是Java、C#選擇的。這種型別的垃圾收集通常也叫作追蹤性垃圾收集(Tracing Garbage Collection)

基本思路如下:

  1. 可達性分析演算法是以根物件集合(GCRoots)為起始點,按照從上至下的方式搜尋被根物件集合所連線的目標物件是否可達。
  2. 使用可達性分析演算法後,記憶體中的存活物件都會被根物件集合直接或間接連線著,搜尋所走過的路徑稱為引用鏈(Reference Chain)
  3. 如果目標物件沒有任何引用鏈相連,則是不可達的,就意味著該物件己經死亡,可以標記為垃圾物件。
  4. 在可達性分析演算法中,只有能夠被根物件集合直接或者間接連線的物件才是存活物件。

示意圖:

GC Roots 可以是哪些元素

列舉:

  1. 虛擬機器棧中引用的物件,比如:各個執行緒被呼叫的方法中使用到的引數、區域性變數等。
  2. 本地方法棧內JNI(通常說的本地方法)引用的物件方法區中類靜態屬性引用的物件,比如:Java類的引用型別靜態變數
  3. 方法區中常量引用的物件,比如:字串常量池(StringTable)裡的引用
  4. 所有被同步鎖synchronized持有的物件
  5. Java虛擬機器內部的引用。
  6. 基本資料型別對應的Class物件,一些常駐的異常物件(如:NullPointerException、OutofMemoryError),系統類載入器。
  7. 反映java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生代碼快取等。

總結一句話就是,除了堆空間外的一些結構,比如:虛擬機器棧、本地方法棧、方法區、字串常量池等地方對堆空間進行引用的,都可以作為GC Roots進行可達性分析

擴充套件:

除了這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件“臨時性”地加入,共同構成完整GC Roots集合。比如:在進行分代收集和區域性回收時(PartialGC)。

如果只針對Java堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到這個區域的物件完全有可能被其他堆區域的物件所引用,例如老年代等,這時候就需要一併將關聯的區域物件也加入GC Roots集合中去考慮,才能保證可達性分析的準確性。

總結:

  • 在進行可達性分析時, 要對目標區域進行隔離, 一般將目標區域外的物件作為 GC Roots,
  • 例如在大多數GC收集整個堆空間時, 就將堆外的物件,例如方法區作為GC Roots 的物件
  • 如果在特殊的 GC 中,單獨收集新生代,,就需要將新生代除外的區域的物件都考慮到,比如 老年代中引用新生代物件,此時老年代的物件也可以作為 GC Roots

可達性分析注意事項

  1. 如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。這點不滿足的話分析結果的準確性就無法保證。
  2. 這點也是導致GC進行時必須“Stop The World”的一個重要原因。即使是號稱(幾乎)不會發生停頓的CMS收集器中,列舉根節點時也是必須要停頓的。

2. 物件的 finalization 機制

  1. Java語言提供了物件終止(finalization)機制來允許開發人員提供物件被銷燬之前的自定義處理邏輯。
  2. 當垃圾回收器發現沒有引用指向一個物件,即:垃圾回收此物件之前,標記階段,總會先呼叫這個物件的finalize()方法。
  3. finalize() 方法允許在子類中被重寫,用於在物件被回收時進行資源釋放。通常在這個方法中進行一些資源釋放和清理的工作,比如關閉檔案、套接字和資料庫連線等。

注意 finaliza() 方法並不是必定是銷燬前呼叫的, 它也是確定此物件可不可以被銷燬的一個判斷因素,在標記階段呼叫

Object 類中 finalize() 原始碼

// 等待被重寫
protected void finalize() throws Throwable { }

2.1 finalize() 方法使用的注意事項

  1. 永遠不要主動呼叫某個物件的finalize()方法應該交給垃圾回收機制呼叫。

    • finalize() 方法是可以在標記階段導致物件復活的,但是如果手動執行過,那麼將不在判斷(finalize方法只可呼叫一次)
    • finalize()方法的執行時間是沒有保障的,它完全由GC執行緒決定,極端情況下,若不發生GC,則finalize()方法將沒有執行機會。因為jvm中有專門執行物件finalize方法的執行緒,此執行緒優先順序比較低,即使主動呼叫該方法,也不會因此就直接進行回收
  2. 一個糟糕的finalize()會嚴重影響GC的效能(寫個多重迴圈, 每個物件在標記時呼叫時,都可能執行)。

  3. 從功能上來說,finalize()方法與C++中的解構函式比較相似,但是Java採用的是基於垃圾回收器的自動記憶體管理機制,所以finalize()方法在本質上不同於C++中的解構函式。

2.2 物件的三種可能的狀態

由於finalize()方法的存在,可能會將物件復活,所以虛擬機器中的物件一般處於三種可能的狀態。

如果從所有的根節點都無法訪問到某個物件,說明物件己經不再使用了。一般來說,此物件需要被回收。

但事實上,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段(關入大牢)。一個無法觸及的物件有可能在某一個條件下“復活”自己,如果這樣,那麼對它立即進行回收就是不合理的

為此,定義虛擬機器中的物件可能的三種狀態。如下:

  1. 可觸及的:從根節點開始,可以到達這個物件。
  2. 可復活的:物件的所有引用都被釋放,但是物件有可能在finalize()中復活。(所有引用全部釋放,第一次標記)
  3. 不可觸及的:物件的finalize()被呼叫,並沒有重新使GC Roots跟節點的物件引用自己(關入大牢的物件沒有找到關係),所以沒有復活,那麼就會進入不可觸及狀態。不可觸及的物件不可能被複活,因為finalize()只會被呼叫一次。(finalize方法沒有復活自己,第二次標記)

以上3種狀態中,是由於finalize()方法的存在,進行的區分。只有在物件兩次標記,不可觸及時才可以被回收。

2.3 finalize() 具體執行過程

上一節已經說到, 判定一個物件objA是否可回收,至少要經歷兩次標記過程:

  1. 如果物件objA到GC Roots沒有引用鏈,則進行第一次標記。
  2. 進行篩選,判斷此物件是否有必要執行finalize()方法
    • 如果物件objA沒有重寫finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,則虛擬機器視為“沒有必要執行”,objA直接 判定為不可觸及的。(沒有關係,或者關係已經找過了)
    • 如果物件objA重寫了finalize()方法,且還未執行過,那麼objA會被插入到F-Queue佇列中,由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒觸發其finalize()方法執行。(如果關入大牢的有可能有關係,而且沒有找過,則把他們都放到一個房間中,讓他們打電話)
  3. finalize()方法是物件逃脫死亡的最後機會,稍後GC會對F-Queue佇列中的物件進行第二次標記。如果objA在finalize()方法中與引用鏈上的任何一個物件建立了聯絡,那麼在第二次標記時,objA會被移出“即將回收”集合。(如果找到打電話攀上關係,則復活)
  4. 之後,物件若再次出現沒有引用存在的情況。在這個情況下,finalize()方法不會被再次呼叫,物件會直接變成不可觸及的狀態,也就是說,一個物件的finalize()方法只會被呼叫一次。(如果復活後,再次關進來,則不會再讓他找關係,直接標記第二次)

使用程式碼證明上述觀點

下面的程式碼中, 使用類變數作為 GC Roots ,並且在物件回收時,在finalize 方法裡自救

public class CanReliveObj {
    public static CanReliveObj obj;//類變數,屬於 GC Root


    //此方法只能被呼叫一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("呼叫當前類重寫的finalize()方法");
        obj = this;//當前待回收的物件在finalize()方法中與引用鏈上的一個物件obj建立了聯絡
    }


    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            // 物件第一次成功拯救自己
            obj = null;
            System.gc();//呼叫垃圾回收器
            System.out.println("第1次 gc");
            // 因為Finalizer執行緒優先順序很低,暫停2秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
            System.out.println("第2次 gc");
            // 下面這段程式碼與上面的完全相同,但是這次自救卻失敗了
            obj = null;
            System.gc();
            // 因為Finalizer執行緒優先順序很低,暫停2秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 將finalize 方法註釋的情況

列印:

第1次 gc
obj is dead
第2次 gc
obj is dead

說明此物件在第一次 gc時直接就回收了

  • 將註釋放開

列印:

呼叫當前類重寫的finalize()方法
第1次 gc
obj is still alive
第2次 gc
obj is dead

在第一次gc時 ,呼叫了 finalize 方法,並又重新使類變數指向自己,復活

但是在第二次gc 時,發現finalize 方法 就沒有再執行了,直接被回收

3. GC Roots 溯源

本節將介紹使用各個工具檢視 GC Roots 集合

3.1 MAT 工具檢視

  1. MAT是Memory Analyzer的簡稱,它是一款功能強大的Java堆記憶體分析器。用於查詢記憶體洩漏以及檢視記憶體消耗情況。
  2. MAT是基於Eclipse開發的,是一款免費的效能分析工具。
  3. 大家可以在http://www.eclipse.org/mat/下載並使用MAT

獲取 dump 檔案

jvm的一個記憶體快照,可以被各個軟體分析,

下面將 演示如何將正在執行的 程式匯出 dump檔案

public class GCRootsTest {
    public static void main(String[] args) {
        List<Object> numList = new ArrayList<>();
        Date birth = new Date();

        for (int i = 0; i < 100; i++) {
            numList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("資料新增完畢,請操作:");
        new Scanner(System.in).next();
        numList = null;
        birth = null;

        System.out.println("numList、birth已置空,請操作:");
        new Scanner(System.in).next();

        System.out.println("結束");
    }
}

先將程式跑起來,將阻塞

方式一:命令列使用jmap

方式二:使用JVisualVM

第一步: 選中監視tab, 點選堆 Dump

第二步: 右擊另存為

第三步: 將上面程式 鍵盤輸入,繼續執行, 捕獲第二個快照

這樣我們就獲取到了兩個 記憶體快照, 一個是被區域性變數引用的,一個是釋放掉的

如何使用MAT 檢視堆記憶體快照

開啟 MAT ,選擇 File --> Open Heap Dump, 選擇 需要檢視的Dump檔案

選擇 Java Basics --> GC Roots

先後檢視兩個快照, 由於 區域性變數不再引用物件, 所以不在是GC Roots

釋 放後:

3.2 JProfiler 工具使用

不用 dump檔案, 檢視實時的 執行時程式

檢視當前程式中堆中最多的物件型別,並檢視其GC Roots

點選 :Live Memory --> All Object ,檢視 堆中最多的物件, 並右擊 ,點選Show Selection In Heap Walker

在顯示介面 選擇 References tab,檢視堆中該型別的所有例項, 然後可以選中某一個物件,選擇 Incoming References 選項, 再點選 Show Paths To FC Roots 按鈕,彈出框點選確認

然後就可以看到 選中的物件的GC Roots , 例如下面的案例中, 字串 "新增完畢,請操作" 物件 的 GC Roots 就是 out 物件, 因為被 System.out.println("新增完畢,請操作")列印

使用JProfiler 分析OOM

程式碼:

public class HeapOOM {
    byte[] buffer = new byte[1 * 1024 * 1024];//1MB

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();

        int count = 0;
        try{
            while(true){
                list.add(new HeapOOM());
                count++;
            }
        }catch (Throwable e){
            System.out.println("count = " + count);
            e.printStackTrace();
        }
    }
}

上面的程式碼 將會導致OOM, 可以開啟jvm指令,在出現OOM 時 自動生成 dump檔案

執行程式,jvm指令: -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError

輸出日誌: 出現了OOM,生成的dump檔案在 工程目錄下

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid14608.hprof ...
java.lang.OutOfMemoryError: Java heap space
	at com.atguigu.java.HeapOOM.<init>(HeapOOM.java:12)
	at com.atguigu.java.HeapOOM.main(HeapOOM.java:20)
Heap dump file created [7797849 bytes in 0.010 secs]
count = 6

開啟JProfiler, 可以在 超大物件 裡面找到它

也可以 查看出現OOM的執行緒:

4. 垃圾清除階段

上面第一節中, 說到了如何標記垃圾,那麼下面就開始清除垃圾,關於清除垃圾,也有不同的演算法

目前在JVM中比較常見的三種垃圾收集演算法是

  1. 標記-清除演算法(Mark-Sweep)
  2. 複製演算法(Copying)
  3. 標記-壓縮演算法(Mark-Compact)

4.1 標記——清除 演算法

標記-清除演算法(Mark-Sweep)是一種非常基礎和常見的垃圾收集演算法,該演算法被J.McCarthy等人在1960年提出並並應用於Lisp語言。

執行過程

當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被稱為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除

標記:垃圾收集器從引用根節點開始遍歷,標記所有被引用的物件。(這裡是標記不是垃圾的物件)

  • 一般是在物件的Header中記錄為可達物件。
  • 注意:標記的是引用的物件,不是垃圾!!

清除:垃圾收集器對堆記憶體從頭到尾進行線性的遍歷,如果發現某個物件在其Header中沒有標記為可達物件,則將其回收

流程示意圖, 先從跟節點 找出所有的 可達物件, 標記為"綠色",再遍歷整個物件列表,將沒有標記為綠色的清除

何為清除?

這裡所謂的清除並不是真的置空,而是把需要清除的物件地址回收,儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就覆蓋原有的地址。 (跟電腦硬碟的刪除一樣)

關於空閒列表是在為物件分配記憶體的時候提過:

如果記憶體規整

  • 採用指標碰撞的方式進行記憶體分配

如果記憶體不規整

  • 虛擬機器需要維護一個空閒列表
  • 採用空閒列表分配記憶體

標記-清除演算法的缺點

標記清除演算法的優點很明顯, 簡單 易理解 易於實現,但是缺點也很明顯

  1. 標記清除演算法的效率不算高
  2. 在進行GC的時候,需要停止整個應用程式,使用者體驗較差
  3. 這種方式清理出來的空閒記憶體是不連續的,產生記憶體碎片,需要維護一個空閒列表

4.2 複製演算法

  1. 為了解決標記-清除演算法在垃圾收集效率方面的缺陷,M.L.Minsky於1963年發表了著名的論文,“使用雙儲存區的Lisp語言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。
  2. M.L.Minsky在該論文中描述的演算法被人們稱為複製(Copying)演算法,它也被M.L.Minsky本人成功地引入到了Lisp語言的一個實現版本中。

核心思路:

將存放物件的記憶體空間分為兩塊,每次只使用其中一塊,在垃圾回收時,垃圾回收器也從跟節點開始遍歷,找到所有的可達物件,但是此時不標記, 而是直接將此物件複製到未被使用的記憶體塊中,之後全盤清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,最後完成垃圾回收

示意圖:

把可達的物件,直接複製到另外一個區域中複製完成後,from區裡面的物件就沒有用了,新生代裡面就用到了複製演算法

複製演算法的優缺點

優點

  1. 沒有標記和清除過程,實現簡單,執行高效
  2. 複製過去以後保證空間的連續性,不會出現“碎片”問題。

缺點

  1. 此演算法的缺點也是很明顯的,就是需要兩倍的記憶體空間。
  2. 因為物件的地址發生了改變,所有對此物件的使用的地方的引用都需要改變

注意事項

如果系統中的垃圾物件很多,複製演算法需要複製的存活物件數量並不會太大,效率較高,但是如果垃圾物件非常少的情況, 每次拷貝都幾乎全部拷貝了,然後清除也就清除了個寂寞,

所以在jvm 中新生代中, 由於垃圾回收頻率高,數量多,一次通常可以回收70% - 99% 的記憶體空間 ,回收價效比很高。所以現在的商業虛擬機器都是用這種收集演算法回收新生代。

4.3 標記 - 壓縮(或標記-整理,Mark - Compact) 演算法

複製演算法的高效性是建立在存活物件少、垃圾物件多的前提下的。這種情況在新生代經常發生,但是在老年代,更常見的情況是大部分物件都是存活物件。

如果依然使用複製演算法,由於存活物件較多,複製的成本也將很高。因此,基於老年代垃圾回收的特性,需要使用其他的演算法。

標記-清除演算法的確可以應用在老年代中,但是該演算法不僅執行效率低下,而且在執行完記憶體回收後還會產生記憶體碎片,所以JVM的設計者需要在此基礎之上進行改進。標記-壓縮(Mark-Compact)演算法由此誕生。

1970年前後,G.L.Steele、C.J.Chene和D.s.Wise等研究者釋出標記-壓縮演算法。在許多現代的垃圾收集器中,人們都使用了標記-壓縮演算法或其改進版本。

執行流程:

  1. 第一階段和標記清除演算法一樣,從根節點開始標記所有被引用物件
  2. 第二階段將所有的存活物件壓(或者說是整理)到記憶體的一端,按順序排放。之後,清理邊界外所有的空間。

示意圖:

標記-壓縮演算法與標記-清除演算法的比較

標記-壓縮演算法的最終效果等同於標記-清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱為標記-清除-壓縮(Mark-Sweep-Compact)演算法。

二者的本質差異在於

  • 標記-清除演算法是一種非移動式的回收演算法,
  • 標記-壓縮是移動式的。

而且在物件分配記憶體時可以看到,若記憶體區域是零散的,需要訪問空閒列表(標記-清除演算法回收地址到空閒列表)

但是如果使用標記-壓縮演算法, 標記的存活物件將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。

是否移動回收後的存活物件是一項優缺點並存的風險決策。

標記-壓縮演算法的優缺點

優點

  1. 消除了標記-清除演算法當中,記憶體區域分散的缺點,我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可。
  2. 消除了複製演算法當中,記憶體減半的高額代價。

缺點

  1. 從效率上來說,標記-整理演算法要低於複製演算法和標記清除-演算法
  2. 移動物件的同時,如果物件被其他物件引用,則還需要調整引用的地址
  3. 移動過程中,需要全程暫停使用者應用程式。即:STW

4.4 三種清除演算法的VS

三種演算法的橫縱對比:

標記清除 標記整理 複製
速率 中等 最慢 最快
空間開銷 少(但會堆積碎片) 少(不堆積碎片) 通常需要活物件的2倍空間(不堆積碎片)
移動物件
  1. 效率上來說,複製演算法是當之無愧的老大,但是卻浪費了太多記憶體。
  2. 而為了儘量兼顧上面提到的三個指標,標記-整理演算法相對來說更平滑一些,但是效率上不盡如人意,它比複製演算法多了一個標記的階段,比標記-清除多了一個整理記憶體的階段。

總結: 沒有最好的演算法,只有最適合的演算法

4.5 分代收集演算法

前面所有這些演算法中,並沒有一種演算法可以完全替代其他演算法,它們都具有自己獨特的優勢和特點。

分代收集演算法應運而生。他的目標不是替換上面的演算法,而是具體問題 具體對待

分代收集演算法,是基於這樣一個事實:不同的物件的生命週期是不一樣的。因此,不同生命週期的物件可以採取不同的收集方式,以便提高回收效率。

一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點使用不同的回收演算法,以提高垃圾回收的效率。

在Java程式執行的過程中,會產生大量的物件,其中有些物件是與業務資訊相關:

  • 比如Http請求中的Session物件、執行緒、Socket連線,這類物件跟業務直接掛鉤,因此生命週期比較長。
  • 但是還有一些物件,主要是程式執行過程中生成的臨時變數,這些物件生命週期會比較短,比如:String物件,由於其不變類的特性,系統會產生大量的這些物件,有些物件甚至只用一次即可回收。

目前幾乎所有的GC都採用分代收集演算法執行垃圾回收的

每個代的各個特點和適合的回收演算法

年輕代(Young Gen)

  • 年輕代特點:區域相對老年代較小,物件生命週期短、存活率低,回收頻繁。
  • 這種情況複製演算法的回收整理,速度是最快的。複製演算法的效率只和當前存活物件大小有關,因此很適用於年輕代的回收。而複製演算法記憶體利用率不高的問題,通過hotspot中的兩個survivor的設計得到緩解。(總體佔用比例較小)

老年代(Tenured Gen)

  • 老年代特點:區域較大,物件生命週期長、存活率高,回收不及年輕代頻繁。
  • 這種情況存在大量存活率高的物件,複製演算法明顯變得不合適。一般是由標記-清除或者是標記-清除與標記-整理的混合實現。
    • Mark(標記)階段的開銷與存活物件的數量成正比。標記的是存活的物件,存活的越多,標記的越多,越慢
    • Sweep階段(標記清除的清除階段)的開銷與所管理區域的大小成正相關。全盤遍歷所有需要清理的區域, 管理的區域越大, 越慢
    • Compact階段(標記整理的整理階段)的開銷與存活物件的資料成正比。存活的越多,整理的物件越多,越慢

簡單介紹CMS 回收器

  1. 以HotSpot中的CMS回收器為例,CMS是基於Mark-Sweep實現的,對於物件的回收效率很高。
  2. 對於碎片問題,CMS採用基於Mark-Compact演算法的Serial Old回收器作為補償措施:當記憶體回收不佳(碎片導致的Concurrent Mode Failure時),將採用Serial Old執行Full GC以達到對老年代記憶體的整理。
  3. 分代的思想被現有的虛擬機器廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代

4.6 增量收集演算法

上述現有的演算法,在垃圾回收過程中,應用軟體將處於一種Stop the World的狀態。在Stop the World狀態下,應用程式所有的執行緒都會掛起,暫停一切正常的工作,等待垃圾回收的完成。

如果垃圾回收時間過長,應用程式會被掛起很久,將嚴重影響使用者體驗或者系統的穩定性。為了解決這個問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的誕生。

基本思路:

  1. 如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行。每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接著切換到應用程式執行緒。依次反覆,直到垃圾收集完成。
  2. 總的來說,增量收集演算法的基礎仍是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間。

缺點:

因為執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

4.7 分割槽演算法

  1. 一般來說,在相同條件下,堆空間越大,一次GC時所需要的時間就越長,有關GC產生的停頓也越長。
  2. 為了更好地控制GC產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若干個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。
  3. 分代演算法將按照物件的生命週期長短劃分成兩個部分,分割槽演算法將整個堆空間劃分成連續的不同小區間。每一個小區間都獨立使用,獨立回收。這種演算法的好處是可以控制一次回收多少個小區間。

5. 寫在最後

注意,這些只是基本的演算法思路,實際GC回收器過程要複雜的多,目前還在發展中的前沿GC都是複合演算法,並且並行和併發兼備。 所以這裡覺得模糊的,到後面把各個GC 回收器的實現說明完,就清晰了.