Java編程拾遺『JVM垃圾回收』

垃圾收集(Garbage Collection)通常被稱爲GC,大部分人都把這項技術當作Java語言的伴生產物。事實上,GC的歷史遠遠比Java久遠,1960年誕生於MIT的Lisp語言是第一門真正使用內存動態分配和垃圾收集技術的語言。經過半個世紀的發展,內存動態分配與內存回收技術已經相當成熟,一切看起來都進入“自動化”時代,那麼爲什麼我們還要去了解GC和內存分配呢?原因很簡單:當需要排查各種內存溢出、內存泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們就需要對這些“自動化”技術實施必要的監控和調節。

之前的文章,介紹了Java運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧三個區域隨線程而生,隨線程而滅。一般方法或線程結束時,內存自然就跟隨着回收了,所以這幾個區域基本不需要考慮內存回收問題。而Java堆和方法區則不一樣,一個接口的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也不一樣,只有在程序處於運行期間才能知道要創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的就是這部分內存。

1. JVM垃圾收集

1.1 對象存活判斷

堆中幾乎存放着Java世界中所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是確定這些對象哪些還“存活”着,哪些已經“死亡”,以便後續將“死亡”對象回收,判斷對象是否存活一般有兩種方式。

1.1.1 引用計數器法

給對象添加一個引用計數器,每當有一個地方引用它時,計數器就加1。引用失效時,計數器值就減1。任何時刻,計數器爲0的對象就是不可能再被使用的對象,可以被垃圾收集器回收。此方法非常簡單,但是無法解決對象循環引用問題,現在的主流虛擬機都不實用引用計數器法判斷對象是否存活。

/**
 * @author zhuoli
 * VM Args: -XX:+PrintGCDetails
 */
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 main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;
        
        System.gc();
    }
}

運行結果:

[GC (System.gc()) [PSYoungGen: 8028K->512K(76288K)] 8028K->520K(251392K), 0.0034552 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 512K->0K(76288K)] [ParOldGen: 8K->443K(175104K)] 520K->443K(251392K), [Metaspace: 3077K->3077K(1056768K)], 0.0034621 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 655K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 1% used [0x000000076ab00000,0x000000076aba3ee8,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
  to   space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
 ParOldGen       total 175104K, used 443K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c006ef28,0x00000006cab00000)
 Metaspace       used 3084K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K

可以看到PSYoungGen: 8028K->512K,說明雖然兩個對象存在循環引用,但是還是被回收了,也證明虛擬機不是使用引用計數器法來判斷對象是否存活的(如果使用引用計數器法判斷,兩個對象的引用計數器都不是0,是不應該被回收的)。

1.1.2 根搜索算法

