《JVM 垃圾收集策略與算法》

對象已死

在堆裏面存放着Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事是確定這些對象之中哪些還"存活"着,哪些已經"死去"了。

1. 引用計數法

在對象中添加一個引用計數器,每當有一個地方引用它時,這個計數器值就+1;當引用失效時,計數器值就-1;任何時刻計數器爲零的對象就是不可能在被使用的。但是在Java領域,至少主流的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;

        System.gc();
    }
    
    public static void main(String[] args) {
        testGC();
    }
}

2. 可達性分析法

當前主流的商用程序語言(Java、C#等)的內存管理系統,都是通過可達性分析算法來判定對象是否存活的。這個算法的基本思路是通過一系列稱爲"GC Roots"的根對象作爲起始節點集,從這些節點開始根據引用關係向下搜索,搜索過程所走過的路徑被稱爲"引用鏈",如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達,則證明此對象是不可能再被使用的。

在Java技術體系中,固定可作爲GC Roots的對象包括以下幾種:

  • 在虛擬機棧中引用的對象
  • 在本地方法棧中引用的對象
  • 在方法區中類靜態屬性引用的對象
  • 在方法區中常量引用的對象
  • 所有被同步鎖(synchronize關鍵字)所持有的對象
  • .......

3. 再談引用

在JDK1.2版之後,Java對引用的概念進行了擴充,將引用分爲強引用、軟引用、弱引用、虛引用4種,這4種引用強度依次減弱。

  • 強引用是最傳統"引用"的定義,是指在程序代碼中普遍存在的引用賦值,即類似Object object = new Object() 這種引用關係。無論任何情況下,只要強引用關係存在,垃圾回收器就永遠不會回收掉被引用的對象。
  • 軟引用是用來描述一些還有用,但是非必須的對象。 只被軟引用關聯的對象,在系統將要發生內存溢出前會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。
  • 弱引用也是用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被軟引用關聯的對象只能生存到下次垃圾收集爲止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。
  • 虛引用也被稱爲"幽靈引用",它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象的實例。爲一個對象設置虛引用關聯的唯一目的只是爲了能在這個對象被收集器回收時收到一個系統通知。

4. 生存還是死亡

即使在可達性分析法中判定爲不可達的對象,也不是"非死不可"的,這時候它們暫時還處於"緩刑"階段,要真正宣佈一個對象死亡,只要要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會第一次被標記,隨後進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。假如對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,那麼虛擬機將這兩種情況視爲"沒有必要執行"。

如果這個對象被判定爲確有必要執行finalize()方法,那麼該對象將會被放置在一個名爲F-Queue的隊列中,並在稍後由一個由虛擬機自動建立的、低調度優先級的Finalizer線程去執行它們的finalize()方法。這裏所說的"執行"是指虛擬機會觸發這個方法開始運行,但並不承諾一定會等待它運行結束。這樣做的原因是,如果某個對象的finalize()方法執行緩慢,或者跟極端的發生了死循環,將很可能導致F-Queue隊列中的其它對象永久的處於等待,甚至導致整個內存回收子系統的崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後收集器將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個變量或者對象的成員變量,那在第二次標記時它將被移出"即將回收"集合;如果對象這個時候還沒有逃脫,那基本上它真的要被回收了。

public class FinalizeEscapeGc {

    public static FinalizeEscapeGc SAVA_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 method executed");
        FinalizeEscapeGc.SAVA_HOOK = this;
    }

    public static void main(String[] args) throws Throwable{
        SAVA_HOOK = new FinalizeEscapeGc();

        // 對象第一次成功拯救自己
        SAVA_HOOK = null;
        System.gc();

        // 因爲Finalizer方法的優先級很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVA_HOOK != null) {
            SAVA_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead");
        }

        // 下邊的代碼與上邊的完全相同,但是這次自救失敗了
        SAVA_HOOK = null;
        System.gc();

        // 因爲Finalizer方法的優先級很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVA_HOOK != null) {
            SAVA_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead");
        }
    }
}

輸出:
finalize method executed
yes, i am still alive
no, i am dead


從上述代碼可以看出,SAVA_HOOK對象的finalize() 確實被垃圾收集器觸發過,並且在被收集前成功逃脫了

另外值得注意的是,代碼中有兩段完全相同的片段,執行結果卻是一次逃脫一次失敗了。這是因爲任何一個對象的finalize() 都只會被系統自動調用一次,如果對象面臨下一次回收,它的finalize() 方法不會被再次執行,因此第二段代碼的自救行動失敗了。

