1. 程式人生 > 實用技巧 >【JVM之記憶體與垃圾回收篇】垃圾回收相關演算法

【JVM之記憶體與垃圾回收篇】垃圾回收相關演算法

垃圾回收相關演算法

標記階段:引用計數演算法

在堆裡存放著幾乎所有的 Java 物件例項,在 GC 執行垃圾回收之前,首先需要區分出記憶體中哪些是存活物件,哪些是已經死亡的物件。只有被標記為己經死亡的物件,GC 才會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱為垃圾標記階段

那麼在 JVM 中究竟是如何標記一個死亡物件呢?簡單來說,當一個物件已經不再被任何的存活物件繼續引用時,就可以宣判為已經死亡。

類比:
人會死三次,第一次是他斷氣的時候,在生物學上他死了;
第二次是他下葬的時候,人們來參加他的葬禮,懷念他的一生,然後他在社會中死了,不再有他的位置;
第三次是最後一個記得他的人把他忘記的時候,那時候他才真的死了。”

死亡物件其實就跟我們所說的第三次死亡是一個道理,當所有記住你的人都把你忘記了,那麼你才是真的死了。

判斷物件存活一般有兩種方式:引用計數演算法可達性分析演算法。

引用計數演算法(Reference Counting)比較簡單,對每個物件儲存一個整型的引用計數器屬性用於記錄物件被引用的情況。

對於一個物件 A,只要有任何一個物件引用了 A,則 A 的引用計數器就加 1;當引用失效時,引用計數器就減 1。只要物件 A 的引用計數器的值為 0,即表示物件 A 不可能再被使用,可進行回收。

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

缺點:

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

迴圈引用

當 p 的指標斷開的時候,內部的引用形成一個迴圈,這就是迴圈引用,從而造成記憶體洩漏

[](

舉例

我們使用一個案例來測試 Java 中是否採用的是引用計數演算法

/**
 * 引用計數演算法測試
 *
 * @author: Nemo
 */
public class RefCountGC {
    // 這個成員屬性的唯一作用就是佔用一點記憶體
    private byte[] bigSize = new byte[5*1024*1024];
    // 引用
    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;
        // 顯示的執行垃圾收集行為,判斷obj1 和 obj2是否被回收?
        System.gc();
    }
}

執行結果

[GC (System.gc()) [PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K), 0.0061980 secs] [Times: user=0.00 sys=0.00, real=0.36 secs] 
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->672K(175104K)] 816K->672K(251392K), [Metaspace: 3479K->3479K(1056768K)], 0.0045983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 655K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
  eden space 65536K, 1% used [0x000000076b500000,0x000000076b5a3ee8,0x000000076f500000)
  from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
  to   space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
 ParOldGen       total 175104K, used 672K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
  object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ea8070,0x00000006cc900000)
 Metaspace       used 3486K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 385K, capacity 388K, committed 512K, reserved 1048576K

我們能夠看到,上述進行了 GC 收集的行為,將上述的新生代中的兩個物件都進行回收了

PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K)

如果使用引用計數演算法,那麼這兩個物件將會無法回收。而現在兩個物件被回收了,說明 Java 使用的不是引用計數演算法來進行標記的。