在主流的商用程序語言中(Java、C#等),都是使用根搜索算法(GC Roots Tracing)判斷對象是否存活的。這個算法的基本思路是通過一系列名爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GC Roots到這個對象不可達)時,則證明此對象是不可用的。如下圖對象object6、object7、object8雖然互相有關聯,但是他們到Gc Roots是不可達的,所以他們將會被判定爲可回收對象。

在Java語言中,可作爲GC Roots的對象包括以下幾種:

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

1.2 引用

無論是通過引用計數器算法判斷對象的引用數量,還是通過根搜索算法判斷對象的引用鏈是否可達,判斷對象是否存活都與“引用”有關。在JDK 1.2之前,Java中的引用定義很傳統:如果reference類型的數據中存儲的數值代表的另一塊內存的起始地址,就稱這塊內存代表着一個引用。這種定義很純粹,但是太過於狹隘,一個對象在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的對象就顯得無能爲力。午門希望能夠描述這樣一類對象:當內存空間還足夠時,則能保留在內存之中:如果內存在進行垃圾手機後還是非常緊張,則可以拋棄這些對象。很多系統的緩存功能都符合這樣的應用場景。

在JDK 1.2之後,Java對引用的概念進行了擴充,將引用費爲強引用、軟引用、弱引用、虛引用四種,這四種引用強度一次逐漸減弱。

  • 強引用:就是指在代碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還在,垃圾收集器永遠不會回首掉被引用的對象。
  • 軟引用:用來描述一些還有用,但並非必需的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,就會把這些對象列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的內存,纔會拋出內存溢出異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。
  • 弱引用:也是用來描述非必需的對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回首掉只被弱引用關聯的對象。在JDK 1.2之後,提供WeakReference類來實現弱引用。
  • 虛引用:是一種最弱的引用關係,一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來去的一個對象實例。爲一個對象設置虛引用關聯的唯一目的就是希望能在這個對象被垃圾收集器回收時收到一個系統通知。在Java 1.2之後,提供了PhantomReference類實現虛引用。

1.3 對象存活條件

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

如果這個對象被判定爲有必要執行finalize()方法,那麼這個對象將會被放置在一個F-Queue的隊列中,並在稍後由一條虛擬機自動創建的、低優先級的Finalizer線程去執行。這裏所謂的“執行”是指虛擬機會出發這個方法,但並不承諾會等待它執行結束。這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環(更極端的情況),將很可能會導致F-Queue隊列中的其它對象永久處於等待狀態,甚至導致這個那個內存回收系統崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue隊列中的對象進行第二次小規模標記。如果對象要在finalize()中拯救自己,只要重新與引用鏈上任何一個對象建立關聯即可(比如把自己賦值給某個類變量活對象的成員變量),那麼第二次標記時它將被移出“即將回收”的集合。如果對象這時候還是沒有逃脫,那它就真的離死不遠了。

public class FinalizeEscapeGCTest {

    public static FinalizeEscapeGCTest 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!");
        FinalizeEscapeGCTest.SAVE_HOOK = this;
    }

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

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

        //因爲Finalizer方法優先級很低,暫停500ms,等待它執行finalize()方法
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

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

        //因爲Finalizer方法優先級很低,暫停500ms,等待它執行finalize()方法
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

運行結果:

finalize method executed!
yes, i am still alive :)
no, i am dead :(

從運行結果可以看出,SAVE_HOOK對象的finalize()方法確實被GC收集器觸發過,並且在被收集前成功逃脫了。

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

雖然可以在finalize()方法中“拯救”對象,但是並不鼓勵使用這種方法來拯救對象。相反,建議大家儘量避免使用它,因爲它不是C/C++中的析構函數,而是Java剛誕生時爲了是C/C++程序員更容易接受它所做的一個妥協。它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。有些教材中提到它適合做“關閉外部資源”之類的工作,這完全是對這種方法的用途的一種自我安慰。finalize()能做的工作,使用try-finally或其它方式都可以做的更好、更及時,大家完全可以忘掉Java語言中還有這個方法的存在。

1.4 回收方法區

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

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用類。回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收爲例,加入一個字符串”abc”已經進入常量池中,但是當前系統沒有任何一個String對象是叫做”abc”的,換句話說是沒有任何String對象引用常量池中的”abc”常量,也沒有其他地方引用這個字面量,如果在這時候發生垃圾回收,而且必要的話,這個”abc”常量就會被系統回收掉。常量池中其他類(接口)、方法、字段的符號引用也與此類似。

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

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

在大量使用反射、動態代理、CGLIB等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

2. 垃圾收集算法

本節來介紹一下幾種常見的垃圾收集算法的思想及發展歷程。

2.1 標記——清除算法

“標記-清除”(Mark-Sweep)算法,如它的名字一樣,算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收掉所有被標記的對象。之所以說它是最基礎的收集算法,是因爲後續的收集算法都是基於這種思路並對其缺點進行改進而得到的。

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

2.2 複製算法

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

現在的商業虛擬機都採用這種收集算法來回收新生代,IBM的專門研究表明,新生代中的對象98%是朝生夕死的,所以不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden控件和塊兩塊較小的Survivor空間,每次使用Enden和其中一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor控件。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代可用內存空間爲整個新生代容量的90%(80% + 10%),只有10%的內存會被“浪費”掉。當然98%的對象可回收只是一般場景下的數據,我們沒辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要以來其它內存(這裏指老年代)進行分配擔保(Handle Promotion)。

內存的分配擔保機制就好比我們去銀行借錢,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要一個擔保人能保證如果我們不能還款時,可以從他的賬戶扣錢,那銀行就認爲沒有風險了。內存的分配擔保機制也一樣,如果另一塊Survivor空間沒有足夠的內存空間存放上一次新生代收集下來的存貨對象,也謝對象將直接通過分配擔保機制進入老年代。關於新生代進行分配擔保的內容,稍後再詳細介紹。

2.3 標記——整理算法

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

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

2.4 分代收集算法

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

3 垃圾收集器

如果說收集算法時內存回收的方法論,垃圾收集器就是內存回收的具體實現。Java虛擬機規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機所提供的垃圾收集器都可能都可能會有很大的差別,並且一般都會提供參數供用戶根據自己的特點和要求組合出各個年代所使用的的垃圾收集器。

上圖展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們之間可以搭配使用。

再介紹這些收集器之前,我們先來明確一個觀點:雖然我們是在對各個收集器進行比較,但並非爲了挑選一個最好的收集器出來。因爲知道現在爲止還沒有最好的收集器出現,更沒有萬能的收集器,所以我們選擇的只是對具體引用的最合適的收集器。這點不需要多加解釋就能證明:如果有一種航任何場景下都適用的完美收集器存在,那麼HotSpot虛擬機就沒必要實現那麼多不同的收集器了。

3.1 Serial收集器

Serial收集器是最基本,歷史最悠久的收集器,曾經(再JDK 1.3.1之前)是虛擬機新生代收集的唯一選擇。大家看名字就知道,這個收集器時候一個單線程的收集器,但它的“單線程”的意義並不僅僅是說明它只會使用一個CPU或一條手機線程去完成垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停其他所有的工作線程(Sun將這件事情稱之爲“Stop The World”),直到它收集結束。“Stop The World”這項工作是由虛擬機在後臺自動發起和自動完成的,在用戶不可見的情況下吧用戶的正常工作的線程全部停掉,這對很多應用來說都是難以接受的。假如,你的電腦沒運行一個小時候就會暫停響應5分鐘,你會有什麼樣的心情?Serial/Serial Old收集器的運行過程如下圖所示:

從JDK 1.3開始一直到現在,HotSpot虛擬機團隊爲消除或減少工作線程因內存回收而導致的停頓努力,從Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS),再到 Garbage First(G1)收集器,我們看到了一個個越來越優秀(也越來越複雜)的收集器出現,用戶線程的停頓時間再不斷縮減,但是仍然沒有辦法完全消除。

