深入理解JVM(4) : Java垃圾收集 (GC)

weixin_34291004發表於2015-09-03

Java與C++之間有一堵由記憶體動態分配和垃圾收集技術所圍成的“高牆”,牆外面的人想進去,牆裡面的人卻想出來。

對於垃圾收集(Gabage Collection,GC), 我們需要考慮三件事情:

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

一、GC的工作區域(哪些記憶體需要回收?)

Java虛擬機器的記憶體區域中,程式計數器、虛擬機器棧和本地方法棧三個區域是執行緒私有的,隨執行緒生而生,隨執行緒滅而滅;棧中的棧幀隨著方法的進入和退出而進行入棧和出棧操作,每個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的,因此這幾個區域的記憶體分配和回收都具有確定性。在這幾個區域不需要過多考慮回收的問題,因為方法結束或執行緒結束時,記憶體自然就跟隨著回收了。

垃圾回收重點關注的是堆和方法區部分的記憶體。因為一個介面中的多個實現類需要的記憶體可能不一樣,一個方法的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,所以垃圾回收器所關注的主要是這部分的記憶體。


二、垃圾物件的判定(什麼時候回收?)

Java堆中存放著幾乎所有的物件例項,垃圾收集器對堆中的物件進行回收前,要先確定這些物件是否還有用,哪些還活著。物件死去的時候才需要回收。

1. 判斷物件是否存活的演算法:

  • 引用計數演算法
    給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1,當引用失效時,計數器值就減1,任何時刻計數器為0的物件就是不可能再被使用的。
    1)優點:引用計數演算法的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的選擇.
    2)缺點:Java虛擬機器並沒有選擇這種演算法來進行垃圾回收,主要原因是它很難解決物件之間的相互迴圈引用問題。

    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();
    }
    }
    

    物件objA和objB都有欄位instance,賦值令objA.instance = objB;以及objB.instance = objA;,除此之外,這兩個物件再無任何其他引用,實際上這兩個物件已經不可能再被訪問,但是因為它們互相引用著對方,導致它們的引用計數值都不為0,引用計數演算法無法通知GC收集器回收它們。

  • 可達性分析演算法
    這種演算法的基本思路是通過一系列名為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時,就證明此物件是不可用的。
    Java語言是通過可達性分析演算法來判斷物件是否存活的。

    650075-69872a8e14820445.jpg

    在Java語言裡,可作為GC Roots的物件包括下面幾種:
    虛擬機器棧(棧幀中的本地變數表)中引用的物件。
    方法區中的類靜態屬性引用的物件。
    方法區中的常量引用的物件。
    本地方法棧中JNI(Native方法)的引用物件。

2. 正確理解引用:

無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。

在JDK 1.2以前,Java中的引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的物件就顯得無能為力。
我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體空間在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

  • 強引用就是指在程式程式碼之中普遍存在的,類似Object obj = new Object()這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。

  • 軟引用是用來描述一些還有用但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。

  • 弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。在JDK 1.2之後,提供了WeakReference類來實現弱引用。

  • 虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。在JDK 1.2之後,提供了PhantomReference類來實現虛引用。

3. 物件死亡的標記過程:

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程:

  • 如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

    如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。

  • finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在 finalize()中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。

下面的程式碼演示了兩點:

  1. 物件可以在被GC時自我拯救。
  2. 這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //物件第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因為Finalizer方法優先順序很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        // 下面這段程式碼與上面的完全相同,但是這次自救卻失敗了
        SAVE_HOOK = null;
        System.gc();
        // 因為Finalizer方法優先順序很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
finalize mehtod executed!  
yes, i am still alive :)  
no, i am dead :( 

PS : finalize()的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序,應該儘量避免使用。

4. 回收方法區:

很多人認為方法區(或者HotSpot虛擬機器中的永久代)是沒有垃圾收集的,Java虛擬機器規範中確實說過可以不要求虛擬機器在方法區實現垃圾收集,而且在方法區中進行垃圾收集的“價效比”一般比較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

  • 回收廢棄常量與回收Java堆中的物件非常類似。
    以常量池中字面量的回收為例,假如一個字串“abc”已經進入了常量池中,但是當前系統沒有任何一個String物件是叫做“abc”的,換句話說,就是沒有任何String物件引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果這時發生記憶體回收,而且必要的話,這個“abc”常量就會被系統清理出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

  • 判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:

    • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。
    • 載入該類的ClassLoader已經被回收。
    • 該類對應的java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

    虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣,不使用了就必然會回收。是否對類進行回收,需要虛擬機器的引數進行控制。
    在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

三、垃圾收集演算法 ( 如何回收?)


由於垃圾收集演算法的實現涉及大量的程式細節,而且各個平臺的虛擬機器操作記憶體的方法又各不相同,以下只是介紹幾種演算法的思想及其發展過程。

  1. 標記-清除演算法:
    最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,如同它的名字一樣,演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
    缺點:
    1)效率問題,標記和清除兩個過程的效率都不高;
    2)空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

    650075-2a76a0252a79d298.jpg

  2. 複製演算法:
    為了解決效率問題,一種稱為“複製”(Copying)的收集演算法出現了,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。
    1)優點:每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。
    2)缺點:演算法的代價是將記憶體縮小為了原來的一半,未免太高了一點。

    650075-0de1499cbfe986fc.jpg

    現在的商業虛擬機器都採用這種收集演算法來回收新生代,研究表明,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1∶1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。
    當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%,只有10%的記憶體會被“浪費”。
    當然,90%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。

  3. 標記-整理演算法:
    複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
    根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

    650075-806f037158cc73f7.jpg

  4. 分代收集演算法:
    當前商業虛擬機器的垃圾收集都採用“分代收集”(Generational Collection)演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。
    1)在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。
    2)在老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用"標記—清理"或者"標記—整理"演算法來進行回收。


推薦閱讀:《深入理解Java虛擬機器:JVM高階特性與最佳實踐》周志明著


[2015-09-03]

相關文章