jvm 之 垃圾標記演算法

u013378306發表於2017-07-06

JVM深入筆記(3)垃圾標記演算法

  • Author: Poechant
  • Blog: blog.CSDN.net/Poechant
  • Email: zhongchao.ustc#gmail.com (#->@)
  • Date: March 3rd, 2012
  • Copyright © 柳大·Poechant

如果您還不瞭解 JVM 的基本概念和記憶體劃分,請先閱讀《JVM 深入筆記(1)記憶體區域是如何劃分的?》一文。然後再回來 :)

因為 Java 中沒有留給開發者直接與記憶體打交道的指標(C++工程師很熟悉),所以如何回收不再使用的物件的問題,就丟給了 JVM。所以下面就介紹一下目前主流的垃圾收集器所採用的演算法。不過在此之前,有必要先講一下 Reference。

1 引用(Reference)

你現在還是 JDK 1.0 或者 1.1 版本的開發者嗎?如果是的話,可以告訴你跳過“5 Reference”這一部分吧,甚至跳過本文。如果不是的話,下面這些內容還是有參考價值的。你可能會問,Reference 還有什麼可講的?還是有一點的,你知道 Reference 有四種分類嗎?這可不是孔乙己的四種“回”字寫法可以類比的。說引用,我們最先想到的一般是:

Object obj = new Object();

這種屬於 Strong Reference(JDK 1.2 之後引入),這類 ref 的特點就是,只要 ref 還在,目標物件就不能被幹掉。我們可以想一下為什麼要幹掉一些物件?很簡單,因為記憶體不夠了。如果記憶體始終夠用,大家都活著就好了。所以當記憶體不夠時,會先幹掉一些“必死無疑的傢伙”(下面會解釋),如果這時候記憶體還不夠用,就該幹掉那些“可死可不死的傢伙”了。

JDK 1.2 之後還引入了 SoftReference 和 WeakReference,前者就是那些“可死可不死的傢伙”。當進行了一次記憶體清理(幹掉“必死無疑”的傢伙)後,還是不夠用,就再進行一次清理,這次清理的內容就是 SoftReference 了。如果幹掉 Soft Reference 後還是不夠用,JVM 就丟擲 OOM 異常了。

好像 WeakReference 還沒說呢?它是幹嘛的?其實它就是那些“必死無疑的傢伙”。每一次 JVM 進行清理時,都會將這類 ref 幹掉。所以一個 WeakReference 出生後,它的死期,就是下一次 JVM 的清理。

“回”字的最後一種寫法,是 PhantomReference,名字很恐怖吧(Phantom是鬼魂的意思,不僅含義恐怖,而且發音也恐怖——“墳頭”)。這類 ref 的唯一作用,就是當相應的 Object 被 clean 掉的時候,通知 JVM。

雖然有四種“回”字,但是 Strong Reference 卻沒有相應的類,java.lang.ref.Reference 只有三個子類。

你可能會發現,在 Reference 這一部分,我經常性地提到“清理”。什麼“清理”?就是下面要說的 Garbage Collection 中對”無用”物件的 clean。

這是 JVM 的核心功能之一,同時也是為什麼絕大多數 Java 工程師不需要像 C++ 程式設計師那樣考慮物件的生存期問題。至於因此而同時導致 Java 工程師不能夠放任自由地控制記憶體的結果,其實是一個 Freedom 與 Effeciency 之間的 trade-off,而 C++ 工程師與 Java 工程師恰如生存在兩個國度的人,好像“幸福生活”的天朝人民與“水深火熱”的西方百姓之間的“時而嘲笑、時而豔羨”一般。

言歸正傳,Garbage Collector(GC)是 JVM 中篩選並清理 Garbage 的工具。那麼第一個要搞清楚的問題是,什麼是 Garbage?嚴謹的說,Garbage 就是不再被使用、或者認為不再被使用、甚至是某些情況下被選作“犧牲品”的物件。看上去很羅嗦,那就先理解成“不再被使用”吧。這就出現了第二個問題,怎麼判斷不再被使用?這就是下面首先要介紹的 Object Marking Algorithms。

2 物件標記演算法(Object Marking Algorithms)

下面還是先從本質一點的東西開始說吧。一個物件變得 useless 了,其實就是它目前沒有稱為任何一個 reference 的 target,並且認為今後也不會成為(這是從邏輯上說,實際上此刻沒有被引用的物件,今後也沒有人會去引用了⋯⋯)

2.1 引用計數法(Reference Counting)