寫到這裏,Serial收集器看着像是一個老而無用的雞肋收集器,但實際上到現在爲止,它依然是虛擬機運行在Client模式下的默認新生代收集器。它也有着由於其它收集器的地方:簡單而高效(與其它收集器的單線程比),對於限定單個CPU的環境來說,Serial收集器沒有現成交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。再用戶的桌面引用場景中,分配給虛擬機管理的內存一般不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面引用基本不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒內,只要不是頻繁發生,這點停頓是可以接受的。所以Serial收集器對於運行在Client模式下的虛擬機來說是一個很好地選擇。

3.2 ParNew收集器

ParNew收集器其實即使Serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行爲包括Serial收集器可用的所有控制器參數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一樣,實現上中和兩種收集器頁共用了相當多的代碼。ParNew/Serial Old收集器的工作過程如下圖所示:

ParNew收集器除了多線程收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是許多運行在Server模式下的虛擬機中的首選新生代收集器,其中一個與性能無關但是很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。在JDK 1.5,HotSpot推出了一款在強交互應用中幾乎可以稱爲劃時代意義的垃圾收集器——CMS垃圾收集器(Concurrent Mark Sweep),這款收集器是HotSpot虛擬機中第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。舉個例子就是,媽媽幫你的屋子打掃衛生時,還允許你同時往地上扔垃圾。不過,CMS作爲老年代的垃圾收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或Serial收集器中的一個。ParNew收集器也是使用-XX:+UseConcMarkSweepGC選項後的默認新生代收集器,也可以使用-XX:+UseParNewGC選項來強制定它。

ParNew收集器在單CPU的環境中絕對不會比Serial收集器效果更好,甚至由於存在線程交互的開銷,該收集器在通過超線程技術實現的兩個CPU環境中都不能百分之百保證能超越Serial收集器。當然,隨着使用的CPU的數量的增加,他對於GC時系統資源的利用還是很有好處的。它默認開啓的垃圾收集線程的數與CPU的數量相同(可以使用-XX:ParallelGCThreads參數來限制垃圾收集的線程數)。

3.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一個新生代收集器,它也是使用複製算法的收集器,又是並行的多線程收集器。看上去跟ParNew收集器非常相似,他們之間有什麼區別?在此之前我們先來熟悉一下如下兩個概念——垃圾收集器中的並行與併發。關於並行和併發的概念之前在介紹多線程時,已經說明過,這裏我麼從垃圾收集器角度來解釋一下這兩個詞。

  • 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。
  • 併發(Concurrent):至用戶線程與垃圾收線程同時執行(但不一定是並行的,有可能會交替執行),用戶程序繼續運行,而垃圾收集程序運行於另一個CPU上。

