垃圾收集器與內存分配策略

垃圾收集器與內存分配策略

標籤(空格分隔): 未分類



GC要完成的三件事

  • 哪些內存需要進行回收
  • 什麼時候進行回收
  • 如何回收

程序計數器、 虛擬機棧、本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因爲方法結束或者線程結束時,內存自然就跟隨着回收了。

Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的是這部分內存。


對象死了嗎?

引用計數法

  • 基本原理:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器爲0的對象就是不可能再被使用的。
  • 有點:引用計數算法(Reference Counting)的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的算法,也有一些比較著名的應用案例,例如微軟公司的COM(Component Object Model)技術、 使用ActionScript 3的FlashPlayer、 Python語言和在遊戲腳本領域被廣泛應用的Squirrel中都使用了引用計數算法進行內存管理。
  • 不足:主流的Java虛擬機裏面沒有選用引用計數算法來管理內存,其中最主要的原因是它很難解決對象之間相互循環引用的問題。
public class ReferenceCountingGC {
    public Object instance;

    private static final int _1MB = 1024 * 1024;

    private byte[] bigSize = new byte[_1MB];

    public static void main(String[] args) {
        ReferenceCountingGC r1 = new ReferenceCountingGC();
        ReferenceCountingGC r2 = new ReferenceCountingGC();

        r1.instance = r2;
        r2.instance = r1;

        r1 = null;
        r2 = null;

        System.gc();
    }
}

# 現在的Java虛擬機即使出現循環引用也會進行垃圾回收。
output:
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -XX:+PrintGCDetails ch3.ReferenceCountingGC
[GC (System.gc()) [PSYoungGen: 3993K->416K(56320K)] 3993K->424K(184832K), 0.0032026 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 416K->0K(56320K)] [ParOldGen: 8K->258K(128512K)] 424K->258K(184832K), [Metaspace: 2492K->2492K(1056768K)], 0.0136073 secs] [Times: user=0.02 sys=0.02, real=0.02 secs] 
Heap
 PSYoungGen      total 56320K, used 486K [0x0000000781700000, 0x0000000785580000, 0x00000007c0000000)
  eden space 48640K, 1% used [0x0000000781700000,0x0000000781779b10,0x0000000784680000)
  from space 7680K, 0% used [0x0000000784680000,0x0000000784680000,0x0000000784e00000)
  to   space 7680K, 0% used [0x0000000784e00000,0x0000000784e00000,0x0000000785580000)
 ParOldGen       total 128512K, used 258K [0x0000000704400000, 0x000000070c180000, 0x0000000781700000)
  object space 128512K, 0% used [0x0000000704400000,0x0000000704440b00,0x000000070c180000)
 Metaspace       used 2499K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K

可達性分析

主流的商用程序語言(Java、C#,甚至包括前面提到的古老的Lisp)的主流實現中,都是稱通過可達性分析(Reachability Analysis)來判定對象是否存活的;

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

GC Root對象包括:
- 虛擬機棧(棧幀中的本地變量表)中引用的對象。
- 方法區中類靜態屬性引用的對象。
- 方法區中常量引用的對象。
- 本地方法棧中JNI(即一般說的Native方法)引用的對象。

引用

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

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

生存還是死亡

在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與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;

    public void isAlive() {
        System.out.println("I am 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 InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

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

        // Finalizer線程優先級很低,暫停0.5s等待;
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("I am dead :(");
        }

        // 與上面相同的代碼,但是對象finalize方法只會被系統調用一次,這裏finalize方法不會再次執行,FinalizeEscapeGC自救失敗;
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("I am dead :(");
        }
    }
}


output:
finalize method executed
I am still Alive.
I am dead :(

回收方法區

Java虛擬機規範不要求虛擬機在方法區實現垃圾收集,而且在方法區中進行垃圾收集的“性價比”一般比較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低於此。

方法區(或者說永久代)的垃圾回收主要是回收兩部分內容:廢棄常量和無用的類;

  • 假如一個字符串“abc”已經進入了常量池中,但是當前系統沒有任何一個String對象是叫做“abc”的,換句話說,就是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果這時發生內存回收,而且必要的話,這個“abc”常量就會被系統清理出常量池。常量池中的其他類(接口)、 方法、 字段的符號引用也與此類似。
  • 類需要同時滿足下面3個條件才能算是“無用的類”
    • 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例。
    • 加載該類的ClassLoader已經被回收。
    • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看類加載和卸載信息。


垃圾收集算法

標記應該是指判定對象是否存活時進行的兩次標記。

標記-清除算法

  • 算法分爲“標記”和”清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有
    被標記的對象
  • 主要不足有兩個:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

mark-sweep.png-66.5kB

複製算法

  • 將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。
  • 優點:不用考慮內存碎片,實現簡單,運行高效;
  • 缺點:內存縮小爲原來的一半;
  • 適用於新生代的垃圾收集;新生代中的對象98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間(永遠只用了一塊Survior區)。 HotSpot虛擬機默認Eden和Survivor的大小比例是8:1。沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這裏指老年代)進行分配擔保(Handle Promotion)
  • 內存的分配擔保是指:如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代

copy-algorithm.png-66.2kB

標記-整理算法

  • 標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。
  • 客服了複製算法中如果大量對象存活時效率變低的弱點以及可用內存空間減半的問題;
  • 一般適用於老年代的垃圾收集;

mark_clean.png-64.7kB

分代收集算法

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


HotSpot算法實現

對象存活判定算法和垃圾收集算法的效率問題

枚舉根節點

  • 可達性分析中,可作爲GCRoots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中;逐個檢查裏面的引用,相當耗時;
  • 可達性分析對執行時間的敏感還體現在GC停頓上,因爲這項分析工作必須在一個能確保一致性的快照中進行——這裏“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。 這點是導致GC進行時必須停頓所有Java執行線程(Sun將這件事情稱爲“Stop The World”)的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的。
  • 當前,執行系統停頓下來以後,不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放着對象引用。在HotSpot的實現中,是使用一組稱爲OopMap的數據結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些信息了。

安全點

  • 在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots枚舉,但是不可能爲每一條指令都生成對應的OopMap(又會產生額外的開銷),HotSpot也的確沒有爲每條指令都生成OopMap;只是在“特定的位置”記錄了這些信息,這些位置稱爲安全點(Safepoint),即程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停
  • Safepoint的選定既不能太少以致於讓GC等待時間太長,也不能過於頻繁以致於過分增大運行時的負荷。 所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特徵”爲標準進行選定的,“長時間執行”的最明顯特徵就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,所以具有這些功能的指令纔會產生Safepoint。
  • 對於Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這裏不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。 這裏有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension),其中搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應GC事件;主動式中斷的思想是當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的信號爲止。

垃圾收集器

gabage_collectors.png-127.7kB

Serial收集器

  • 單線程;進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束;虛擬機運行在Client模式下的默認新生代收集器;簡單而高效(與其他收集器的單線程比);複製算法
    Serial.png-119.5kB

ParNew收集器

  • Serial收集器的多線程版本複製算法;
  • 其餘行爲包括Serial收集器可用的所有控制參數(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、 -XX:HandlePromotionFailure等)、 收集算法、 Stop The World、 對象分配規則、 回收策略等都與Serial收集器完全一樣;
  • Server模式下的虛擬機中首選的新生代收集器,除了Serial收集器外,目前只有它能與CMS收集器配合工作
  • ParNew收集器也是使用-XX:+UseConcMarkSweepGC選項後的默認新生代收集器,也可以使用-XX:+UseParNewGC選項來強制指定它
  • 默認開啓的收集線程數與CPU的數量相同,可以使用-XX:ParallelGCThreads參數來限制垃圾收集的線程數;

ParNew.png-131.3kB

垃圾收集器的上下文語境下的併發和並行:

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

Parallel Scavenge收集器

  • 新生代收集器,複製算法,又是並行的多線程收集器
  • Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間)
  • Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
  • MaxGCPauseMillis參數允許的值是一個大於0的毫秒數,收集器將儘可能地保證內存回收花費的時間不超過設定值。不過GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的(新生代縮小,垃圾蒐集頻繁,吞吐量下降)。
  • GCTimeRatio參數的值應當是一個大於0且小於100的整數,也就是垃圾收集時間佔總時間的比率。此參數設置爲19,那允許的最大GC時間就佔總時間的5%(即1/(1+19)),默認值爲99,就是允許最大1%(即1/(1+99))的垃圾收集時間。
  • Parallel Scavenge收集器還有一個參數-XX:+UseAdaptiveSizePolicy – GC
    自適應的調節策略(GC Ergonomics);

Serial Old收集器

  • Serial收集器的老年代版本;單線程;“標記-整理”算法;
  • 給Client模式下的虛擬機使用

serial-old.png-117kB

Parallel Old收集器

  • Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法。
  • JDK 1.6
  • 注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。

parallel_old.png-127.9kB

CMS收集器

  • 獲取最短回收停頓時間爲目標的收集器;基於“標記—清除”算法;
  • 運行過程:
    • 初始標記(CMS initial mark)
    • 併發標記(CMS concurrent mark)
    • 重新標記(CMS remark)
    • 併發清除(CMS concurrent sweep)
    • 重置線程
  • 初始標記、 重新標記這兩個步驟仍然需要“Stop The World”。 初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,併發標記階段就是進行GC RootsTracing的過程,而重新標記階段則是爲了修正併發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。
  • CMS的缺點
    • 對CPU資源非常敏感。CMS默認啓動的回收線程數是(CPU數量+3)/4;
    • 無法處理浮動垃圾(Floating Garbage),可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生;CMS併發清理階段用戶線程還在運行着,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。 這一部分垃圾就稱爲“浮動垃圾”。因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程序運作使用;
    • JDK 1.5的默認設置下,CMS收集器當老年代使用了68%的空間後就會被激活,這是一個偏保守的設置,如果在應用中老年代增長不是太快,可以適當調高參數-XX:CMSInitiatingOccupancyFraction的值來提高觸發百分比,以便降低內存回收次數從而獲取更好的性能,在JDK 1.6中,CMS收集器的啓動閾值已經提升至92%。要是CMS運行期間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失敗,這時虛擬機將啓動後備預案:臨時啓用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。 所以說參數-XX:CM SInitiatingOccupancyFraction設置得太高很容易導致大量“Concurrent Mode Failure”失敗,性能反而降低。
    • CMS基於“標記—清除”算法實現;收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次Full GC。
    • CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開 關參數(默認就是開啓的),用於在CMS收集器頂不住要進行Full GC時開啓內存碎片的合併整理過程,內存整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設計者還提供了另外一個參數-XX:CMSFullGCsBeforeCompaction,這個參數是用於設置執行多少次不壓縮的Full GC後,跟着來一次帶壓縮的(默認值爲0,表示每次進入Full GC時都進行碎片整理)。

G1收集器

  • G1特徵:

    • 並行與併發:G1能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程序繼續執行。
    • 分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次GC的舊對象以獲取更好的收集效果。
    • 空間整合:與CMS的“標記—清理”算法不同,G1從整體來看是基於“標記—整理”算法實現的收集器,從局部(兩個Region之間)上來看是基於“複製”算法實現的,但無論如何,這兩種算法都意味着G1運作期間不會產生內存空間碎片,收集後能提供規整的可用內存。這種特性有利於程序長時間運行,分配大對象時不會因爲無法找到連續內存空間而提前觸發下一次GC。
    • 可預測的停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵
  • 將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。

  • G1跟蹤各個Region裏面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。
  • 問題是Region不可能是孤立的。一個對象分配在某個Region中,它並非只能被本Region中的其他對象引用,而是可以與整個Java堆任意的對象發生引用關係(不可能去掃描整個堆)。在G1收集器中,Region之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,虛擬機都是使用Remembered Set來避免全堆掃描的。
  • G1中每個Region都有一個與之對應的Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處於不同的Region之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),如果是,便通過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。當進行內存回收時,在GC根節點的枚舉範圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏

  • G1收集器的運行步驟

    • 初始標記(Initial Marking)
    • 併發標記(Concurrent Marking)
    • 最終標記(Final Marking)
    • 篩選回收(Live Data Counting and Evacuation)

    初始標記階段僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。 併發標記階段是從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序併發執行。 而最終標記階段則是爲了修正在併發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs裏面,最終標記階段需要把Remembered Set Logs的數據合併到Remembered Set中,這階段需要停頓線程,但是可並行執行。最後在篩選回收階段首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也可以做到與用戶程序一起併發執行,但是因爲只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。

G1.png-180kB

GC日誌

33.125(# GC發生的時間):[GC[DefNew:3324K->152K3712K),0.0025925 secs]3324K->152K11904K),0.0031680 secs] 100.667:[Full GC[Tenured:0 K->210K10240K),0.0149142sec s]4603K->210K19456K),[Perm:2999K->
2999K21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
  • 33.125 GC發生的時間– 是從Java虛擬機啓動以來經過的秒數。
  • “[GC”和“[Full GC”說明了這次垃圾收集的停頓類型,如果有“Full”,說明這次GC是發生了Stop-The-World的
  • 如果是調用System.gc()方法所觸發的收集,那麼在這裏將顯示“[Full GC(System)”
  • “[DefNew”、 “[Tenured”、 “[Perm”表示GC發生的區域,這裏顯示的區域名稱與 使用的GC收集器是密切相關的;如果是ParNew收集器,新生代名稱就會變爲“[ParNew”,意爲“Parallel New Generation”。如果採用Parallel Scavenge收集器,那它配套的新生代稱爲“PSYoungGen”,老年代和永久代同理,名稱也是由收集器決定的。
  • 方括號內部的“3324K->152K(3712K)”含義是“GC前該內存區域已使用容量->GC後該內存區域已使用容量(該內存區域總容量)”。而在方括號之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量)”。
  • “0.0025925 secs”表示該內存區域GC所佔用的時間,單位是秒

垃圾收集器參數總結

GC-parameter-1.png-584.7kB

GC-parameter-2.png-268.5kB


內存分配和回收策略

對象主要分配在新生代的Eden區上,如果啓動了本地線程分配緩衝,將按線程優先在TLAB上分配。 少數情況下也可能會直接分配在老年代中,分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。

對象優先在Eden分配

  • 大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC
  • -XX:+PrintGCDetails : 虛擬機在發生垃圾收集行爲時打印內存回收日誌,並且在進程退出的時候輸出當前的內存各區域分配情況。
  • 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因爲Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
  • 老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)。 Major GC的速度一般會比Minor GC慢10倍以上。
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     */
    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];  // 出現一次Minor GC
    }

    public static void main(String[] args) {
        testAllocation();
    }
}

對垃圾回收的說明:
- 通過-Xms20M、 -Xmx20M、-Xmn10M這3個參數限制了Java堆大小爲20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8:1,從輸出的結果也可以清晰地看到“eden space 8192K、 from space 1024K、 to space 1024K”的信息,新生代總可用空間爲9216KB(Eden區+1個Survivor區的總容量)
- 這裏沒有發生GC,最後的4MB直接在老年代ParOldGen(Parallel Old收集器)中分配;
- 這裏默認使用的是Parallel Scavenge & Parallel Old組合垃圾回收。

darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ javac ch3/MinorGCTest.java 
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 ch3.MinorGCTest
Heap
 PSYoungGen      total 9216K, used 6815K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 83% used [0x00000000ff600000,0x00000000ffca7fd8,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
 Metaspace       used 2495K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K

大對象直接進入老年代

  • 比遇到一個大對象更加壞的消息就是遇到一羣“朝生夕滅”的“短命大對象”,寫程序的時候應當避免
  • -XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存複製。
public class PreTenureSizeThresholdTest {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     * -XX:PretenureSizeThreshold=3145728
     */
    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4 * _1MB];  //直接分配在老年代中
    }

    public static void main(String[] args) {
        testPretenureSizeThreshold();
    }
}

GC日誌分析

  • 這裏同樣默認使用的是Parallel Old & Parallel Scavenge垃圾收集器組合;
  • Parallel Scavenge不認識PretenureSizeThreshold這個參數,所以4MB仍然在新生代中分配;
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ javac ch3/PreTenureSizeThresholdTest.java 
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 ch3.PreTenureSizeThresholdTest
Heap
 PSYoungGen      total 9216K, used 4931K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 60% used [0x00000000ff600000,0x00000000ffad0f18,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 2499K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K

長期存活的對象將進入老年代

既然虛擬機採用了分代收集的思想來管理內存,那麼內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。爲了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設爲1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認爲15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

public class MaxTenuringThresholdTest {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
     * -XX:+PrintTenuringDistribution
     */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold() {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];  // 什麼時候進入老年代決定於XX:MaxTenuringThreshold設置
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }

    public static void main(String[] args) {
        testTenuringThreshold();
    }
}

GC日誌分析

  • 兩次都沒有發生過GC;應該是參數無法識別;
# -XX:MaxTenuringThreshold=1
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ javac ch3/MaxTenuringThresholdTest.java 
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+PrintTenuringDistribution  -XX:MaxTenuringThreshold=1 ch3.MaxTenuringThresholdTest 
Heap
 PSYoungGen      total 9216K, used 5023K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 61% used [0x00000000ff600000,0x00000000ffae7fb0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 2495K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K
# -XX:MaxTenuringThreshold=15
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+PrintTenuringDistribution  -XX:MaxTenuringThreshold=15 ch3.MaxTenuringThresholdTest
Heap
 PSYoungGen      total 9216K, used 5187K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 63% used [0x00000000ff600000,0x00000000ffb10f20,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 2499K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K

動態對象年齡判定

虛擬機並不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

public class TenuringThresholdTest2 {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
     * -XX:+PrintTenuringDistribution
     */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold2() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];   // allocation1+allocation2大於survivo空間一半
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }

    public static void main(String[] args) {
        testTenuringThreshold2();
    }
}
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution ch3.TenuringThresholdTest2
Heap
 PSYoungGen      total 9216K, used 5443K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 66% used [0x00000000ff600000,0x00000000ffb50f18,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 2499K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 268K, capacity 386K, committed 512K, reserved 1048576K

空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次Minor GC,儘管這次MinorGC是有風險的;如果小於,或者HandlePromotionFailure設置不允許(冒險),那這時也要改爲進行一次Full GC。

新生代使用複製收集算法,但爲了內存利用率,只使用其中一個Survivor空間來作爲輪換備份,因此當出現大量對象在Minor GC後仍然存活的情況(最極端的情況就是內存回收後新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代。老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,一共有多少對象會活下來在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作爲經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

如果某次Minor GC存活後的對象突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免Full GC過於頻繁(嘗試進行可能的Minor GC)。

雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。 JDK 6 Update24之後的規則變爲只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC


ref

深入理解Java虛擬機(第二版)

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