1. 程式人生 > >深入理解JVM(二)--垃圾收集算法

深入理解JVM(二)--垃圾收集算法

靜態 情況下 ref 遊戲 規模 還在 生存 案例 object

一. 概述

    說起垃圾收集(Garbage Collection, GC), 大部分人都把這項技術當做Java語言的伴隨生產物. 事實上, GC的歷史遠遠比Java久遠, 1960年 誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言. 當Lisp還在胚胎時期時,人們就在思考GC需要完成的三件事情:

  • 哪些內存需要回收?
  • 什麽時候回收?
  • 如何回收?

  現在內存的動態分配與內存回收技術已經相當成熟, 那為什麽我們還要去了解GC和內存分配呢? 答案很簡單: 當需要排查各種內存溢出, 內存泄漏問題時, 當垃圾收集稱為系統達到更高並發量的瓶頸時, 我們就需要對這些"自動化"的技術實施必要的監控和調節.

二. 對象的生與死

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

  1. 引用計數算法

    概念: 給對象中添加一個引用計數器, 每當有一個地方引用它時, 計數器值+1; 當引用失效時, 計數器值-1; 任何時刻計數器都為0的對象就是不可能再被使用的.

    客觀地說, 引用計數算法(Reference Counting) 的實現簡單, 判定效率也很高, 在大部分情況下它都是一個不錯的算法, 也有一些比較著名的應用案例, 例如微軟的COM(Component Object Model) 技術, 使用ActionScript 3的FlashPlayer, Python語言以及在遊戲腳本領域被廣泛引用的Squirrel中都使用了引用計數算法進行內存管理.但是, Java語言沒有選用引用計數算法來管理內存, 其中最主要的原因是它很難解決對象之間的相互循環引用的問題.

   2. 根搜索算法

    概念: 通過一系列的名為"GC Roots" 的對象作為起始點, 從這些節點開始向下搜索, 搜索所走過的路徑稱為引用鏈(Reference Chain), 當一個對象到GC Roots 沒有任何引用鏈想連(用圖論的話來說就是從GC Roots到這個對象不可達)時, 則證明此對象是不可用的.

    在Java和C#, 以及上面提到的古老的Lisp, 都是使用跟搜索算法(GC Roots Tracing) 判斷對象是否存活的.

    在Java語言裏, 可作為GC Roots的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中的引用的對象.
  • 方法區中的類靜態屬性引用的對象.
  • 方法區中的常量引用的對象.
  • 本地方法棧中JNI(即一般說的Native方法)的引用的對象.

  3. 生存還是死亡

    在跟搜索算法中不可達的對象, 也並非是"非死不可"的, 這時候他們暫時處於"緩刑"階段, 要真正宣告一個對象死亡, 至少要經歷兩次標記過程: 如果對象在進行根搜索後發現沒有與GC Roots相連接的引用鏈, 那它將會第一次被標記並且進行一次篩選, 篩選的條件是此對象是否有必要進行finalize()方法, 當對象沒有覆蓋finalize() 方法, 或者finalize()方法已經被虛擬機調用郭, 虛擬機將這兩種情況都視為"沒有必要執行".

    如果這個對象有必要執行finalize()方法, 那麽這個對象將會被放置在一個名為F-Queue的隊列之中, 並在稍後由一條由虛擬機自動建立的, 低優先級的Finalizer線程去執行. finalize()方法是對象逃脫死亡命運的最後一次機會, 稍後GC將對F-Queue中的對象進行第二次小規模標記, 如果對象要在finalize()中成功拯救自己---只要重新與引用鏈上的任何一個對象建立關聯即可, 譬如把自己賦值給某個類變量或對象的成員變量, 那在第二次標記時它將被移除出"即將回收的集合", 如果對象這時候還沒有逃脫, 那它就這的離死不遠了.

    代碼: 一次對象自我拯救的演示

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("Yes, I‘m still alive.");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("Finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I‘m dead.");
        }

        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I‘m dead.");
        }

    }

}