Parallel Scavenge收集器的特點是他的關注點和其它收集器不同,CMS等收集器的關注點是儘可能縮短垃圾收集時用戶線程的停頓時間,而ParallelScavenge收集器的目標是達到一個可控的吞吐量(Throughput)。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,及吞吐量 = 運行用戶代碼的時間 / (運行用戶代碼的時間 + 垃圾收集時間),虛擬機共運行100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

停頓時間越短就越適合需要用戶交互的程序,良好的響應速度能提升用戶的體驗;而高吞吐量則可以最高效率的利用CPU時間,儘快的完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務

Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數及直接設置吞吐量大小的-XX:GCTimeRatio參數。

MaxGCPauseMillls參數允許設置的值是一個大於0的毫秒數,收集器將盡力保證內存回收花費的時間不超過設定值。不過大家不要太異想天開地認爲如果把這個參數的值設置的稍微小一點就能使得系統的垃圾收集速度變得更快,GC停頓四濺縮短是以犧牲吞吐量和新生代空間換取的:系統把新生代調小一些,收集300M新生代肯定比收集500M新生代快,這也直接導致垃圾收集發生的更頻繁,原來10S收集一次、每次停頓100毫秒,現在變成5S收集一次,每次停頓70毫秒。停頓時間在下降,但是吞吐量也降下來了。

GCTimeRatio參數的值應當是一個大於0小於100的整數,通過-XX:GCTimeRatio=我們告訴JVM吞吐量要達到的目標值。 更準確地說,-XX:GCTimeRatio=N指定目標應用程序線程的執行時間(與總的程序執行時間)達到N/(N+1)的目標比值。 例如,通過-XX:GCTimeRatio=9我們要求應用程序線程在整個執行時間中至少9/10是活動的(因此,GC線程佔用其餘1/10)。 基於運行時的測量,JVM將會嘗試修改堆和GC設置以期達到目標吞吐量。 -XX:GCTimeRatio的默認值是99,也就是說,應用程序線程應該運行至少爲總執行時間的99%(吞吐量爲99%)。

由於與吞吐量關係密切,Parallel Scanvenge收集器也經常被稱爲“吞吐量優先”收集器。除上述兩個參數之外,Parallel Scavenge收集器還有一個參數-XX:+UseAdaptiveSizePolicy值得關注。這是一個開關參數,當這個參數打開之後,就不需要手工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升到老年代對象年齡(-XX:PretenureSizeThreadhold)等細節參數了,虛擬機會根據當前系統的運行情況手機性能監控信息,動態調整這些參數以提供最合適的停頓時間或最大的吞吐量,這種提高接訪室稱爲GC自適應調節策略(GC Ergonomics)。如果對於垃圾收集器的運行原理不太瞭解,手工優化存在困難的時候,使用Parallel Scavenge收集器配合自適應調節策略,把內存管理的調優任務交給虛擬機去完成將是一個不錯的選擇。只需要把基本的內存數據設置好(如-Xmx設置最大堆),然後使用MaxGCPauseMillls參數(關注最大停頓時間)或GCTimeRatio參數(關注吞吐量)給虛擬機設立一個優化目標,那具體的細節參數的調整工作就有虛擬機完成了。自適應調節策略也是ParallelScavenge收集器與ParNew收集器的一個重要區別。

3.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法。這個收集器的主要意義也是被Client模式下的虛擬機使用。如果在Server模式下,它主要還有兩大用途:一個是在JDK 1.5及之前的版本中與Parallel Scavenge收集器搭配是喲好難過,另一個就是作爲CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure的時候使用。Serial/Serial Old收集器的運行過程如下圖所示:

3.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程“標記-整理”算法。這個收集器實在JDK 1.6中才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處於比較尷尬的狀態。原因是,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old收集器外別無選擇,即使使用了Parallel Scavenge收集器,也未必能在整體上獲得吞吐量最大化的效果,又因爲老年代手機中無法充分利用服務器多CPU的處理能力,在老年代很大而且硬件比較高級的環境中,這種組合的吞吐量甚至還不一定有ParNew加CMS組合給力。

直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注意吞吐量及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。Parallel Scavenge/Parallel Old收集器的運行過程如下:

3.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短會搜停頓時間爲目標的收集器。目前很大一部分的Java應用都集中在互聯網站或B/S系統的服務端上,這類應用有其中是服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收集器就非常符合這類應用的需求。

從名字(包含Mark Sweep)上就可以看出CMS收集器是基於“標記——清除”算法實現的,他的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分爲4個步驟,包括:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,併發標記階段就是進行跟搜索算法(GC Roots Tracing)標記的過程,而重新標記階段則是爲了修正併發標記期間,因爲用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,單元比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程中,垃圾收集器線程都可以與用戶線程一起工作,所以總體上來說,CMS收集器的內存回收過程是與用戶線程一起併發的執行的。CMS垃圾收集器的運行過程如下圖所示:

CMS是一款優秀的垃圾收集器,他的最主要的優點都在名字上已經體現出來了:併發收集、低停頓,Sun的一些官方文檔裏也稱之爲併發低停頓收集器(Concurrent Low Pause Collector)。但是CMS還遠達不到完美的程度,它有以下三個顯著的缺點:

  • CMS收集器對CPU資源非常敏感。其實面向併發設計的程序都對CPU資源比較敏感。在併發階段,它雖然不會導致用戶線程停頓,但是會因爲佔用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。CMS默認啓動回收線程數目是(CPU數量 + 3) / 4,也就是當CPU在4個以上時,併發回收時垃圾收集線程最多佔有不超過25%的CPU資源。但是當CPU資源不足4個時,那麼CMS對用戶程序的影響就可能變得很大,如果CPU負載本身就比較大的時候,還分出一半的運算能力去執行收集器線程,就可能導致用戶的執行速度忽然降低了50%,這也讓人很受不了。爲了解決這種情況,虛擬機提供了一種稱爲“增量式併發收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器變種,所做的事情和單CPU年代PC機操作系統使用搶佔式來模擬多任務機制的思想一樣,就是在併發標記和併發清理的時候讓GC線程、用戶線程交替運行,儘量減少GC線程的獨佔資源的時間,這樣整個垃圾收集的過程會更長,但對用戶線程的影響就會顯得少一些,速度下降也就沒那麼麼明顯,但是目前版本中,i-CMS已經被聲明爲“deprecated”,即不再提倡用戶使用。
  • CMS收集器無法處理浮動垃圾(Floating Garbage),可能會出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。由於CMS併發清理階段用戶線程還在運行着,伴隨着程序的運行自然還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在本次手機中處理掉它們,只好等待下一次GC時再將其清理掉。這一部分垃圾就稱爲“浮動垃圾”。也是由於在垃圾收集階段用戶線程還需要運行,即還需要預留足夠的內存空間給用戶線程使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程序運行使用。在默認設置下,CMS收集器在老年代使用了68%的空間後就會被激活,這是一個偏保守的設置,如果在應用中老年代增長不是太快,可以適當調高參數-XX:CMSInitiatingOccupancyFraction的值來提高觸發百分比,以便降低內存回收次數以獲取更好的性能。要是CMS運行期間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失敗,這時候虛擬機將啓動後備預案:臨時啓動Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說參數-XX:CMSInitiatingOccupancyFraction設置的太將會容易導致大量“Concurrent Mode Failure”失敗,性能反而降低。
  • 最後一個缺點,就是CMS是一款基於“標記-清除”算法實現的垃圾收集器,這也就意味着收集結束時會產生大量的空間碎片。空間碎片過多時,將會給大對象的分配帶來很大的麻煩,往往會出現老年代還有很大的剩餘空間,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次Full GC。爲了解決這個問題,CMS收集器提供了一個-XX:UseCMSCompactAtFullCollection開關參數,用於在完成Full GC後進行碎片處理,碎片整理的過程是無法併發的。空間碎片問題沒有了,單停頓時間不得不變長了。虛擬機提供了另一個參數-XX:CMSFullGCsBeforeCompaction,這個參數用於設置在執行多少次不整理碎片的GC後,跟着來一次帶碎片整理的GC。

關於CMS收集器,介紹了這麼多,有一點還是不得不提的是,在JEP 291中說道CMS垃圾收集器已經被置爲“deprecated”,虛擬機團隊不在對該收集器提供支持,建議使用G1垃圾收集器替換CMS垃圾收集器。

3.7 G1收集器