[](

小結

引用計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的 Python,它更是同時支援引用計數和垃圾收集機制。

具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。

Java 並沒有選擇引用計數,是因為其存在一個基本的難題,也就是很難處理迴圈引用關係。

Python 如何解決迴圈引用?

  • 手動解除:很好理解,就是在合適的時機,解除引用關係。
  • 使用弱引用 weakref,weakref 是 Python 提供的標準庫,旨在解決迴圈引用。

標記階段:可達性分析演算法

概念

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

相對於引用計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在引用計數演算法中迴圈引用的問題,防止記憶體洩漏的發生。

相較於引用計數演算法,這裡的可達性分析就是 Java、C# 選擇的。這種型別的垃圾收集通常也叫作追蹤性垃圾收集(Tracing Garbage Collection)

思路

所謂“GCRoots”根集合就是一組必須活躍的引用。

基本思路:

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

[](

官場上的裙帶關係,可達性分析在人類關係網中

[](

GC Roots 可以是哪些元素?

在 Java 語言中,GC Roots 包括以下幾類元素:

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

[](

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

  • 如果只針對 Java 堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到記憶體區域是虛擬機器自己的實現細節,更不是孤立封閉的,這個區域的物件完全有可能被其他區域的物件所引用,這時候就需要一併將關聯的區域物件也加入 GCRoots 集合中去考慮,才能保證可達性分析的準確性。

總結

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

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

  • 如果只針對 Java 堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到記憶體區域是虛擬機器自己的實現細節,更不是孤立封閉的,這個區域的物件完全有可能被其他區域的物件所引用,這時候就需要一併將關聯的區域物件也加入 GCRoots 集合中去考慮,才能保證可達性分析的準確性。

小技巧

由於 Root 採用棧方式存放變數和指標,所以如果一個指標,它儲存了堆記憶體裡面的物件,但是自己又不存放在堆記憶體裡面,那它就是一個 Root。

注意

如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。這點不滿足的話分析結果的準確性就無法保證。

這點也是導致 GC 進行時必須“Stop The World”的一個重要原因。

  • 即使是號稱(幾乎)不會發生停頓的 CMS 收集器中,列舉根節點時也是必須要停頓的。

物件的 finalization 機制

Java 語言提供了物件終止(finalization)機制來允許開發人員提供物件被銷燬之前的自定義處理邏輯。

當垃圾回收器發現沒有引用指向一個物件,即:垃圾回收此物件之前,總會先呼叫這個物件的 finalize() 方法。

finalize() 方法允許在子類中被重寫,用於在物件被回收時進行資源釋放。通常在這個方法中進行一些資源釋放和清理的工作,比如關閉檔案、套接字和資料庫連線等。

注意

永遠不要主動呼叫某個物件的 finalize() 方法,應該交給垃圾回收機制呼叫。理由包括下面三點:

  • finalize() 時可能會導致物件復活。
  • finalize() 方法的執行時間是沒有保障的,它完全由 GC 執行緒決定,極端情況下,若不發生 GC,則 finalize() 方法將沒有執行機會。

    因為優先順序比較低,即使主動呼叫該方法,也不會因此就直接進行回收

  • 一個糟糕的 finalize() 會嚴重影響 GC 的效能。

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

由於 finalize() 方法的存在,虛擬機器中的物件一般處於三種可能的狀態

生存還是死亡?

如果從所有的根節點都無法訪問到某個物件,說明物件己經不再使用了。一般來說,此物件需要被回收。但事實上,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段。一個無法觸及的物件有可能在某一個條件下“復活”自己,如果這樣,那麼對它的回收就是不合理的,為此,定義虛擬機器中的物件可能的三種狀態。如下:

  • 可觸及的(可達的):從根節點開始,可以到達這個物件。
  • 可復活的:物件的所有引用都被釋放,但是物件有可能在 finalize() 中復活。
  • 不可觸及的:物件的 finalize() 被呼叫,並且沒有復活,那麼就會進入不可觸及狀態。不可觸及的物件不可能被複活,因為finalize() 只會被呼叫一次

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

具體過程

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

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

[](

上圖就是我們看到的 Finalizer 執行緒

程式碼演示

我們使用重寫 finalize() 方法,然後在方法的內部,重寫將其存放到 GC Roots 中

/**
 * 測試Object類中finalize()方法
 * 物件復活場景
 *
 * @author: Nemo
 */
public class CanReliveObj {
    // 類變數,屬於GC Roots的一部分
    public static CanReliveObj canReliveObj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("呼叫當前類重寫的finalize()方法");
        canReliveObj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        canReliveObj = new CanReliveObj();
        canReliveObj = null;
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        // 因為Finalizer執行緒的優先順序比較低,暫停2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

        System.out.println("-----------------第二次gc操作------------");
        canReliveObj = null;
        System.gc();
        // 下面程式碼和上面程式碼是一樣的,但是 canReliveObj卻自救失敗了
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

    }
}

最後執行結果

-----------------第一次gc操作------------
呼叫當前類重寫的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead

在進行第一次清除的時候,我們會執行 finalize() 方法,然後 物件 進行了一次自救操作,但是因為 finalize() 方法只會被呼叫一次,因此第二次該物件將會被垃圾清除。

MAT 與 JProfiler 的 GC Roots 溯源

MAT 是什麼?

MAT 是 Memory Analyzer 的簡稱,它是一款功能強大的 Java 堆記憶體分析器。用於查詢記憶體洩漏以及檢視記憶體消耗情況。

MAT 是基於 Eclipse 開發的,是一款免費的效能分析工具。

大家可以在 http://www.eclipse.org/mat/ 下載並使用 MAT

獲取 dump 檔案

方法一:命令列使用 jmap

[](

方法二:使用 JVIsualVM

捕獲的 heap dump 檔案是一個臨時檔案,關閉 JVisualVM 後自動刪除,若要保留,需要將其另存為檔案。可通過以下方法捕獲 heap dump:

在左側“Application”(應用程式)子視窗中右擊相應的應用程式,選擇 Heap Dump(堆Dump)。

在 Monitor(監視)子標籤頁中點選 Heap Dump(堆 Dump)按鈕。本地應用程式的 Heap dumps 作為應用程式標籤頁的一個子標籤頁開啟。同時,heap dump 在左側的 Application(應用程式)欄中對應一個含有時間戳的節點。

右擊這個節點選擇 save as(另存為)即可將 heap dump 儲存到本地。

使用 MAT 開啟 Dump 檔案

開啟後,我們就可以看到有哪些可以作為 GC Roots 的物件

[](

裡面我們能夠看到有一些常用的 Java 類,然後 Thread 執行緒。

JProfiler 的 GC Roots 溯源

我們在實際的開發中,一般不會查詢全部的 GC Roots,可能只是查詢某個物件的整個鏈路,或者稱為 GC Roots 溯源,這個時候,我們就可以使用 JProfiler

[](

如何判斷什麼原因造成 OOM

當我們程式出現 OOM 的時候,我們就需要進行排查,我們首先使用下面的例子進行說明

/**
 * 記憶體溢位排查
 * -Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError
 * @author: Nemo
 */
public class HeapOOM {
    // 建立1M的檔案
    byte [] buffer = new byte[1 * 1024 * 1024];

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();
        int count = 0;
        try {
            while (true) {
                list.add(new HeapOOM());
                count++;
            }
        } catch (Exception e) {
            e.getStackTrace();
            System.out.println("count:" + count);
        }
    }
}

上述程式碼就是不斷的建立一個 1M 小位元組陣列,然後讓記憶體溢位,我們需要限制一下記憶體大小,同時使用 HeapDumpOnOutOfMemoryError 將出錯時候的 dump 檔案輸出

-Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError

我們將生成的dump檔案開啟,然後點選Biggest Objects就能夠看到超大物件

[](

然後我們通過執行緒,還能夠定位到哪裡出現 OOM

[](

清除階段:標記-清除演算法

當成功區分出記憶體中存活物件和死亡物件後,GC 接下來的任務就是執行垃圾回收,釋放掉無用物件所佔用的記憶體空間,以便有足夠的可用記憶體空間為新物件分配記憶體。

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

  • 標記一清除演算法(Mark-Sweep)
  • 複製演算法(copying)
  • 標記-壓縮演算法(Mark-Compact)

背景

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

執行過程

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

  • 標記:Collector 從引用根節點開始遍歷,標記所有被引用的物件。一般是在物件的Header中記錄為可達物件。

    標記的是引用的物件,不是垃圾!!

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

[](

什麼是清除?

這裡所謂的清除並不是真的置空,而是把需要清除的物件地址儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放覆蓋原有的地址。

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

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

      指標碰撞(Bump the Pointer):如果記憶體空間以規整和有序的方式分佈,即 已用和未用的記憶體都各自一邊,彼此之間維繫著一個記錄下一次分配起始點的標記指標,當為新物件分配記憶體時,只需要通過修改指標的偏移量將新物件分配在第一個空閒記憶體位置上,這種分配方式就叫做指標碰撞(Bump the Pointer)。

  • 如果記憶體不規整
    • 虛擬機器需要維護一個列表
    • 空閒列表分配

缺點

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

清除階段:複製演算法

背景

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

核心思想

將活著的記憶體空間分為兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,最後完成垃圾回收

[](

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

[](

優點

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

缺點

  • 此演算法的缺點也是很明顯的,就是需要兩倍的記憶體空間。
  • 對於 G1 這種分拆成為大量 region 的 GC,複製而不是移動,意味著 GC 需要維護 region 之間物件引用關係,不管是記憶體佔用或者時間開銷也不小

注意

如果系統中的垃圾物件很多,複製演算法需要複製的存活物件數量並不會太大,或者說非常低才行(老年代大量的物件存活,那麼複製的物件將會有很多,效率會很低)

應用場景:
在新生代,對常規應用的垃圾回收,一次通常可以回收 70%-99% 的記憶體空間。回收價效比很高。所以現在的商業虛擬機器都是用這種收集演算法回收新生代。

清除階段:標記-整理演算法

Mark-Compact

背景

複製演算法的高效性是建立在存活物件少、垃圾物件多的前提下的。這種情況在新生代經常發生,但是在老年代,更常見的情況是大部分物件都是存活物件。如果依然使用複製演算法,由於存活物件較多,複製的成本也將很高。因此,基於老年代垃圾回收的特性,需要使用其他的演算法。

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

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

執行過程

第一階段和標記清除演算法一樣,從根節點開始標記所有被引用物件

第二階段將所有的存活物件壓縮到記憶體的一端,按順序排放。

之後,清理邊界外所有的空間。

[](

標清和標整的區別

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

二者的本質差異在於標記-清除演算法是一種非移動式的回收演算法,標記-壓縮是移動式的。是否移動回收後的存活物件是一項優缺點並存的風險決策。

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

標整的優缺點

優點

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

缺點

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

小結

標記清除 標記整理 複製
速度 中等 最慢 最快
空間開銷 少(但會堆積碎片) 少(不堆積碎片) 通常需要活物件的 2 倍空間(不堆積碎片)
移動物件

效率上來說,複製演算法是當之無愧的老大,但是卻浪費了太多記憶體。

而為了儘量兼顧上面提到的三個指標,標記-整理演算法相對來說更平滑一些,但是效率上不盡如人意,它比複製演算法多了一個標記的階段,比標記-清除多了一個整理記憶體的階段。

綜合我們可以知道,沒有最好的演算法,只有最合適的演算法

分代收集演算法

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

分代收集演算法,是基於這樣一個事實:不同的物件的生命週期是不一樣的。因此,不同生命週期的物件可以採取不同的收集方式,以便提高回收效率。一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點使用不同的回收演算法,以提高垃圾回收的效率。

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

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

在 HotSpot 中,基於分代的概念,GC 所使用的記憶體回收演算法必須結合年輕代和老年代各自的特點。

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

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

以 HotSpot 中的 CMS 回收器為例,CMS 是基於 Mark-Sweep 實現的,對於物件的回收效率很高。而對於碎片問題,CMS 採用基於 Mark-Compact 演算法的 Serial old 回收器作為補償措施:當記憶體回收不佳(碎片導致的 Concurrent Mode Failure 時),將採用 serial old 執行 FullGC 以達到對老年代記憶體的整理。

分代的思想被現有的虛擬機器廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代。

增量收集演算法

概述

上述現有的演算法,在垃圾回收過程中,應用軟體將處於一種 Stop the World 的狀態。在 stop the World 狀態下,應用程式所有的執行緒都會掛起,暫停一切正常的工作,等待垃圾回收的完成。如果垃圾回收時間過長,應用程式會被掛起很久,將嚴重影響使用者體驗或者系統的穩定性。為了解決這個問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的誕生。

基本思想:
如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行。每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接著切換到應用程式執行緒。依次反覆,直到垃圾收集完成。

總的來說,增量收集演算法的基礎仍是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作。

缺點

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間。但是,因為執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

分割槽演算法

一般來說,在相同條件下,堆空間越大,一次 GC 時所需要的時間就越長,有關 GC 產生的停頓也越長。為了更好地控制 GC 產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若干個小區間,而不是整個堆空間,從而減少一次 GC 所產生的停頓。

分代演算法將按照物件的生命週期長短劃分成兩個部分,分割槽演算法將整個堆空間劃分成連續的不同小區間。

每一個小區間都獨立使用,獨立回收。這種演算法的好處是可以控制一次回收多少個小區間。

[](

寫到最後

注意,這些只是基本的演算法思路,實際 GC 實現過程要複雜的多,目前還在發展中的前沿 GC 都是複合演算法,並且並行和併發兼備。