運行結果:

Finalize method executed!
Yes, I‘m still alive.
No, I‘m dead.

三. 垃圾收集算法

  1. 標記-清除算法(Mark-Sweep)

    如他的名字一樣, 算法分為"標記"和"清除"兩個階段: 首先標記出所有需要回收的對象, 在標記完成後統一會受到所有被標記的對象,它的標記過程在上面講述對象標記判定時已經基本介紹過了. 它是最基礎的收集算法, 是因為後續的書記算法都是基於這種思路對其缺點進行改進而得到的.

    它的主要缺點有兩個: 一個是效率問題, 標記和清除過程的效率都不高, 另一個是空間問題, 標記清除之後會產生大量不連續的內存碎片, 空間碎片太多可能會導致, 當程序在以後的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作.

  2. 復制收集算法(Coping)

    為了解決效率問題, 一種稱為"復制"的收集算法出現了, 它將可用內存按容量劃分為大小相等的兩塊, 每次只使用其中的一塊. 當著一塊的內存用完了, 就將還存活著的對象復制到另一塊上面, 然後再把已使用過的內存空間一次清理掉, 這樣使得每次都是對其中的一塊進行內存回收, 內存分配時也不用考慮內存碎片等復雜情況, 只要移動堆頂指針, 按順序分配內存即可, 實現簡單, 運行高效. 只是這種算法的代價是將內存縮小為原來的一半, 未免太高了一點.

    現在的商業虛擬機都采用這種收集算法來回收新生代, IBM的專門研究表明, 新生代中的對象98%都是朝生夕死, 所以並不需要按照1:1的比例來話費呢內存空間, 二十將內存分為一塊較大的Eden空間和兩塊較小的Survivor空間, 每次使用Eden和其中的一塊Survivor. 當回收時, 將Eden和Survivor中還存活著的對象一次性的拷貝到另一塊Survivor空間上, 最後清理掉Eden和剛才用過的Survivor的空間, HotSpot虛擬機默認Eden和Survivor的大小比例是8:1, 也就是每次新生代中可用內存空間為整個新生代容量的90%(80%+ 10%), 只有10%的內存會被"浪費". 當然98%的對象可回收只是一般場景下的數據, 我們沒有辦法保證每次回收都只有不多余10%的對象存活, 當Survivor空間不夠時, 需要依賴其他內存(這裏指老年代)進行分配擔保(Handle Promotion).

  3. 標記-整理算法(Mark-Compact)

    復制收集算法在對象存活率較高時就要執行較多的復制操作, 效率將會變低, 更關鍵的是, 如果不想浪費50%的空間, 就需要有額外的空間進行分配擔保, 以應對被使用的內存中所有對象都100%存活的極端情況, 所以在老年代一般不能直接選用這種算法.

    根據老年代的特點, 於是提出了另一種"標記-整理"(Mark-Compact)算法, 標記過程仍與"標記-清除"算法一樣, 但後續步驟不是直接對可回收對象進行清理, 而是讓所有存活的對象都向一端移動, 然後直接清理掉邊界以外的的內存.

  4. 分代收集算法

    當前商業虛擬機的垃圾收集都采用"分代收集"(Generation Collection)算法, 這種算法並沒有什麽新思想, 只是根據對象的存活周期的不同將內存劃分為幾塊. 一般是把Java堆氛圍新生代和老年代, 這樣就可以根據各個年代的特點采用最適當的收集算法, 在新生代中, 每次垃圾收集時都發現有大批對象死去, 只有少量存貨, 那就選用復制算法, 只需要付出少量存活對象的復制成本就可以完成收集. 而老年代中因為對象存活率高, 沒有額外的空間對它進行分配擔保, 就必須使用"標記-清理"或"標記-整理"算法來進行回收.

深入理解JVM(二)--垃圾收集算法