jdk8:垃圾回收演算法

行者路上發表於2018-08-05

GC需要完成三件事:1,哪些記憶體需要回收?2:什麼時候回收?3:如何回收?
Java記憶體執行時區域的各部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著入棧和出棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的,因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者執行緒結束,記憶體自然就跟隨著回收了。
而Java堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收是動態的,垃圾收集器所關注的是這部分的記憶體。

物件已死嗎?

如果判斷物件是否還被任何途徑使用

引用計數

給物件新增一個引用計數器,每當有一個地方引用它的地方,計數器值+1;當引用失效,計數器值就減1;任何時候計數器為0,物件就不可能再被引用了。
它很難解決物件之間相互迴圈引用的問題。

public class ReferenceCountingGc {

    public Object instance = null;


    public static final int _1MB = 1024 * 1024;

    //這個屬性 佔用記憶體
    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();
    }

}

jvm options:-Xmx10m -Xms10m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

gc輸出

0.222: [GC (System.gc()) [PSYoungGen: 1699K->471K(2560K)] 5795K->4567K(9728K), 0.0022001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
0.224: [Full GC (System.gc()) [PSYoungGen: 471K->0K(2560K)] [ParOldGen: 4096K->446K(7168K)] 4567K->446K(9728K), [Metaspace: 2933K->2933K(1056768K)], 0.0057040 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 2560K, used 20K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 1% used [0x00000000ffd00000,0x00000000ffd05360,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen total 7168K, used 446K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 6% used [0x00000000ff600000,0x00000000ff66f970,0x00000000ffd00000)
Metaspace used 2940K, capacity 4568K, committed 4864K, reserved 1056768K
class space used 315K, capacity 392K, committed 512K, reserved 1048576K

黑色的地方可以看到,objB和objA並沒有回收

可達性分析演算法

在主流的商用語言(Java、C#)中都使用可達性分析(Reachability Analysis)來判定物件是否存活的。通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。
這裡寫圖片描述

圖中雖然object5,object6,object7相互互聯,但是GC root是不可達的,所以判定物件回收。

在Java語言中,可以作為Gc Roots的物件包括下面幾種:

  1. 虛擬機器棧(棧幀中的本地變數表)中引用的物件。
  2. 方法區中類靜態屬性引用的物件。
  3. 方法區中常量引用的物件。

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

引用

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

    @Override
    protected void finalize() throws Throwable {
        System.out.println("Referred物件被垃圾收集");
    }
}

public class StrongRef {

    public static void collect() throws InterruptedException {
        System.out.println("開始垃圾回收...");
        System.gc();
        System.out.println("結束垃圾回收.....");
        Thread.sleep(2000);
    }

    public static void main(String[] args) throws InterruptedException{

        System.out.println("建立一個強引用---->");

        Referred strong = new Referred();

        StrongRef.collect();

        System.out.println("刪去引用---->");

        strong = null;
        StrongRef.collect();;
        System.out.println("done");
    }
}

輸出:

建立一個強引用—->
開始垃圾回收…
結束垃圾回收…..
開始垃圾回收…
結束垃圾回收…..
> Referred物件被垃圾收集
done

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

    public static void collect() throws InterruptedException {
        System.out.println("開始垃圾收集...");
        System.gc();
        System.out.println("結束垃圾收集...");
        Thread.sleep(2000);
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("建立一個軟引用--->");

        Referred strong = new Referred();
        SoftReference<Referred> soft = new SoftReference<Referred>(strong);

        SoftRef.collect();

        System.out.println("刪除引用--->");

        strong = null;
        SoftRef.collect();

        System.out.println("開始堆佔用");

        try {
            List<SoftRef> heap = new ArrayList<>(100);
            while (true) {
                heap.add(new SoftRef());
            }
        } catch (OutOfMemoryError e) {
            // 軟引用物件應該在這個之前被收集
            System.out.println("記憶體溢位...");
        }

        System.out.println("Done");
    }
}

jvm options:-Xmx100m -Xms100m

建立一個軟引用—>
開始垃圾收集…
結束垃圾收集…
刪除引用—>
開始垃圾收集…
結束垃圾收集…
開始堆佔用
Referred物件被垃圾收集
記憶體溢位…
Done

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

    public static void collect() throws InterruptedException {
        System.out.println("開始垃圾收集...");
        System.gc();
        System.out.println("結束垃圾收集...");
        Thread.sleep(2000);
    }

    /**
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        System.out.println("建立一個弱引用--->");

        Referred strong = new Referred();
        WeakReference<Referred> weak = new WeakReference<>(strong);

        WeakRef.collect();
        System.out.println("刪除引用--->");

        strong = null;
        WeakRef.collect();

        System.out.println("Done");
    }
}

輸出:

建立一個弱引用—>
開始垃圾收集…
結束垃圾收集…
刪除引用—>
開始垃圾收集…
結束垃圾收集…
Referred物件被垃圾收集
Done

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

public class PhantomRef {

    public static class Referred {
        // Note! 如果這裡重寫了finalize方法,那麼PhantomReference不會追加到ReferenceQueue中
//        @Override
        protected void finalize() throws Throwable {
           System.out.println("Referred物件被垃圾收集");
        }
    }

    public static void collect() throws InterruptedException {
        System.out.println("開始垃圾收集...");
        System.gc();
        System.out.println("結束垃圾收集...");
        Thread.sleep(2000);
    }

    /**
     * 執行結果
     * 建立一個虛引用--->
     * 開始垃圾收集...
     * 結束垃圾收集...
     * 你需要清理一些東西了
     * Done
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        System.out.println("建立一個虛引用--->");

        ReferenceQueue dead = new ReferenceQueue();
        Map<Reference, String> cleanUpMap = new HashMap<>();

        Referred strong = new Referred();
        PhantomReference<Referred> phantom = new PhantomReference<>(strong, dead);
        cleanUpMap.put(phantom, "你需要清理一些東西了");

        strong = null;
        PhantomRef.collect();

        Reference reference = dead.poll();
        if (reference != null) {
            System.out.println(cleanUpMap.remove(reference));
        } else {
            System.out.println("reference為空");
        }
        System.out.println("Done");
    }
}

輸出:

建立一個強引用—->
開始垃圾回收…
結束垃圾回收…..
刪去引用—->
開始垃圾回收…
結束垃圾回收…..
Referred物件被垃圾收集
done

生存還是死亡?

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。下面例子可以看出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 method executed!");
        SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        SAVE_HOOK = null;
        System.gc();
        //因為finalize方法優先順序很低,所以暫停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();
        //因為finalize方法優先順序很低,所以暫停0.5秒等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

這裡寫圖片描述

一樣的程式碼,一次逃脫,一次失敗。因為物件的finalize()只能被系統執行一次。

方法區的回收

方法區的回收主要回收2個部分:廢棄常量和無用的類。
回收常量池和回收堆中的物件類似,比“abc”加入常量池,沒有任何String物件引用常量池中的“abc”,那麼就要回收。常量詞中的其他類(介面)、方法、欄位符號引用也與此類似。
無用的類的判斷複雜一些,需要滿足下面3個條件:

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

是否對類進行回收,HotSpot虛擬機器提供了-Xnoclassgc引數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading, -XX:+TraceClassUnLoading檢視類架子啊和解除安裝資訊,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機器中使用,-XX:+TraceClassUnLoading引數需要FastDebug版的虛擬機器支援。
在大量使用反射、動態代理、Cglib等ByteCode框架、動態生成JSP以及OSGI這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

GC 演算法

標記-清除演算法

演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
主要不足有兩個:

  • 一個是效率問題,標記和清除兩個過程的效率都不高;
  • 一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

標記—清除演算法的執行過程如下圖所示:
這裡寫圖片描述

複製演算法

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

現在的商業虛擬機器都採用這種收集演算法來回收新生代,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)

分配擔保:如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件時,這些物件將直接通過分配擔保機制進入老年代。

標記-整理演算法

標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。
複製演算法的高校建立在存活物件少,垃圾物件多的前提下。這種情況在年輕代比較容易發生,在老年代更常見的情況是大部分都是存活物件。標記整理演算法,是一種老年代的回收演算法,從根節點對所有的物件做一次標記,然後降所有存活的物件壓縮到記憶體的另外一端,在清楚界邊界外所有的空間。這種方法不產生碎片,又不需要2塊相同的記憶體空間。
這裡寫圖片描述

增量演算法

增量演算法基本思想是:如果一次性將所有垃圾進行處理,需要在早晨系統長時間的停頓,那麼科技讓垃圾回收的執行緒和應用程式的執行緒交替執行。垃圾回收只是回收一小塊記憶體,接著切換到應用程式執行緒。這樣就減少了系統的停頓時間。因為執行緒的切換和上下文的轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量下降
增量收集演算法的技術是標記清楚和整理,只是允許垃圾回收程式以階段完成標記、清理、或複製工作。

分代收集演算法

根據物件存活週期的不同將記憶體分為幾塊。一般把Java堆分為新生代和老年代,根據各個年代的特點採用最合適的收集演算法。在新生代中,每次垃圾收集時有大批物件死去,只有少量存活,可以選用複製演算法。而老年代物件存活率高,使用標記清除或者標記整理演算法。

HotSpot的演算法實現

列舉根節點

從可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如幀棧中的本地變數表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這裡面的引用,那麼必然會消耗很多時間。

另外,可達性分析對執行時間的敏感還體現在GC停頓上,因為這項分析工作必須在一個能確保一致性的快照中進行–這裡“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中物件引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。這點是導致GC進行時必須停頓所有Java執行執行緒(Sun將這件事情稱為“Stop The World”)的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的CMS收集器中,列舉根節點也是必須要停頓的。

由於目前的主流Java虛擬機器使用的都是準確式GC,所以當執行系統停頓下來後,並不需要一個不漏地檢查完所有執行上下文和全域性的引用位置,虛擬機器應當是有辦法直接得知哪些地方存放著物件的引用。在HotSpot的實現中,是使用一組稱為OopMap的資料結構來達到這個目的的,在類載入完成的時候,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和暫存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些資訊了。

安全點

在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots列舉,但一個很現實的問題隨之而來:可能導致引用關係變化,或者說OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。

實際上,HotSpot也的確沒有為每條指令都生成OopMap,前面已經提到,只是在“特定的位置”記錄了這些資訊,這些位置稱為安全點(Safepoint),即程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定即不能太少以至於讓GC等待時間太長,也不能過於頻繁以至於過分增大執行時負荷。所以,安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的–因為每條指令執行的時間都非常短暫,程式不太可能因為指令流長度太長這個原因而過長時間執行,“長時間執行”的最明顯特徵就是指令序列複用,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

對於Safepoint,另一個需要考慮的問題是如何在GC發生時讓所以執行緒(這裡不包括執行JNI呼叫的執行緒)都“跑”到最近的安全點上再停頓下來。這裡有兩種方案可供選擇:

搶先式中斷(Preemptive Suspension)
主動式中斷(Voluntary Suspension)
其中搶先式中斷不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它“跑”到安全點上。現在幾乎沒有虛擬機器實現採用搶先式中斷來暫停執行緒從而響應GC事件。

而主動式中斷的思想是當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方。

安全區域

使用Safepoint似乎已經完美地解決了如何進入GC的問題,但實際情況卻並不一定。Safepoint機制保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程式就”不執行“的時候呢?所謂的程式不執行就是沒有分配CPU時間,典型的例子就是執行緒處於Sleep狀態或者Blocked狀態,這時候執行緒無法響應JVM的中斷請求,”走“到安全的地方去中斷掛起,JVM也顯然不太可能等待執行緒重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段程式碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴充套件了的Safepoint。

線上程執行到Safe Region中的程式碼時,首先標識自己已經進入了Safe Region,那樣,當在這段時間裡JVM要發起GC時,就不用管標識自己為Safe Region狀態的執行緒了。線上程要離開Safe Region時,它要檢查系統是否已經完成了根節點列舉(或者是整個GC過程),如果完成了,那執行緒就繼續執行,否則它就必須繼續等待直到收到可以安全離開Safe Region的訊號為止。

相關文章