G1垃圾收集器(Garbage-First Garbage Collector)是一種以可控停頓時間爲目標,並且在此基礎上儘可能提高吞吐量的收集器。可以通過-XX:+UseG1GC參數來啓用,作爲體驗版隨着JDK 6u14版本面世,在JDK 7u4版本發行時被正式推出。在JDK 9中,G1被提議設置爲默認垃圾收集器(JEP 248)。在JEP 291中停止了對CMS收集器的支持,建議使用G1收集器替代CMS收集器。

G1收集器被設計用來取代CMS收集器,和CMS相同的地方在於,它們都屬於併發收集器,在大部分的收集階段都不需要掛起應用程序。G1垃圾收集器適用於以下場景:

  • 服務端多核CPU、JVM內存佔用較大的應用(至少大於4G)
  • 應用在運行過程中會產生大量內存碎片、需要經常壓縮空間
  • 想要更可控、可預期的GC停頓週期,防止高併發下應用雪崩現象

與CMS收集器相比,G1收集器在以下方面表現的更出色:

  • G1是一個有整理內存過程的垃圾收集器,不會產生很多內存碎片
  • G1的Stop The World(STW)更可控,G1在停頓時間上添加了預測機制,用戶可以指定期望停頓時間

3.7.1 G1收集器內存模型

傳統的GC收集器將連續的內存空間劃分爲新生代、老年代和永久代(Java8去除了永久代,引入了元空間Metaspace),這種劃分的特點是各代的存儲地址(邏輯地址,下同)是連續的。如下圖所示:

而G1收集器的各代存儲地址是不連續的,每一代都使用了n個不連續的大小相同的Region,每個Region佔有一塊連續的虛擬內存地址。如下圖所示:

在上圖中,還有一些Region標明瞭H,它代表Humongous,表示這些Region存儲的是巨大對象(humongous object,H-obj),即大小大於等於region一半的對象。H-obj有如下幾個特徵:

  • H-obj直接分配到了old gen,防止了反覆拷貝移動
  • H-obj在global concurrent marking階段的cleanup 和 full GC階段回收
  • 在分配H-obj之前先檢查是否超過 initiating heap occupancy percent和the marking threshold, 如果超過的話,就啓動global concurrent marking,爲的是提早回收,防止 evacuation failures 和 full GC

一個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值範圍從1M到32M,且是2的指數。如果不設定,那麼G1會根據Heap大小自動決定。

3.7.2 SATB

SATB全稱是Snapshot-At-The-Beginning,由字面理解,是GC開始時活着的對象的一個快照。它是通過Root Tracing得到的,作用是維持併發GC的正確性。 那麼它是怎麼維持併發GC的正確性的呢?根據三色標記算法,我們知道對象存在三種狀態:

  • 白:對象沒有被標記到,標記階段結束後,會被當做垃圾回收掉
  • 灰:對象被標記了,但是它的field還沒有被標記或標記完
  • 黑:對象被標記了,且它的所有field也被標記完了

由於併發階段的存在,Mutator和Garbage Collector線程同時對對象進行修改,就會出現白對象漏標的情況,這種情況發生的前提是:

  • Mutator賦予一個黑對象該白對象的引用
  • Mutator刪除了所有從灰對象到該白對象的直接或者間接引用

對於第一個條件,在併發標記階段,如果該白對象是new出來的,並沒有被灰對象持有,那麼它會不會被漏標呢?Region中有兩個top-at-mark-start(TAMS)指針,分別爲prevTAMS和nextTAMS。在TAMS以上的對象是新分配的,這是一種隱式的標記。對於在GC時已經存在的白對象,如果它是活着的,它必然會被另一個對象引用,即條件二中的灰對象。如果灰對象到白對象的直接引用或者間接引用被替換了,或者刪除了,白對象就會被漏標,從而導致被回收掉,這是非常嚴重的錯誤,所以SATB破壞了第二個條件。也就是說,一個對象的引用被替換時,可以通過write barrier 將舊引用記錄下來。

//  share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.hpp
// This notes that we don't need to access any BarrierSet data
// structures, so this can be called from a static context.
template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {
  T heap_oop = oopDesc::load_heap_oop(field);
  if (!oopDesc::is_null(heap_oop)) {
    enqueue(oopDesc::decode_heap_oop(heap_oop));
  }
}
// share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.cpp
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  // Nulls should have been already filtered.
  assert(pre_val->is_oop(true), "Error");
  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

SATB也是有副作用的,如果被替換的白對象就是要被收集的垃圾,這次的標記會讓它躲過GC,這就是float garbage。因爲SATB的做法精度比較低,所以造成的float garbage也會比較多。

1.2.7.3 RSet

RSet全稱是Remembered Set,是輔助GC過程的一種結構,典型的空間換時間工具,和Card Table有些類似。還有一種數據結構也是輔助GC的:Collection Set(CSet),它記錄了GC要收集的Region集合,集合裏的Region可以是任意年代的。在GC的時候,對於old->young和old->old的跨代對象引用,只要掃描對應的CSet中的RSet即可。 邏輯上說每個Region都有一個RSet,RSet記錄了其他Region中的對象引用本Region中對象的關係,屬於points-into結構(誰引用了我的對象)。而Card Table則是一種points-out(我引用了誰的對象)的結構,每個Card 覆蓋一定範圍的Heap(一般爲512Bytes)。G1的RSet是在Card Table的基礎上實現的:每個Region會記錄下別的Region有指向自己的指針,並標記這些指針分別在哪些Card的範圍內。 這個RSet其實是一個Hash Table,Key是別的Region的起始地址,Value是一個集合,裏面的元素是Card Table的Index。下圖表示了RSet、Card和Region的關係:

上圖中有三個Region,每個Region被分成了多個Card,在不同Region中的Card會相互引用,Region1中的Card中的對象引用了Region2中的Card中的對象,藍色實線表示的就是points-out的關係,而在Region2的RSet中,記錄了Region1的Card,即紅色虛線表示的關係,這就是points-into。 而維繫RSet中的引用關係靠post-write barrier和Concurrent refinement threads來維護,操作僞代碼如下:

void oop_field_store(oop* field, oop new_value) {
  pre_write_barrier(field);             // pre-write barrier: for maintaining SATB invariant
  *field = new_value;                   // the actual store
  post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}

post-write barrier記錄了跨Region的引用更新,更新日誌緩衝區則記錄了那些包含更新引用的Cards。一旦緩衝區滿了,Post-write barrier就停止服務了,會由Concurrent refinement threads處理這些緩衝區日誌。 RSet究竟是怎麼輔助GC的呢?在做YGC的時候,只需要選定young generation region的RSet作爲根集,這些RSet記錄了old->young的跨代引用,避免了掃描整個old generation。 而mixed gc的時候,old generation中記錄了old->old的RSet,young->old的引用由掃描全部young generation region得到,這樣也不用掃描全部old generation region。所以RSet的引入大大減少了GC的工作量。

1.2.7.4 Pause Prediction Model

Pause Prediction Model 即停頓預測模型。它在G1中的作用是: 

G1 uses a pause prediction model to meet a user-defined pause time target and selects the number of regions to collect based on the specified pause time target

 

即G1收集器通過停頓預測模型預估選擇回收的region數目來滿足用戶預設的停頓時間。

G1 GC是一個響應時間優先的GC算法,它與CMS最大的不同是,用戶可以設定整個GC過程的期望停頓時間,參數-XX:MaxGCPauseMillis指定一個G1收集過程目標停頓時間,默認值200ms,不過它不是硬性條件,只是期望值。具體實現比較複雜,有興趣的可以去了解一下。

1.2.7.4 Global Concurrent Marking

Global Concurrent Marking執行過程類似CMS,但是不同的是,在G1 GC中,它主要是爲Mixed GC(G1提供的一種模式,下文會介紹)提供標記服務的,並不是一次GC過程的一個必須環節。G1收集器Global Concurrent Marking的流程如下圖所示:

  • G1執行的第一階段:初始標記(Initial Marking )

這個階段是STW(Stop the World )的,所有應用線程會被暫停,標記所有從GC Roots可直接到達的對象並將它們的字段壓入掃描棧(marking stack)中等到後續掃描。G1使用外部的bitmap來記錄mark信息,而不使用對象頭的mark word裏的mark bit。在分代式G1模式中,初始標記階段借用young GC的暫停,因而沒有額外的、單獨的暫停階段。

  • G1執行的第二階段:併發標記

通過跟搜索算法,不斷從掃描棧取出引用遞歸掃描整個堆裏的對象圖。每掃描到一個對象就會對其標記,並將其字段壓入掃描棧。重複掃描過程直到掃描棧清空。過程中還會掃描SATB write barrier所記錄下的引用。

  • 最終標記