5. 回收方法區

在Java堆中尤其是在新生代中,對常規應用進行一次垃圾收集通常可以回收70%至99%的內存空間,相比之下,方法區回收由於苛刻的判定條件,其區域垃圾收集的回收成果往往遠低於此。

方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄的常量跟Java堆中的對象非常類似。

判定一個常量是否“廢棄”還是相對簡單,而要判定一個類型是否屬於“不再被使用的類”的條件就比較苛刻。需要同時滿足一下三個條件:

  • 該類所有的實例都已經被回收,也就是說Java堆中不存在該類及其任何派生子類的實例
  • 加載該類的類加載器已經被回收,這個矯健除非是精心設計的可替換類加載器的場景,如:OSGi、JSP的重加載等,否則通常是很難達成的
  • 該類對應的java.lang.Class對象沒有在任何地方被引用過,無法在任何地方通過反射訪問該類的方法

Java虛擬機被允許對滿足上述三個條件的無用類進行回收,這裏說的僅僅是“被允許”,而並不是和對象一樣,沒有引用就必然會回收。關於是否要對類型進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class 以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看類加載和卸載信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。

在大量使用反射、動態代理、CGLib等字節碼框架,動態生成JSP以及OSGi這類頻繁自定義類加載器的場景中,通常都需要Java虛擬機具備類型卸載的能力,以保證不會對方法區早晨過大的內存壓力。

垃圾收集算法

  • 弱分代假說:絕大多數對象都是朝生夕滅
  • 強分代假說:熬過越多次垃圾收集過程的對象就越難以消亡
  • 跨代引用假說:跨代引用相對於同代引用來說僅佔極少數
  • 新生代收集:Minor GC/Young GC
  • 老年代收集:Major GC/Old GC
  • 混合收集:Mixed GC
  • 整堆收集:Full GC

1. 標記-清除算法

算法分爲“標記”和“清除”兩個部分,首先標記出所有需要回收的對象,在標記完成之後,統一回收掉所有被標記的對象。也可以反過來標記存活的對象,統一回收所有未標記的對象。

主要有兩個缺點:第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清楚的動作,導致標記和清楚兩個過程的執行效率都隨對象數量增長而降低;第二個是內存空間碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致當以後在程序中需要分配較對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

2. 標記-整理算法(老年代)

 

其中的標記過程與“標記-清除”算法一樣,但是後續的步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間的一端移動,然後直接清理掉邊界以外的內存。

這是一種老年代的垃圾收集算法。老年代的對象一般壽命比較長,因此每次垃圾回收會有大量對象存活,如果採用複製算法,每次需要複製大量存活的對象,效率很低。

3. 複製算法(新生代)

它將可用內存按容量劃分爲大小相等的兩部分,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另一塊上面,然後再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活着,這種算法將會產生大量的內存間複製的開銷,但對於多數對象都是可回收的情況,算法需要複製的就是佔少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指針,按順序分配即可。這樣實現簡單,運行高效,不過其缺點也顯而易見,這種複製回收算法的代價是將可用內存縮小爲原來的一半,空間浪費未免太多了一點。

在1989年,Andrew Appel針對具備“朝生夕滅”特點的對象,提出了一種更優化的半區複製策略,現在稱爲“Appel式回收”。HotSpot虛擬機的Serial、parNew等新生代收集器均採用了這種策略來設計新生代的內存佈局。Appel式回收的具體做法是把新生代分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾收集時,將Eden和Survivor中仍然存活的對象一次性複製到另一塊Survivor空間上,然後直接清理掉Eden和已使用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor大小比例是8:1,也即每次新生代中可用內存空間爲整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%新生代是會被“浪費”的。當Survivor空間不足以容納一次MInor GC之後存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保。

內存的分配擔保好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會默認我們下一次也能按時償還貸款,只需要有一個擔保人能保證如果我們不能還款時,可以從他的賬戶扣錢,那銀行就認爲沒有什麼風險了。內存的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來存活的對象,這些對象便將通過分配擔保機制直接進入老年代,這對虛擬機來說就是安全的。

4. 增量算法

增量算法的基本思想是,如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集線程和應用程序線程交替執行。每次,垃圾收集線程只收集一小片區域的內存空間,接着切換到應用程序線程。依次反覆,直到垃圾收集完成。使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程序代碼,所以能減少系統的停頓時間。但是,因爲線程切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章