記憶體管理:判斷物件是否存活

真正的飛魚發表於2023-04-03

在堆裡面存放著 Java 世界中幾乎所有的物件例項,垃圾收集器在對 Java 堆進行回收前,第一件事情就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”(“死去”即不可能再被任何途徑使用的物件)。

有兩種判斷物件是否存活的演算法:引用計數演算法、可達性分析演算法。

  • 引用計數演算法判斷物件是否存活的基本思路是:在物件中新增一個引用計數器,每當有一個地方引用該物件時,計數器的值就加一;當引用失效時,計數器的值就減一;任何時刻計數器為零的物件就是不可能再被使用的物件。
  • 可達性分析演算法判斷物件是否存活的基本思路是:透過一系列被稱為 “GC Roots” 的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程所走過的路徑被稱為 “引用鏈”(Reference Chain),如果某個物件到 GC Roots 間沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可能再被使用的物件。

引用計數演算法

引用計數演算法(Reference Counting)判斷物件是否存活的基本思路是:在物件中新增一個引用計數器,每當有一個地方引用該物件時,計數器的值就加一;當引用失效時,計數器的值就減一;任何時刻計數器為零的物件就是不可能再被使用的物件。


客觀地說,引用計數演算法雖然佔用了一些額外的記憶體空間來進行計數,但引用計數演算法的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的演算法。也有一些比較著名的應用案例, 例如微軟 COM(Component Object Model)技術、使用 ActionScript3 的 FlashPlayer、Python 語言以及在遊戲指令碼領域得到許多應用的 Squirrel 中都使用了引用計數演算法進行記憶體管理。

但是,在 Java 領域,至少主流的 Java 虛擬機器裡面都沒有選用引用計數演算法進行記憶體管理,主要原因是,這個看似簡單的演算法有很多例外情況要考慮,必須要配合大量的額外處理才能保證正確地工作,譬如單純的引用計數就很難解決物件之間相互迴圈引用的問題。

舉個簡單的例子,請看程式碼清單 3-1 的 testGC() 方法:物件 objA 和 objB 都有欄位 instance,賦值令 objA.instance=objB 及 objB.instance=objA,除此之外,這兩個物件再無任何引用,實際上這兩個物件已經不可能再被訪問,但是因為它們互相引用著對方, 導致它們的引用計數都不為零,引用計數演算法也就無法回收它們。

程式碼清單 3-1 引用計數演算法的缺陷

/**
 * testGC()方法執行後, objA和objB會不會被GC呢?
 *
 * @author zzm
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 這個成員屬性的唯一意義就是佔點記憶體, 以便能在GC日誌中看清楚是否有回收過
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
		// 假設在這行發生GC, objA和objB是否能被回收?
        System.gc();
    }
}

可達性分析演算法

當前主流的商用程式語言(Java、C#,上溯至古老的 Lisp)的記憶體管理子系統,都是透過可達性分析(Reachability Analysis)演算法來判斷物件是否存活。

可達性分析演算法判斷物件是否存活的基本思路是:透過一系列被稱為 “GC Roots” 的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程所走過的路徑被稱為 “引用鏈”(Reference Chain),如果某個物件到 GC Roots 間沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可能再被使用的物件。

如下圖所示,物件 object 5、object 6、object 7 雖然互有關聯,但是它們到 GC Roots 是不可達的,因此它們將會被判定為是可回收的物件。

image-20230222100921648.png

在 Java 技術體系裡面,固定可作為 GC Roots 的物件包括以下幾種:

  • Java 虛擬機器棧(棧幀中的本地變數表) 中引用的物件,譬如各個執行緒呼叫的方法堆疊中使用到的引數變數(方法定義時宣告的變數)引用的物件、區域性變數(定義在方法中的變數)引用的物件、臨時物件(沒有變數引用的物件)等。

  • 本地方法棧中 JNI(即通常所說的 Native 方法)引用的物件(非 Java 程式碼中的物件)。

  • 方法區中引用的物件:

    • 方法區中類的靜態屬性(static 關鍵字)引用的物件,譬如 Java 類的引用型別靜態變數。
    • 方法區中常量(static 和 final 關鍵字)引用的物件,譬如字串常量池(String Table)裡的引用。
  • 所有被同步鎖(synchronized 關鍵字)持有的物件。

  • Java 虛擬機器內部的引用,如基本資料型別對應的 Class 物件,一些常駐的異常物件(比如 NullPointExcepiton、 OutOfMemoryError)等,還有系統類載入器。

  • 反映 Java 虛擬機器內部情況的 JMXBean、JVMTI 中註冊的回撥、原生程式碼快取等。

除了這些固定的 GC Roots 集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件 “臨時性” 地加入,共同構成完整的 GC Roots 集合。譬如後文將會提到的分代收集和區域性回收(Partial GC),如果只針對 Java 堆中某一塊區域發起垃圾收集時(如最典型的只針對新生代的垃圾收集),必須考慮到記憶體區域是虛擬機器自己的實現細節(在使用者視角里任何記憶體區域都是不可見的),更不是孤立封閉的,所以某個區域裡的物件完全有可能被位於堆中其他區域的物件所引用,這個時候就需要將這些關聯區域的物件也一併加入 GC Roots 集合中去,這樣才能保證可達性分析的正確性。

參考資料

《深入理解 Java 虛擬機器》第 3 章:垃圾收集器與記憶體分配策略 3.2 物件已死?

相關文章