核心思想:很簡單。每個物件都有一個引用計數器,當在某處該物件被引用的時候,它的引用計數器就加一,引用失效就減一。引用計數器中的值一旦變為0,則該物件就成為垃圾了。但目前的 JVM 沒有用這種標記方式的。為什麼呢?

因為引用計數法無法解決迴圈引用(物件引用關係組成“有向有環圖”的情況,涉及一些圖論的知識,在根搜尋演算法中會解釋)的問題。比如下面的例子:

package com.sinosuperman.jvm;

class _1MB_Data {
    public Object instance = null;
    private byte[] data = new byte[1024 * 1024 * 1];
}

public class CycledReferenceProblem {

    public static void main(String[] args) {

        _1MB_Data d1 = new _1MB_Data();
        _1MB_Data d2 = new _1MB_Data();
        d1.instance = d2;
        d2.instance = d1;

        d1 = null;
        d2 = null;

        System.gc();
    }
}

在這個程式中,首先在堆記憶體中建立了兩個 1MB 大小的物件,並且其中分別儲存的 instance 成員引用了對方。那麼即使 d1和 d2 被置為 null 時,引用數並沒有變為零。如果這是採用引用計數法來標記的話,記憶體就被浪費了,gc 的時候不會被回收。好悲催啊 :(

重複一下在《JVM 深入筆記(1)記憶體區域是如何劃分的?》中提到的執行環境:

**Mac OS X 10.7.3**,**JDK 1.6.0   Update 29**,**Oracle Hot Spot 20.4-b02**。

那麼我們來試試Oracle Hot Spot 20.4-b02是不是採用引用計數法來標記的。對了,別忘了為CycledReferenceProblem使用的虛擬機器開啟-XX:+PrintGCDetails引數,然後執行結果如下:

[Full GC (System) [CMS: 0K->366K(63872K), 0.0191521 secs] 3778K->366K(83008K), [CMS Perm : 4905K->4903K(21248K)], 0.0192274 secs] [Times: user=0.03 sys=0.00, real=0.02 secs] 
Heap
 par new generation   total 19136K, used 681K [7f3000000, 7f44c0000, 7f44c0000)
  eden space 17024K,   4% used [7f3000000, 7f30aa468, 7f40a0000)
  from space 2112K,   0% used [7f40a0000, 7f40a0000, 7f42b0000)
  to   space 2112K,   0% used [7f42b0000, 7f42b0000, 7f44c0000)
 concurrent mark-sweep generation total 63872K, used 366K [7f44c0000, 7f8320000, 7fae00000)
 concurrent-mark-sweep perm gen total 21248K, used 4966K [7fae00000, 7fc2c0000, 800000000)

可以看到,在Full GC時,清理掉了 (3778-366)KB=3412KB 的物件。這一共有 3MB 多,可以確定其中包括兩個我們建立的 1MB 的物件嗎?貌似無法確定。好吧,那下面我們使用_2M_Data物件來重複上面的程式。

package com.sinosuperman.jvm;

class _2MB_Data {
    public Object instance = null;
    private byte[] data = new byte[1024 * 1024 * 2];
}

public class CycledReferenceProblem {

    public static void main(String[] args) {

        _2MB_Data d1 = new _2MB_Data();
        _2MB_Data d2 = new _2MB_Data();
        d1.instance = d2;
        d2.instance = d1;

        d1 = null;
        d2 = null;

        System.gc();
    }
}

執行結果如下:

[Full GC (System) [CMS: 0K->366K(63872K), 0.0185981 secs] 5826K->366K(83008K), [CMS Perm : 4905K->4903K(21248K)], 0.0186886 secs] [Times: user=0.04 sys=0.00, real=0.02 secs] 
Heap
 par new generation   total 19136K, used 681K [7f3000000, 7f44c0000, 7f44c0000)
  eden space 17024K,   4% used [7f3000000, 7f30aa4b0, 7f40a0000)
  from space 2112K,   0% used [7f40a0000, 7f40a0000, 7f42b0000)
  to   space 2112K,   0% used [7f42b0000, 7f42b0000, 7f44c0000)
 concurrent mark-sweep generation total 63872K, used 366K [7f44c0000, 7f8320000, 7fae00000)
 concurrent-mark-sweep perm gen total 21248K, used 4966K [7fae00000, 7fc2c0000, 800000000)

這次清理掉了 (5826-366)=5460KB 的物件。我們發現兩次清理相差 2048KB,剛好是 2MB,也就是 d1 和 d2 剛好各相差 1MB。我想這可以確定,gc 的時候確實回收了兩個迴圈引用的物件。如果你還不信,可以再試試 3MB、4MB,都是剛好相差 2MB。

這說明oracle Hot Spot 20.4-b02虛擬機器並不是採用引用計數方法。事實上,現在沒有什麼流行的 JVM 會去採用簡陋而問題多多的引用計數法來標記。不過要承認,它確實簡單而且大多數時候有效。

那麼,這些主流的 JVM 都是使用什麼標記演算法的呢?

2.2. 根搜尋演算法(Garbage Collection Roots Tracing)

對,沒錯,就是“根搜尋演算法”。我來介紹以下吧。

2.2.1 基本思想

其實思路也很簡單(演算法領域,除了紅黑樹、KMP等等比較複雜外,大多數思路都很簡單),可以概括為如下幾步:

  1. 選定一些物件,作為 GC Roots,組成基物件集(這個詞是我自己造的,與其他文獻資料的說法可能不一樣。但這無所謂,名字只是個代號,理解演算法內涵才是根本);
  2. 由基物件集內的物件出發,搜尋所有可達的物件;
  3. 其餘的不可達的物件,就是可以被回收的物件。

這裡的“可達”與“不可達”與圖論中的定義一樣,所有的物件被看做點,引用被看做有向連線,整個引用關係就是一個有向圖。在“引用計數法”中提到的迴圈引用,其實就是有向圖中有環的情況,即構成“有向有環圖”。引用計數法不適用於“有向有環圖”,而根搜尋演算法適用於所有“有向圖”,包括有環的和無環的。那麼是如何解決的呢?

2.2.2 GC Roots

如果你的邏輯思維夠清晰,你會說“一定與選取基物件集的方法有關”。是的,沒錯。選取 GC Roots 組成基物件集,其實就是選取如下這些物件:

《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐》一書中提到的 GC Roots 為:

  1. 方法區(Method Area,即 Non-Heap)中的類的 static 成員引用的物件,和 final 成員引用的物件;
  2. Java 方法棧(Java Method Stack)的區域性變數表(Local Variable Table)中引用的物件;
  3. 原生方法棧(Native Method Stack)中 JNI 中引用的物件。

但顯然不夠全面,[參考2]中提到的要更全面:(March 6th,2012 update

  1. 由系統類載入器載入的類相應的物件:這些類永遠不會被解除安裝,且這些類建立的物件都是 static 的。注意使用者使用的類載入器載入的類建立的物件,不屬於 GC Roots,除非是 java.lang.Class 的相應例項有可能會稱為其他類的 GC Roots。
  2. 正在執行的執行緒。
  3. Java 方法棧(Java Method Stack)的區域性變數表(Local Variable Table)中引用的物件。
  4. 原生方法棧(Native Method Stack)的區域性變數表(Local Variable Table)中引用的物件。
  5. JNI 中引用的物件。
  6. 同步監控器使用的物件。
  7. 由 JVM 的 GC 控制的物件:這些物件是用於 JVM 內部的,是實現相關的。一般情況下,可能包括系統類載入器(注意與“1”不一樣,“1”中是 objects created by the classes loaded by system class loaders,這裡是 the objects, corresponding instances of system class loaders)、JVM 內部的一些重要的異常類的物件、異常控制程式碼的預分配物件和在類載入過程中自定義的類載入器。不幸的是,JVM 並不提供這些物件的任何額外的詳細資訊。因此這些實現相關的內容,需要依靠分析來判定。

所以這個演算法實施起來有兩部分,第一部分就是到 JVM 的幾個記憶體區域中“找物件”,第二部分就是運用圖論演算法。

3. 廢話

JVM 的標記演算法並不是 JVM 垃圾回收策略中最重要的。真正的核心,是回收演算法,當然標記演算法是基礎。如果你想複習一下前兩篇文章,連結在這裡:

JVM 深入筆記(1)記憶體區域是如何劃分的?

JVM 深入筆記(2)各記憶體區溢位場景模擬

JVM 深入筆記(3)垃圾標記演算法

參考

  1. http://www.yourkit.com/docs/10/help/gc_roots.jsp
  2. 《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐》周志明(著),機械工業出版社

-

如果這篇文章幫助到了您,歡迎您到我的部落格留言,我會很高興的。

轉載請註明來自“柳大的CSDN部落格”:blog.csdn.net/Poechant

-

相關文章