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


一、為什么需要垃圾回收

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

二、哪些內存需要進行垃圾回收

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

三、垃圾收集算法

  任何垃圾收集算法都必須做兩件事情。首先,它必須檢測出垃圾對象。其次,它必須回收垃圾對象所使用的堆空間並還給程序。那么問題來了,如何檢測出一個對象是否為垃圾對象呢?一般有兩種算法解決這個問題。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高級特性與最佳實踐


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM