【JVM】JVM系列之垃圾回收(二)

leesf發表於2016-02-27

一、為什麼需要垃圾回收

  如果不進行垃圾回收,記憶體遲早都會被消耗空,因為我們在不斷的分配記憶體空間而不進行回收。除非記憶體無限大,我們可以任性的分配而不回收,但是事實並非如此。所以,垃圾回收是必須的。

二、哪些記憶體需要進行垃圾回收

  對於虛擬機器中執行緒私有的區域,如程式計數器、虛擬機器棧、本地方法棧都不需要進行垃圾回收,因為它們是自動進行的,隨著執行緒的消亡而消亡,不需要我們去回收,比如棧的棧幀結構,當進入一個方法時,就會產生一個棧幀,棧幀大小也可以藉助類資訊確定,然後棧幀入棧,執行方法體,退出方法時,棧幀出棧,於是其所佔據的記憶體空間也就被自動回收了。而對於虛擬機器中執行緒共享的區域,則需要進行垃圾回收,如堆和方法區,執行緒都會在這兩個區域產生自身的資料,佔據一定的記憶體大小,並且這些資料又可能會存在相互關聯的關係,所以,這部分的區域不像執行緒私有的區域那樣可以簡單自動的進行垃圾回收,此部分割槽域的垃圾回收非常複雜,而垃圾回收也主要是針對這部分割槽域。

三、垃圾收集演算法

  任何垃圾收集演算法都必須做兩件事情。首先,它必須檢測出垃圾物件。其次,它必須回收垃圾物件所使用的堆空間並還給程式。那麼問題來了,如何檢測出一個物件是否為垃圾物件呢?一般有兩種演算法解決這個問題。1. 引用計數演算法 2. 可達性分析演算法。

  1.引用計數演算法

  堆中的每一個物件有一個引用計數,當一個物件被建立,並把指向該物件的引用賦值給一個變數時,引用計數置為1,當再把這個引用賦值給其他變數時,引用計數加1,當一個物件的引用超過了生命週期或者被設定為新值時,物件的引用計數減1,任何引用計數為0的物件都可以被當成垃圾回收。當一個物件被回收時,它所引用的任何物件計數減1,這樣,可能會導致其他物件也被當垃圾回收。

  問題:很難檢測出物件之間的額相互引用(引用迴圈問題)

  如下程式碼段可以從反面驗證虛擬機器的垃圾回收不是採用的引用計數。

package com.leesf.chapter3;

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();    
    }
}
View Code

  程式碼的執行引數設定為: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

   在程式碼objA = null 和 objB = null 之前,記憶體結構示意圖如下

  

  注意:區域性變數區的第一項並沒有this引用,因為testGC方法是類方法。

  在程式碼objA = null 和 objB = null 之後,記憶體結構示意圖如下

  

  objA和objB到堆物件的引用已經沒有了,但是ReferenceCountingGC物件內部還存在著迴圈引用,我們在圖中也可以看到。即便如此,JVM還是把這兩個物件當成垃圾進行了回收。具體的GC日誌如下:

 

  由GC日誌可知發生了兩次GC,由11390K -> 514K,即對兩個物件都進行了回收,也從側面說明JVM的垃圾收集器不是採用的引用計數的演算法來進行垃圾回收的。

  2.可達性分析演算法

  此演算法的基本思想就是選取一系列GCRoots物件作為起點,開始向下遍歷搜尋其他相關的物件,搜尋所走過的路徑成為引用鏈,遍歷完成後,如果一個物件到GCRoots物件沒有任何引用鏈,則證明此物件是不可用的,可以被當做垃圾進行回收。

  那麼問題又來了,如何選取GCRoots物件呢?在Java語言中,可以作為GCRoots的物件包括下面幾種:

    1. 虛擬機器棧(棧幀中的區域性變數區,也叫做區域性變數表)中引用的物件。

    2. 方法區中的類靜態屬性引用的物件。

    3. 方法區中常量引用的物件。

    4. 本地方法棧中JNI(Native方法)引用的物件。

  下面給出一個GCRoots的例子,如下圖,為GCRoots的引用鏈。

  由圖可知,obj8、obj9、obj10都沒有到GCRoots物件的引用鏈,即便obj9和obj10之間有引用鏈,他們還是會被當成垃圾處理,可以進行回收。

四、物件的記憶體佈局

  Java中我們提到最多的應該就是物件,但是我們真的瞭解物件嗎,物件在記憶體中的儲存佈局如何?物件的記憶體佈局如下圖所示

  

  幾點說明:1.Mark Word部分資料的長度在32位和64位虛擬機器(未開啟壓縮指標)中分別為32bit和64bit。然後物件需要儲存的執行時資料其實已經超過了32位、64位Bitmap結構所能記錄的限度,但是物件頭資訊是與物件自身定義的資料無關的外儲存成本,Mark Word一般被設計為非固定的資料結構,以便儲存更多的資料資訊和複用自己的儲存空間。2.型別指標,即指向它的類後設資料的指標,用於判斷物件屬於哪個類的例項。3.例項資料儲存的是真正有效資料,如各種欄位內容,各欄位的分配策略為longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同寬度的欄位總是被分配到一起,便於之後取資料。父類定義的變數會出現在子類前面。3.對齊填充部分僅僅起到佔位符的作用,並非必須。

  說完物件的記憶體佈局,現在來說說物件的引用,當我們在堆上建立一個物件例項後,如何對該物件進行操作呢?好比一個電視機,我如何操作電視機來收看不同的電視節目,顯然我們需要使用到遙控,而虛擬機器中就是使用到引用,即虛擬機器棧中的reference型別資料來操作堆上的物件。現在主流的訪問方式有兩種:

  1. 使用控制程式碼訪問物件。即reference中儲存的是物件控制程式碼的地址,而控制程式碼中包含了物件示例資料與型別資料的具體地址資訊,相當於二級指標。

  2. 直接指標訪問物件。即reference中儲存的就是物件地址,相當於一級指標。

  兩種方式有各自的優缺點。當垃圾回收移動物件時,對於方式一而言,reference中儲存的地址是穩定的地址,不需要修改,僅需要修改物件控制程式碼的地址;而對於方式二,則需要修改reference中儲存的地址。從訪問效率上看,方式二優於方式一,因為方式二隻進行了一次指標定位,節省了時間開銷,而這也是HotSpot採用的實現方式。下圖是控制程式碼訪問與指標訪問的示意圖。

 

 五、物件的引用

  前面所談到的檢測垃圾物件的兩種演算法都是基於物件引用。在Java語言中,將引用分為強引用、軟引用、弱引用、虛引用四種型別。引用強度依次減弱。具體如下圖所示

  

 

  對於可達性分析演算法而言,未到達的物件並非是“非死不可”的,若要宣判一個物件死亡,至少需要經歷兩次標記階段。1. 如果物件在進行可達性分析後發現沒有與GCRoots相連的引用鏈,則該物件被第一次標記並進行一次篩選,篩選條件為是否有必要執行該物件的finalize方法,若物件沒有覆蓋finalize方法或者該finalize方法是否已經被虛擬機器執行過了,則均視作不必要執行該物件的finalize方法,即該物件將會被回收。反之,若物件覆蓋了finalize方法並且該finalize方法並沒有被執行過,那麼,這個物件會被放置在一個叫F-Queue的佇列中,之後會由虛擬機器自動建立的、優先順序低的Finalizer執行緒去執行,而虛擬機器不必要等待該執行緒執行結束,即虛擬機器只負責建立執行緒,其他的事情交給此執行緒去處理。2.對F-Queue中物件進行第二次標記,如果物件在finalize方法中拯救了自己,即關聯上了GCRoots引用鏈,如把this關鍵字賦值給其他變數,那麼在第二次標記的時候該物件將從“即將回收”的集合中移除,如果物件還是沒有拯救自己,那就會被回收。如下程式碼演示了一個物件如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。具體程式碼如下

/*
 * 此程式碼演示了兩點:
 * 1.物件可以再被GC時自我拯救
 * 2.這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次
 * */

public class FinalizeEscapeGC {
    public String name;
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public FinalizeEscapeGC(String name) {
        this.name = name;
    }

    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!");
        System.out.println(this);
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    @Override
    public String toString() {
        return name;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC("leesf");
        System.out.println(SAVE_HOOK);
        // 物件第一次拯救自己
        SAVE_HOOK = null;
        System.out.println(SAVE_HOOK);
        System.gc();
        // 因為finalize方法優先順序很低,所以暫停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead : (");
        }

        // 下面這段程式碼與上面的完全相同,但是這一次自救卻失敗了
        // 一個物件的finalize方法只會被呼叫一次
        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 : (");
        }
    }

}
View Code

  執行結果如下:

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

  由結果可知,該物件拯救了自己一次,第二次沒有拯救成功,因為物件的finalize方法最多被虛擬機器呼叫一次。此外,從結果我們可以得知,一個堆物件的this(放在區域性變數表中的第一項)引用會永遠存在,在方法體內可以將this引用賦值給其他變數,這樣堆中物件就可以被其他變數所引用,即不會被回收。

六、方法區的垃圾回收

  方法區的垃圾回收主要回收兩部分內容:1. 廢棄常量。2. 無用的類。既然進行垃圾回收,就需要判斷哪些是廢棄常量,哪些是無用的類。

  如何判斷廢棄常量呢?以字面量回收為例,如果一個字串“abc”已經進入常量池,但是當前系統沒有任何一個String物件引用了叫做“abc”的字面量,那麼,如果發生垃圾回收並且有必要時,“abc”就會被系統移出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

  如何判斷無用的類呢?需要滿足以下三個條件

    1. 該類的所有例項都已經被回收,即Java堆中不存在該類的任何例項。

    2. 載入該類的ClassLoader已經被回收。

    3. 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

  滿足以上三個條件的類可以進行垃圾回收,但是並不是無用就被回收,虛擬機器提供了一些引數供我們配置。

七、垃圾收集演算法

  垃圾收集的主要演算法有如下幾種:

    1. 標記 - 清除演算法

    2. 複製演算法

    3. 標記 - 整理演算法

    4. 分代收集演算法

  7.1 標記 - 清除演算法

  首先標記出所有需要回收的物件,使用可達性分析演算法判斷一個物件是否為可回收,在標記完成後統一回收所有被標記的物件。下圖是演算法具體的一次執行過程後的結果對比。

  

  說明:1.效率問題,標記和清除兩個階段的效率都不高。2.空間問題,標記清除後會產生大量不連續的記憶體碎片,以後需要給大物件分配記憶體時,會提前觸發一次垃圾回收動作。

  7.2 複製演算法

  將記憶體分為兩等塊,每次使用其中一塊。當這一塊記憶體用完後,就將還存活的物件複製到另外一個塊上面,然後再把已經使用過的記憶體空間一次清理掉。圖是演算法具體的一次執行過程後的結果對比。

  說明:1.無記憶體碎片問題。2.可用記憶體縮小為原來的一半。 3.當存活的物件數量很多時,複製的效率很慢。

  7.3 標記 - 整理演算法

  標記過程還是和標記 - 清除演算法一樣,之後讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體,標記 - 整理演算法示意圖如下

  

  說明:1.無需考慮記憶體碎片問題。

  7.4 分代收集演算法

  把堆分為新生代和老年代,然後根據各年代的特點選擇最合適的回收演算法。在新生代基本上都是朝生暮死的,生存時間很短暫,因此可以採擁標記 - 複製演算法,只需要複製少量的物件就可以完成收集。而老年代中的物件存活率高,也沒有額外的空間進行分配擔保,因此必須使用標記 - 整理或者標記 - 清除演算法進行回收。

八、HotSpot的演算法實現

  對於可達性分析而言,我們知道,首先需要選取GCRoots結點,而GCRoots結點主要在全域性性的引用(如常量或類靜態屬性)與執行上下文(如棧幀中的區域性變數表)中。方法區可以很大,這對於尋找GCRoots結點來說會非常耗時。當選取了GCRoots結點之後,進行可達性分析時必須要保證一致性,即在進行分析的過程中整個執行系統看起來就好像被凍結在某個時間點上,不可以在分析的時候,物件的關係還在動態變化,這樣的話分析的準確性就得不到保證,所以可達性分析是時間非常敏感的。

  為了保證分析結果的準確性,就會導致GC進行時必須停頓所有Java執行執行緒(Stop the world),為了儘可能的減少Stop the world的時間,Java虛擬機器使用了一組稱為OopMap的資料結構,該資料結構用於存放物件引用的地址,這樣,進行可達性分析的時候就可以直接訪問OopMap就可以獲得物件的引用,從而加快分析過程,減少Stop the world時間。

  OopMap資料結構有利於進行GC,是不是虛擬機器無論何時想要進行GC都可以進行GC,即無論虛擬機器在執行什麼指令都可以進行GC?答案是否定的,因為要想讓虛擬機器無論在執行什麼指令的時候都可以進行GC的話,需要為每條指令都生成OopMap,顯然,這樣太浪費空間了。為了節約寶貴的空間,虛擬機器只在”特定的位置“存放了OopMap資料結構,這個特定的位置我們稱之為安全點。程式執行時並非在所有地方都能夠停頓下來開始GC(可達性分析),只有到達安全點的時候才能暫停。安全點可以由方法呼叫、迴圈跳轉、異常跳轉等指令產生,因為這些指令會讓程式長時間執行。

  現在我們已經知道了安全點的概念,即進行GC必須要到達安全點,那麼在發生GC時如何讓所有執行緒到達安全點再暫停呢?有兩種方法1. 搶先式中斷,在發生GC時,首先把所有執行緒全部中斷,如果發現執行緒中斷的地方不在安全點上,就恢復執行緒,讓它跑到安全點上。2. 主動式中斷,在發生GC時,不中斷執行緒,而是設定一個標誌,所有執行緒執行時主動輪詢這個標誌,發生標誌位真就自己中斷掛起,輪詢標誌的地方和安全點是重合的,也有可能是建立物件需要分配記憶體的地方。

  現在問題又來了,當程式不執行的時候,如何讓所有執行緒達到安全點呢?典型的就是執行緒處於Sleep狀態或者Blocked狀態,這時候執行緒是無法跑到安全點再中斷自己的,虛擬機器也肯定不可能等待該執行緒被喚醒並重新分配CPU時間後,跑到安全點再暫停。為了解決這個問題,引安全區域的概念。安全區域是對安全點的擴充套件,可以看成由很多安全點組成,安全區域是指一段程式碼片段之中,引用關係不會發生變化。在這個區域的任何地方開始GC都是安全的。當執行緒執行到安全區域的程式碼時,首先標示自己已經進入了安全區域,那麼,在這段時間裡JVM發起GC時,就不用管標示自己為安全區域狀態的執行緒了。線上程奧離開安全區域時,它要檢查系統是否已經完成了根節點列舉(或者整個GC過程),若完成,執行緒繼續執行;否則,它必須等待直到收到可以安全離開安全區域的訊號。

九、垃圾收集器

  垃圾收集器是記憶體回收的具體實現,HotSpot虛擬機器包含的所有收集器如下:

  

  說明:圖中存在連線表示可以搭配使用,總共有7種不同分代的收集器。

  9.1 Serial收集器

  Serial收集器為單執行緒收集器,在進行垃圾收集時,必須要暫停其他所有的工作執行緒,直到它收集結束。執行過程如下圖所示

  

  說明:1. 需要STW(Stop The World),停頓時間長。2. 簡單高效,對於單個CPU環境而言,Serial收集器由於沒有執行緒互動開銷,可以獲取最高的單執行緒收集效率。

  9.2 ParNew收集器

  ParNew是Serial的多執行緒版本,除了使用多執行緒進行垃圾收集外,其他行為與Serial完全一樣,執行過程如下圖所示

  

  說明:1.Server模式下虛擬機器的首選新生收集器,與CMS進行搭配使用。

  9.3 Parallel Scavenge收集器

  Parallel Scavenge收集器的目標是達到一個可控制的吞吐量,吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間),高吞吐量可以高效率地利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務,並且虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為GC自適應調節策略。

  9.4 Serial Old收集器

  老年代的單執行緒收集器,使用標記 - 整理演算法,執行過程在之前的Serial收集器已經給出。不再累贅。

  9.5 Parallel Old收集器

  老年代的多執行緒收集器,使用標記 - 整理演算法,吞吐量優先,適合於Parallel Scavenge搭配使用,執行過程如下圖所示

  

  9.6 CMS收集器

  CMS(Conrrurent Mark Sweep)收集器是以獲取最短回收停頓時間為目標的收集器。使用標記 - 清除演算法,收集過程分為如下四步:

    1. 初始標記,標記GCRoots能直接關聯到的物件,時間很短。

    2. 併發標記,進行GCRoots Tracing(可達性分析)過程,時間很長。

    3. 重新標記,修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,時間較長。

    4. 併發清除,回收記憶體空間,時間很長。

  其中,併發標記與併發清除兩個階段耗時最長,但是可以與使用者執行緒併發執行。執行過程如下圖所示

  

  說明:1. 對CPU資源非常敏感,可能會導致應用程式變慢,吞吐率下降。2. 無法處理浮動垃圾,因為在併發清理階段使用者執行緒還在執行,自然就會產生新的垃圾,而在此次收集中無法收集他們,只能留到下次收集,這部分垃圾為浮動垃圾,同時,由於使用者執行緒併發執行,所以需要預留一部分老年代空間提供併發收集時程式執行使用。3. 由於採用的標記 - 清除演算法,會產生大量的記憶體碎片,不利於大物件的分配,可能會提前觸發一次Full GC。虛擬機器提供了-XX:+UseCMSCompactAtFullCollection引數來進行碎片的合併整理過程,這樣會使得停頓時間變長,虛擬機器還提供了一個引數配置,-XX:+CMSFullGCsBeforeCompaction,用於設定執行多少次不壓縮的Full GC後,接著來一次帶壓縮的GC。

  9.7 G1收集器

  可以在新生代和老年代中只使用G1收集器。具有如下特點。

    1. 並行和併發。使用多個CPU來縮短Stop The World停頓時間,與使用者執行緒併發執行。

    2. 分代收集。獨立管理整個堆,但是能夠採用不同的方式去處理新建立物件和已經存活了一段時間、熬過多次GC的舊物件,以獲取更好的收集效果。

    3. 空間整合。基於標記 - 整理演算法,無記憶體碎片產生。

    4. 可預測的停頓。能簡歷可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

  使用G1收集器時,Java堆會被劃分為多個大小相等的獨立區域(Region),雖然還保留新生代和老年代的概念,但兩者已經不是物理隔離了,都是一部分Region(不需要連續)的集合。G1收集器中,Region之間的物件引用以及其他收集器的新生代和老年代之間的物件引用,虛擬機器都使用Remembered Set來避免全堆掃描的。每個Region對應一個Remembered Set,虛擬機器發現程式在對Reference型別的資料進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的物件是否處於不同的Region之中(在分代的例子中就是檢查老年代的物件是否引用了新生代的物件),如果是,則通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set之中,當進行記憶體回收時,在GC根節點的列舉範圍中加入Remembered Set即可保證不對全堆掃描也不會遺漏。

  對於上述過程我們可以看如下程式碼加深理解

public class G1 {
    private Object obj;
    
    public init() {
        obj = new Object();
    }
    
    public static void main(String[] args) {
        G1 g1 = new G1();
        g1.init();
    }
}
View Code

  說明:程式中執行init函式的時候,會產生一個Write Barrier暫停中斷寫操作,此時,假定程式中G1物件與Object物件被分配在不同的Region當中,則會把obj的引用資訊記錄在Object所屬的Remembered Set當中。具體的記憶體分佈圖如下

  

  如果不計算維護Remembered Set的操作,G1收集器的運作可以分為如下幾步

    1. 初始併發,標記GCRoots能直接關聯到的物件;修改TAMS(Next Top At Mark Start),使得下一階段程式併發時,能夠在可用的Region中建立新物件,需停頓執行緒,耗時很短。

    2. 併發標記,從GCRoots開始進行可達性分析,與使用者程式併發執行,耗時很長。

    3. 最終標記,修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,變動的記錄將被記錄在Remembered Set Logs中,此階段會把其整合到Remembered Set中,需要停頓執行緒,與使用者程式並行執行,耗時較短。

    4. 篩選回收,對各個Region的回收價值和成本進行排序,根據使用者期望的GC時間進行回收,與使用者程式併發執行,時間使用者可控。

  G1收集器具體的執行示意圖如下

  各個垃圾回收器的介紹就到這裡,有興趣的讀者可以去閱讀原始碼。

  看到這裡,相信有些讀者對之前的GC日誌可能會有些疑惑,下面我們來理解一下GC日誌

  [GC (System.gc()) [PSYoungGen: 6270K->584K(9216K)] 11390K->5712K(19456K), 0.0011969 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 584K->0K(9216K)] [ParOldGen: 5128K->514K(10240K)] 5712K->514K(19456K), [Metaspace: 2560K->2560K(1056768K)], 0.0059342 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
Heap
 PSYoungGen      total 9216K, used 82K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 1% used [0x00000000ff600000,0x00000000ff614920,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 514K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 5% used [0x00000000fec00000,0x00000000fec80928,0x00000000ff600000)
 Metaspace       used 2567K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

  這是之前出現過的GC日誌,可以知道筆者虛擬機器的垃圾收集器的組合為Parallel Scavenge(新生代) + Parallel Old(老年代),是根據PSYoungGen和ParOldGen得知,不同的垃圾回收器的不同組成的新生代和老年代的名字也有所不同。虛擬機器也提供了引數供我們選擇不同的垃圾收集器。

  1. [GC (System.gc())]與[Full GC (System.gc())],說明垃圾收集的停頓型別,不是區分新生代GC和老年代GC的,如果有Full,則表示此次GC發生了Stop The World。

  2. PSYoungGen: 6270K->584K(9216K),表示,新生代:該記憶體區域GC前已使用容量 -> 該記憶體區域GC後已使用容量(該記憶體區域總容量)

  3. 11390K->5712K(19456K),表示,GC前Java堆已使用的容量 -> GC後Java堆已使用的容量(Java堆總容量)

  4. 0.0011969 secs,表示GC所佔用的時間,單位為秒。

  5. [Times: user=0.00 sys=0.00, real=0.00 secs],表示GC的更具體的時間,user代表使用者態消耗的CPU時間,sys代表核心態消耗的CPU時間,real代表操作從開始到結束所經過的牆鍾時間。CPU時間與牆鍾時間的區別是,牆鍾時間包括各種非運算的等待耗時,如等待磁碟IO,等待執行緒阻塞,CPU時間則不包含這些耗時。當系統有多CPU或者多核時,多執行緒操作會疊加這些CPU時間,所以讀者看到user或者sys時間超過real時間也是很正常的。

十、記憶體分配與回收策略

  前面我們已經詳細討論了記憶體回收,但是,我們程式中生成的物件是如何進行分配的呢?物件的記憶體分配,絕大部分都是在堆上分配,少數經過JIT編譯後被拆散為標量型別並間接在棧上分配。在堆上的分配又可以有如下分配,主要在新生代的Eden區分配,如果啟動了本地執行緒分配緩衝,將按照執行緒優先在TLAB上分配,少數直接在老年代分配,虛擬機器也提供了一些引數供我們來控制物件記憶體空間的分配。

  堆的結構圖如下圖所示

  

  下面我們將從應用程式的角度理解物件的分配。

  10.1 物件優先在Eden區分配

  物件通常在新生代的Eden區進行分配,當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC,與Minor GC對應的是Major GC、Full GC。

  Minor GC:指發生在新生代的垃圾收集動作,非常頻繁,速度較快。

  Major GC:指發生在老年代的GC,出現Major GC,經常會伴隨一次Minor GC,同時Minor GC也會引起Major GC,一般在GC日誌中統稱為GC,不頻繁。

  Full GC:指發生在老年代和新生代的GC,速度很慢,需要Stop The World。

  如下程式碼片段展示了GC的過程 

public class AllocationTest {
    private static final int _1MB = 1024 * 1024;
    
    /*
     *     -Xms20M -Xmx20M -Xmn10M 
        -XX:SurvivorRatio=8 
        -XX:+PrintGCDetails
        -XX:+UseSerialGC
     * */
    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];
    }
    public static void main(String[] args) {
        testAllocation();
    }
}
View Code

  執行結果:

  [GC (Allocation Failure) [DefNew: 7130K->515K(9216K), 0.0048317 secs] 7130K->6659K(19456K), 0.0048809 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
 def new generation   total 9216K, used 4694K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,  50% used [0x00000000ff500000, 0x00000000ff580fa0, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
 Metaspace       used 2568K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

  說明:新生代可用的空間為9M = 8M(Eden容量) + 1M(一個survivor容量),分配完allocation1、allocation2、allocation3之後,無法再分配allocation4,會發生分配失敗,則需要進行一次Minor GC,survivor to區域的容量為1M,無法容納總量為6M的三個物件,則會通過擔保機制將allocation1、allocation2、allocation3轉移到老年代,然後再將allocation4分配在Eden區。

  10.2 大物件直接進入老年代

  需要大量連續記憶體空間的Java物件稱為大物件,大物件的出現會導致提前觸發垃圾收集以獲取更大的連續的空間來進行大物件的分配。虛擬機器提供了-XX:PretenureSizeThreadshold引數來設定大物件的閾值,超過閾值的物件直接分配到老年代。

  具體程式碼如下

public class AllocationTest {
    private static final int _1MB = 1024 * 1024;
    
    /*
     *     -Xms20M -Xmx20M -Xmn10M 
        -XX:SurvivorRatio=8 
        -XX:+PrintGCDetails
        -XX:+UseSerialGC
        -XX:PretenureSizeThreshold=3145728(3M)
     * */
    
    public static void testPretenureSizeThreshold() {
        byte[] allocation4 = new byte[5 * _1MB];
    }
    
    public static void main(String[] args) {
        testPretenureSizeThreshold();
    }
}
View Code

  執行結果:

  Heap
 def new generation   total 9216K, used 1314K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  16% used [0x00000000fec00000, 0x00000000fed489d0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 5120K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  50% used [0x00000000ff600000, 0x00000000ffb00010, 0x00000000ffb00200, 0x0000000100000000)
 Metaspace       used 2567K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K
  說明:可以看到5MB的物件直接分配在了老年代。

  10.3 長期存活的物件進入老年代

  每個物件有一個物件年齡計數器,與前面的物件的儲存佈局中的GC分代年齡對應。物件出生在Eden區、經過一次Minor GC後仍然存活,並能夠被Survivor容納,設定年齡為1,物件在Survivor區每次經過一次Minor GC,年齡就加1,當年齡達到一定程度(預設15),就晉升到老年代,虛擬機器提供了-XX:MaxTenuringThreshold來進行設定。

  具體程式碼如下

public class AllocationTest {
    private static final int _1MB = 1024 * 1024;
    
    /*
     *     -Xms20M -Xmx20M -Xmn10M 
        -XX:SurvivorRatio=8 
        -XX:+PrintGCDetails
        -XX:+UseSerialGC
        -XX:MaxTenuringThreshold=1
        -XX:+PrintTenuringDistribution
     * */
    public static void testTenuringThreshold() {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }
    
    public static void main(String[] args) {
        testPretenureSizeThreshold();
    }
}
View Code

  執行結果:

  [GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:     790400 bytes,     790400 total
: 5174K->771K(9216K), 0.0050541 secs] 5174K->4867K(19456K), 0.0051088 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
: 4867K->0K(9216K), 0.0015279 secs] 8963K->4867K(19456K), 0.0016327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4260K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  52% used [0x00000000fec00000, 0x00000000ff0290e0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4867K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  47% used [0x00000000ff600000, 0x00000000ffac0d30, 0x00000000ffac0e00, 0x0000000100000000)
 Metaspace       used 2562K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

  說明:發生了兩次Minor GC,第一次是在給allocation3進行分配的時候會出現一次Minor GC,此時survivor區域不能容納allocation2,但是可以容納allocation1,所以allocation1將會進入survivor區域並且年齡為1,達到了閾值,將在下一次GC時晉升到老年代,而allocation2則會通過擔保機制進入老年代。第二次發生GC是在第二次給allocation3分配空間時,這時,allocation1的年齡加1,晉升到老年代,此次GC也可以清理出原來allocation3佔據的4MB空間,將allocation3分配在Eden區。所以,最後的結果是allocation1、allocation2在老年代,allocation3在Eden區。

  10.4 動態物件年齡判斷

  物件的年齡到達了MaxTenuringThreshold可以進入老年代,同時,如果在survivor區中相同年齡所有物件大小的總和大於survivor區的一半,年齡大於等於該年齡的物件就可以直接進入老年代。無需等到MaxTenuringThreshold中要求的年齡。

  具體程式碼如下:

public class AllocationTest {
    private static final int _1MB = 1024 * 1024;
    
    /*
     *     -Xms20M -Xmx20M -Xmn10M 
        -XX:SurvivorRatio=8 
        -XX:+PrintGCDetails
        -XX:+UseSerialGC
        -XX:MaxTenuringThreshold=15
        -XX:+PrintTenuringDistribution
     * */
    
    public static void testTenuringThreshold2() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];
        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) {
        testPretenureSizeThreshold2();
    }
}
View Code

  執行結果:

  [GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:    1048576 bytes,    1048576 total
: 5758K->1024K(9216K), 0.0049451 secs] 5758K->5123K(19456K), 0.0049968 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
: 5120K->0K(9216K), 0.0016442 secs] 9219K->5123K(19456K), 0.0016746 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4178K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 5123K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  50% used [0x00000000ff600000, 0x00000000ffb00f80, 0x00000000ffb01000, 0x0000000100000000)
 Metaspace       used 2568K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

  結果說明:發生了兩次Minor GC,第一次發生在給allocation4分配記憶體時,此時allocation1、allocation2將會進入survivor區,而allocation3通過擔保機制將會進入老年代。第二次發生在給allocation4分配記憶體時,此時,survivor區的allocation1、allocation2達到了survivor區容量的一半,將會進入老年代,此次GC可以清理出allocation4原來的4MB空間,並將allocation4分配在Eden區。最終,allocation1、allocation2、allocation3在老年代,allocation4在Eden區。

  10.5 空間分配擔保

  在發生Minor GC時,虛擬機器會檢查老年代連續的空閒區域是否大於新生代所有物件的總和,若成立,則說明Minor GC是安全的,否則,虛擬機器需要檢視HandlePromotionFailure的值,看是否執行擔保失敗,若允許,則虛擬機器繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,若大於,將嘗試進行一次Minor GC;若小於或者HandlePromotionFailure設定不執行冒險,那麼此時將改成一次Full GC,以上是JDK Update 24之前的策略,之後的策略改變了,只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。

  冒險是指經過一次Minor GC後有大量物件存活,而新生代的survivor區很小,放不下這些大量存活的物件,所以需要老年代進行分配擔保,把survivor區無法容納的物件直接進入老年代。

  具體的流程圖如下:

                 

 

  具體程式碼如下:

public class AllocationTest {
    private static final int _1MB = 1024 * 1024;
    
    /*
     *     -Xms20M -Xmx20M -Xmn10M 
        -XX:SurvivorRatio=8 
        -XX:+PrintGCDetails
        -XX:+UseSerialGC
        -XX:+HandlePromotionFailure
     * */
    
    public static void testHandlePromotion() {
        byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7,
        allocation8;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation1 = null;
        allocation4 = new byte[2 * _1MB];
        allocation5 = new byte[2 * _1MB];
        allocation6 = new byte[2 * _1MB];
        allocation4 = null;
        allocation5 = null;
        allocation6 = null;
        allocation7 = new byte[2 * _1MB];
    }
    
    public static void main(String[] args) {
        testHandlePromotion();
    }
}
View Code

  執行結果:

  [GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:     528280 bytes,     528280 total
: 7294K->515K(9216K), 0.0040766 secs] 7294K->4611K(19456K), 0.0041309 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
: 6818K->0K(9216K), 0.0012444 secs] 10914K->4611K(19456K), 0.0012760 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 2130K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  26% used [0x00000000fec00000, 0x00000000fee14930, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4611K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  45% used [0x00000000ff600000, 0x00000000ffa80d58, 0x00000000ffa80e00, 0x0000000100000000)
 Metaspace       used 2568K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

  說明:發生了兩次GC,第一次發生在給allocation4分配記憶體空間時,由於老年代的連續可用空間大於存活的物件總和,所以allocation2、allocation3將會進入老年代,allocation1的空間將被回收,allocation4分配在新生代;第二次發生在給allocation7分配記憶體空間時,此次GC將allocation4、allocation5、allocation6所佔的記憶體全部回收。最後,allocation2、allocation3在老年代,allocation7在新生代。

十一、總結

  至此,JVM垃圾收集部分就已經介紹完了,看完這部分我們應該知道JVM是怎樣進行垃圾回收的,並且對JVM的理解更加加深。

  花了很長時間,終於寫完了這一部分,還是收穫很多,在看的同時不斷記錄,更進一步加深了印象,感覺還不錯,謝謝各位園友的觀看~

 

參考連結:http://www.open-open.com/lib/view/open1429883238291.html

參考文獻:深入Java虛擬(原書第2版)、深入理解Java虛擬機器-JVM高階特性與最佳實踐

相關文章