在完成併發標記後,每個Java線程還會有一些剩下的SATB write barrier記錄的引用尚未處理。這個階段就負責把剩下的引用處理完,這個階段也是STW(Stop The World)的。同時這個階段也進行弱引用處理(reference processing)。

注意這個暫停與CMS的remark有一個本質上的區別,那就是這個暫停只需要掃描SATB buffer,而CMS的remark需要重新掃描mod-union table裏的dirty card外加整個根集合,而此時整個young gen(不管對象死活)都會被當作根集合的一部分,因而CMS remark有可能會非常慢。

  • 清理垃圾

清除空Region(沒有存活對象的),加入到free list。

第一階段Initial Marking是共用了Young GC的暫停,這是因爲他們可以複用root scan操作,所以可以說Global Concurrent Marking是伴隨Young GC而發生的。第四階段Cleanup只是回收了沒有存活對象的Region,所以它並不需要STW。

1.2.7.4 G1收集器模式

G1收集器提供了兩種GC模式,Young GC和Mixed GC,兩種都是完全Stop The World的。 

  • Young GC:選定所有年輕代裏的Region。通過控制年輕代的region個數,即年輕代內存大小,來控制young GC的時間開銷
  • Mixed GC:選定所有年輕代裏的Region,外加根據Global Concurrent Marking統計得出收集收益高的若干老年代Region。在用戶指定的開銷目標範圍內儘可能選擇收益高的老年代Region。

需要注意的是,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC實在無法跟上程序分配內存的速度,導致老年代填滿無法繼續進行Mixed GC,就會使用serial old GC(full GC)來收集整個GC heap。所以我們可以知道,G1是不提供full GC的。

Young GC

在分配一般對象(非巨型對象)時,當所有Eden Region使用達到最大閥值並且無法申請足夠內存時,會觸發一次Young GC。每次Young Gc會回收所有Eden以及Survivor區,並且將存活對象複製到Old區以及另一部分的Survivor區。

Young GC的回收過程如下:

  • 根掃描,跟CMS類似,Stop The world,掃描GC Roots對象
  • 處理Dirty card,更新RSet
  • 掃描RSet,掃描RSet中所有old區對掃描到的young區或者survivor區的引用
  • 拷貝掃描出的存活的對象到survivor2/old區
  • 處理引用隊列、軟引用、弱引用、虛引用

Mixed GC

當越來越多的對象晉升到老年代old region時,爲了避免堆內存被耗盡,虛擬機會觸發一個混合的垃圾收集,即Mixed Gc。Mixed Gc回收整個young region,還有一部分的old region。這裏需要注意的是,只一部分老年代,而不是全部老年代,回收老年代空間時會根據停頓預測模型(Pause Prediction Model)選擇需要回收的region。需要回收的region都會放入CSet,region是否進入Cset會受以下幾個參數影響:

  • G1MixedGCLiveThresholdPercent:old generation region中的存活對象的佔比,只有在此參數之下,纔會被選入CSet
  • G1MixedGCCountTarget:一次global concurrent marking之後,最多執行Mixed GC的次數
  • G1OldCSetRegionThresholdPercent:一次Mixed GC中能被選入CSet的最多old generation region數量

除了以上的參數,G1 GC相關的其他主要的參數有:

  • -XX:G1HeapRegionSize=n:設置Region大小,取值範圍從1M到32M,且是2的指數。如果不設定,那麼G1會根據Heap大小自動決定
  • -XX:MaxGCPauseMillis:設置G1收集過程目標時間,默認值200ms,不是硬性條件
  • -XX:G1NewSizePercent:新生代最小值,默認值5%
  • -XX:G1MaxNewSizePercent:新生代最大值,默認值60%
  • -XX:ParallelGCThreads:STW期間,並行GC線程數
  • -XX:ConcGCThreads=n:併發標記階段,並行執行的線程數
  • -XX:InitiatingHeapOccupancyPercent:設置觸發標記週期的 Java 堆佔用率閾值。默認值是45%。這裏的java堆佔比指的是non_young_capacity_bytes,包括old+humongous

參考鏈接:

1. 《深入理解Java虛擬機》

2. [HotSpot VM] 請教G1算法的原理

3. Java Hotspot G1 GC的一些關鍵技術

4. G1 垃圾收集器介紹

5. jvm系列(三):GC算法 垃圾收集器

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