1. 程式人生 > >jdk原始碼分析(四)——垃圾收集器與記憶體分配策略

jdk原始碼分析(四)——垃圾收集器與記憶體分配策略

本章介紹的垃圾收集器與記憶體分配策略主要就三點。

第一點:垃圾收集(垃圾回收)。問題:哪些記憶體需要回收?什麼時候回收?如何回收?

第二點:介紹垃圾收集器。問題:有幾種型別是垃圾收集器?根據第一點的介紹,屬於那種型別的?

第三點:記憶體分配。問題:怎麼分配的?

一、垃圾收集(垃圾回收)

前面我們介紹了Java記憶體執行時區域的各個部分,

其中程式計數器、虛擬機器棧、本地方法棧三個區域隨執行緒而生,隨執行緒而滅;

棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。

每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由JIT編譯器進行一些優化,但在本章基於概念模型的討論中,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收的問題,因為方法結束或執行緒結束時,記憶體自然就跟隨著回收了。

而Java堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,垃圾收集器所關注的是這部分記憶體,後續討論中的“記憶體”分配與回收也僅指這一部分記憶體。堆記憶體的回收。

1、(堆中)哪些記憶體需要回收?

1.1死掉的物件需要回收!

我們知道java是面向物件開發的。也就是在java的世界裡萬物皆物件。所以當物件死掉了,那麼他也就是一個垃圾了。所以已經死掉的物件所佔的記憶體需要回收!

堆中幾乎存放著Java世界中所有的物件例項,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些物件有哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的物件)。

1.1.1 如何判斷物件是否已經死掉?(引用計數演算法)

很多教科書判斷物件是否存活的演算法是這樣的:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器都為0的物件就是不可能再被使用的。筆者面試過很多的應屆生和一些有多年工作經驗的開發人員,他們對於這個問題給予的都是這個答案。

客觀地說,引用計數演算法(Reference Counting)的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的演算法,也有一些比較著名的應用案例,例如微軟的COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在遊戲指令碼領域中被廣泛應用的Squirrel中都使用了引用計數演算法進行記憶體管理。但是,Java語言中沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間的相互迴圈引用的問題。

舉個簡單的例子,

0、同前面章節、在jvm設定引數上設定jvm,將gc日誌輸出在控制檯上:-XX:+PrintGCDetails

1、編寫測試程式碼

package com.gc; /**  * jvm 設定的引數:-XX:+PrintGCDetails  * @author mch  */ 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;         //假設在這行發生GC,objA和objB是否被回收?         System.gc();     }     public static void main(String[] args) {         testGC();     } }

2、控制檯列印輸出gc日誌

[GC [PSYoungGen: 6082K->656K(57344K)] 6082K->656K(186880K), 0.0018753 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  [Full GC [PSYoungGen: 656K->0K(57344K)] [ParOldGen: 0K->469K(129536K)] 656K->469K(186880K) [PSPermGen: 2451K->2450K(21504K)], 0.0210266 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]  Heap  PSYoungGen      total 57344K, used 2483K [0x00000007c0900000, 0x00000007c4880000, 0x0000000800000000)   eden space 49664K, 5% used [0x00000007c0900000,0x00000007c0b6ce00,0x00000007c3980000)   from space 7680K, 0% used [0x00000007c3980000,0x00000007c3980000,0x00000007c4100000)   to   space 7680K, 0% used [0x00000007c4100000,0x00000007c4100000,0x00000007c4880000)  ParOldGen       total 129536K, used 469K [0x0000000741c00000, 0x0000000749a80000, 0x00000007c0900000)   object space 129536K, 0% used [0x0000000741c00000,0x0000000741c75738,0x0000000749a80000)  PSPermGen       total 21504K, used 2460K [0x000000073ca00000, 0x000000073df00000, 0x0000000741c00000)   object space 21504K, 11% used [0x000000073ca00000,0x000000073cc67100,0x000000073df00000)

從執行結果中可以清楚地看到GC日誌中包含“656K->469K”,意味著虛擬機器並沒有因為這兩個物件互相引用就不回收它們,這也從側面說明虛擬機器並不是通過引用計數演算法來判斷物件是否存活的。下面講解GC日誌的檢視。

請看程式碼清單中的testGC()方法:物件objA和objB都有欄位instance,賦值令objA.instance = objB及objB.instance = objA,除此之外,這兩個物件再無任何引用,實際上這兩個物件已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為0,於是引用計數演算法無法通知GC收集器回收它們。

插曲:GC日誌如何檢視?理解GC日誌

閱讀GC日誌是處理Java虛擬機器記憶體問題的基礎技能,它只是一些人為確定的規則,沒有太多技術含量。在本書的第1版中沒有專門講解如何閱讀分析GC日誌,為此作者收到許多讀者來信,反映對此感到困惑,因此專門增加本節內容來講解如何理解GC日誌。 每一種收集器的日誌形式都是由它們自身的實現所決定的,換而言之,每個收集器的日誌格式都可以不一樣。但虛擬機器設計者為了方便使用者閱讀,將各個收集器的日誌都維持一定的共性,例如以下兩段典型的GC日誌:

33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]   100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 

最前面的數字“33.125:”和“100.667:”代表了GC發生的時間,這個數字的含義是從Java虛擬機器啟動以來經過的秒數。 GC日誌開頭的“[GC”和“[Full GC”說明了這次垃圾收集的停頓型別,而不是用來區分新生代GC還是老年代GC的。如果有“Full”,說明這次GC是發生了Stop-The-World的,例如下面這段新生代收集器ParNew的日誌也會出現“[Full GC”(這一般是因為出現了分配擔保失敗之類的問題,所以才導致STW)。如果是呼叫System.gc()方法所觸發的收集,那麼在這裡將顯示“[Full GC (System)”。

[Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs]

接下來的“[DefNew”、“[Tenured”、“[Perm”表示GC發生的區域,這裡顯示的區域名稱與使用的GC收集器是密切相關的,例如上面樣例所使用的Serial收集器中的新生代名為“Default New Generation”,所以顯示的是“[DefNew”。如果是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所佔用的時間,單位是秒。有的收集器會給出更具體的時間資料,如“[Times: user=0.01 sys=0.00, real=0.02 secs]”,這裡面的user、sys和real與Linux的time命令所輸出的時間含義一致,分別代表使用者態消耗的CPU時間、核心態消耗的CPU事件和操作從開始到結束所經過的牆鍾時間(Wall Clock Time)。CPU時間與牆鍾時間的區別是,牆鍾時間包括各種非運算的等待耗時,例如等待磁碟I/O、等待執行緒阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多執行緒操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。 1.1.2 如何判斷物件是否已經死掉?(可達性分析演算法或者根搜尋演算法)

在主流的商用程式語言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜尋演算法(GC Roots Tracing)判定物件是否存活的。這個演算法的基本思路就是通過一系列的名為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的。如圖3-1所示,物件object 5、object 6、object 7雖然互相有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的物件。

在Java語言中,可作為GC Roots的物件包括下面幾種: 1、虛擬機器棧(棧幀中的本地變量表)中引用的物件。 2、方法區中類靜態屬性引用的物件。 3、方法區中常量引用的物件。 4、本地方法棧中JNI(即一般說的Native方法)引用的物件。

1.1.2 什麼是物件的引用?

無論是通過引用計數演算法判斷物件的引用數量,還是通過根搜尋演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。 在JDK 1.2之前,Java中的引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的物件就顯得無能為力。我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

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

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

1.1.2 如何判斷物件是真的死掉了還是假的死掉了?

在根搜尋演算法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程: 第一次標記過程:如果物件在進行根搜尋後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。 第二次標記過程:如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會被放置在一個名為F-Queue的佇列之中,並在稍後由一條由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束。這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue佇列中的其他物件永久處於等待狀態,甚至導致整個記憶體回收系統崩潰。finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己—只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那它就真的離死不遠了。

從下面的程式碼清單中我們可以看到一個物件的finalize()被執行,但是它仍然可以存活。

package com.gc; /**  * 此程式碼演示了兩點:  * 1、物件可以在被GC時自我拯救  * 2、這種自救的機會只有一次因為一個物件的finalize()方法最多隻會被系統自動呼叫一次  * @author mch  *  */ public class FinalizeEscapeGC {       public static FinalizeEscapeGC SAVE_HOOK = null;     public void isAlive(){         System.out.println("yes, i am still alive ;");     }     @Override     protected void finalize() throws Throwable {         super.finalize();         System.out.println("finalize method executed!");         FinalizeEscapeGC.SAVE_HOOK = this;     }     public static void main(String[] args) throws Throwable{         SAVE_HOOK = new FinalizeEscapeGC();         //物件第一次成功拯救自己         SAVE_HOOK = null;         System.gc();         //因為finalize方法優先順序很低,所以暫停0.5秒等待它         Thread.sleep(500);         if(SAVE_HOOK != null){             SAVE_HOOK.isAlive();         }else{             System.out.println("no , i am dead1111 : ");         }         //下面的這段程式碼與上面的完全相同,但是這次自救卻失敗了         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 dead2222 : ");         }              }      }

控制檯的輸出:

finalize method executed! yes, i am still alive ; no , i am dead2222 : 

從程式碼的執行結果可以看到,SAVE_HOOK物件的finalize()方法確實被GC收集器觸發過,並且在被收集前成功逃脫了。

另外一個值得注意的地方就是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果物件面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段程式碼的自救行動失敗了。

需要特別說明的是,上面關於物件死亡時finalize()方法的描述可能帶有悲情的藝術色彩,筆者並不鼓勵大家使用這種方法來拯救物件。相反,筆者建議大家儘量避免使用它,因為它不是C/C++中的解構函式,而是Java剛誕生時為了使C/C++程式設計師更容易接受它所做出的一個妥協。它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序。有些教材中提到它適合做“關閉外部資源”之類的工作,這完全是對這種方法的用途的一種自我安慰。finalize()能做的所有工作,使用try-finally或其他方式都可以做得更好、更及時,大家完全可以忘掉Java語言中還有這個方法的存在。 經過上面的解釋:大家已經明白了真的死掉了吧。

2、(方法區中)哪些記憶體需要回收?

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

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

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

第一個條件:該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。

第二個條件:載入該類的ClassLoader已經被回收。

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

虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而不是和物件一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機器提供了-Xnoclassgc引數進行控制,還可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading檢視類的載入和解除安裝資訊。 在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

3、什麼時候回收?(垃圾收集器用了什麼演算法?)

由於垃圾收集演算法的實現涉及大量的程式細節,而且各個平臺的虛擬機器操作記憶體的方法又各不相同,因此本節不打算過多地討論演算法的實現,只是介紹幾種演算法的思想及其發展過程。 3.1、標記-清除演算法

最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,如它的名字一樣,演算法分為“標記”和“清除”兩個階段:

首先標記出所有需要回收的物件,在標記完成後統一回收掉所有被標記的物件,它的標記過程其實在前一節講述物件標記判定時已經基本介紹過了。之所以說它是最基礎的收集演算法,是因為後續的收集演算法都是基於這種思路並對其缺點進行改進而得到的。

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

3.2、複製演算法

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

現在的商業虛擬機器都採用這種收集演算法來回收新生代,IBM的專門研究表明,新生代中的物件98%是朝生夕死的,所以並不需要按照1∶1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地拷貝到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor的空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體是會被“浪費”的。當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。

記憶體的分配擔保就好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會預設我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有風險了。記憶體的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠的空間存放上一次新生代收集下來的存活物件,這些物件將直接通過分配擔保機制進入老年代。關於對新生代進行分配擔保的內容,本章稍後在講解垃圾收集器執行規則時還會再詳細講解。

3.3、標記-整理演算法

複製收集演算法在物件存活率較高時就要執行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。 根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,“標記-整理”演算法的示意圖如圖3-4所示。

3.4、分代收集演算法(並不是一種新的思想,只是將java堆分成新生代和老年代,根據各自特點採用不同演算法)

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

4、HotSpot的演算法實現(jvm實現演算法時需要嚴格考量),如何回收?

4.1、列舉根節點

作為GC Roots 的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,現在僅僅方法區就有數百兆,它們的數量龐大,逐個檢查裡面的引用,會消耗很多時間。

另外、準確的可達性分析需要暫停用執行緒的執行。可達性分析對執行時間的敏感體現在GC停頓上,因為分析工作必須在能確保一致性的快照中進行,即不可以出現在分析過程中物件引用關係還在不斷變化,所以這是導致GC進行時必須停頓所有的Java執行執行緒。

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

下面編譯的指令解讀後期在講解。

4.2、安全點

在OoMap的協助下,HotSpot可以快速且準確地完成GC Roots列舉,但有另一個問題,OopMap內容變化的指令非常多,如果為每一個條指令都生成對應的OopMap,將需要大量的額外空間。這樣GC的空間成本將會變的很高。

HotSpot沒有為每條指令都生成OoMap,只是在“特定的位置”記錄了這些資訊,這些位置稱為安全點,即程式執行時並非在所有的地方都能停下來GC,只有達到安全點才能暫停,安全點的選定不能太少以至讓GC等待的時間太長,又不能過於頻繁以至增大執行時的負荷 如何讓GC發生時,所有執行緒(除執行JNI呼叫的執行緒)都到最近的安全點停頓下來? 1.搶先式中斷,不需要執行緒的執行程式碼主動配合,在GC發生時,首先把所有執行緒中斷,如果有執行緒中斷的地方不在安全點,就恢復執行緒,讓它執行到安全點。 2.主動式中斷,需要中斷執行緒時,不直接對執行緒操作,而是設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就中斷掛起。輪詢標誌的地方和安全點是重合的。

4.3、安全區域

安全點機制保證程式執行時,在不太長的時間內就會遇到可進入GC的安全點,但是,程式“不執行”的時候呢,程式不執行就是沒有分配CPU時間,這時執行緒無法響應JVM的中斷請求,JVM顯然不太可能的等待執行緒重新被分配CPU時間。

安全區域是指一段程式碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。

線上程執行到安全區域程式碼時,首先標識自己進入安全區域,當這段時間裡JVM發起GC,不用管標識為安全區域的執行緒了。線上程要離開安全區域時,要檢查系統是否已經完成了根節點列舉,如果完成,執行緒繼續執行,否則等待直到收到可以安全離開安全區域的訊號為止。 二、垃圾收集器

如果說收集演算法是記憶體回收的方法論,垃圾收集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器都可能會有很大的差別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。這裡討論的收集器基於Sun HotSpot虛擬機器1.6版 Update 22,這個虛擬機器包含的所有收集器如圖3-5所示。

圖3-5展示了7種作用於不同分代的收集器(包括JDK 1.6_Update14後引入的Early Access版G1收集器),如果兩個收集器之間存在連線,就說明它們可以搭配使用。 在介紹這些收集器各自的特性之前,我們先來明確一個觀點:雖然我們是在對各個收集器進行比較,但並非為了挑選一個最好的收集器出來。因為直到現在為止還沒有最好的收集器出現,更加沒有萬能的收集器,所以我們選擇的只是對具體應用最合適的收集器。這點不需要多加解釋就能證明:如果有一種放之四海皆準、任何場景下都適用的完美收集器存在,那HotSpot虛擬機器就沒必要實現那麼多不同的收集器了。

第一種收集器:Serial收集器(在GC日誌中新生代的名稱是DefNew)

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

對於“Stop The World”帶給使用者的惡劣體驗,虛擬機器的設計者們表示完全理解,但也表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或房間外待著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完嗎?”這確實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬於一個性質的,但實際上肯定還要比打掃房間複雜得多啊!

從JDK 1.3開始,一直到現在還沒正式釋出的JDK 1.7,HotSpot虛擬機器開發團隊為消除或減少工作執行緒因記憶體回收而導致停頓的努力一直在進行著,從Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)現在還未正式釋出的Garbage First(G1)收集器,我們看到了一個個越來越優秀(也越來越複雜)的收集器的出現,使用者執行緒的停頓時間在不斷縮短,但是仍然沒有辦法完全消除(這裡暫不包括RTSJ中的收集器)。尋找更優秀的垃圾收集器的工作仍在繼續!

寫到這裡,筆者似乎已經把Serial收集器描述成一個老而無用,食之無味棄之可惜的雞肋了,但實際上到現在為止,它依然是虛擬機器執行在Client模式下的預設新生代收集器。它也有著優於其他收集器的地方:簡單而高效(與其他收集器的單執行緒比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。在使用者的桌面應用場景中,分配給虛擬機器管理的記憶體一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的記憶體,桌面應用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內,只要不是頻繁發生,這點停頓是可以接受的。所以,Serial收集器對於執行在Client模式下的虛擬機器來說是一個很好的選擇。

第二種收集器:ParNew收集器(在GC日誌中新生代的名稱是ParNew)

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為包括Serial收集器可用的所有控制引數(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集演算法、Stop The World、物件分配規則、回收策略等都與Serial收集器完全一樣,實現上這兩種收集器也共用了相當多的程式碼。ParNew收集器的工作過程如圖3-7所示。

ParNew收集器除了多執行緒收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關但很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。在JDK 1.5時期,HotSpot推出了一款在強互動應用中幾乎可稱為有劃時代意義的垃圾收集器—CMS收集器(Concurrent Mark Sweep,本節稍後將詳細介紹這款收集器),這款收集器是HotSpot虛擬機器中第一款真正意義上的併發(Concurrent)收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作,用前面那個例子的話來說,就是做到了在你媽媽打掃房間的時候你還能同時往地上扔紙屑。

不幸的是,它作為老年代的收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或Serial收集器中的一個。ParNew收集器也是使用 -XX: +UseConcMarkSweepGC選項後的預設新生代收集器,也可以使用 -XX:+UseParNewGC選項來強制指定它。

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個CPU的環境中都不能百分之百地保證能超越Serial收集器。當然,隨著可以使用的CPU的數量的增加,它對於GC時系統資源的利用還是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多(譬如32個,現在CPU動輒就4核加超執行緒,伺服器超過32個邏輯CPU的情況越來越多了)的環境下,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。

注意 從ParNew收集器開始,後面還將會接觸到幾款併發和並行的收集器。在大家可能產生疑惑之前,有必要先解釋兩個名詞:併發和並行。這兩個名詞都是併發程式設計中的概念,在談論垃圾收集器的上下文語境中,他們可以解釋為: 

並行(Parallel):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態。 

併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行),使用者程式繼續執行,而垃圾收集程式運行於另一個CPU上。

第三種收集器:Parallel Scavenge收集器(在GC日誌中新生代的名稱是PSYongGen)

也就是說:GC日誌中的分割槽名稱是和收集器相關的每個收集器的分割槽名稱的叫法是不一樣的。 Parallel Scavenge收集器也是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的多執行緒收集器……看上去和ParNew都一樣,那它有什麼特別之處呢?

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

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

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

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

GCTimeRatio引數的值應當是一個大於0小於100的整數,也就是垃圾收集時間佔總時間的比率,相當於是吞吐量的倒數。如果把此引數設定為19,那允許的最大GC時間就佔總時間的5%(即1 /(1+19)),預設值為99,就是允許最大1%(即1 /(1+99))的垃圾收集時間。

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

第四種收集器:Serial Old(MSC)收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器的主要意義也是被Client模式下的虛擬機器使用。如果在Server模式下,它主要還有兩大用途:一個是在JDK 1.5及之前的版本中與Parallel Scavenge收集器搭配使用,另外一個就是作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure的時候使用。這兩點都將在後面的內容中詳細講解。Serial Old收集器的工作過程如圖3-8所示。

第五種收集器:Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器是在JDK 1.6中才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處於比較尷尬的狀態。原因是,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外別無選擇(還記得上面說過Parallel Scavenge收集器無法與CMS收集器配合工作嗎?)。由於單執行緒的老年代Serial Old收集器在服務端應用效能上的“拖累”,即便使用了Parallel Scavenge收集器也未必能在整體應用上獲得吞吐量最大化的效果,又因為老年代收集中無法充分利用伺服器多CPU的處理能力,在老年代很大而且硬體比較高階的環境中,這種組合的吞吐量甚至還不一定有ParNew加CMS的組合“給力”。

直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作過程如圖3-9所示。

第六種收集器: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收集器的記憶體回收過程是與使用者執行緒一起併發地執行的。通過圖3-10可以比較清楚地看到CMS收集器的運作步驟中併發和需要停頓的時間。

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

CMS收集器對CPU資源非常敏感。其實,面向併發設計的程式都對CPU資源比較敏感。在併發階段,它雖然不會導致使用者執行緒停頓,但是會因為佔用了一部分執行緒(或者說CPU資源)而導致應用程式變慢,總吞吐量會降低。CMS預設啟動的回收執行緒數是(CPU數量+3)/ 4,也就是當CPU在4個以上時,併發回收時垃圾收集執行緒最多佔用不超過25%的CPU資源。但是當CPU不足4個時(譬如2個),那麼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,這個引數用於設定在執行多少次不壓縮的Full GC後,跟著來一次帶壓縮的。

第七種收集器:G1收集器

G1(Garbage First)收集器是當前收集器技術發展的最前沿成果,在JDK 1.6_Update14中提供了Early Access版本的G1收集器以供試用。在將來JDK 1.7正式釋出的時候,G1收集器很可能會有一個成熟的商用版本隨之釋出。這裡只對G1收集器進行簡單介紹。

G1收集器是垃圾收集器理論進一步發展的產物,它與前面的CMS收集器相比有兩個顯著的改進:一是G1收集器是基於“標記-整理”演算法實現的收集器,也就是說它不會產生空間碎片,這對於長時間執行的應用系統來說非常重要。二是它可以非常精確地控制停頓,既能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

G1收集器可以實現在基本不犧牲吞吐量的前提下完成低停頓的記憶體回收,這是由於它能夠極力地避免全區域的垃圾收集,之前的收集器進行收集的範圍都是整個新生代或老年代,而G1將整個Java堆(包括新生代、老年代)劃分為多個大小固定的獨立區域(Region),並且跟蹤這些區域裡面的垃圾堆積程度,在後臺維護一個優先列表,每次根據允許的收集時間,優先回收垃圾最多的區域(這就是Garbage First名稱的來由)。區域劃分及有優先順序的區域回收,保證了G1收集器在有限的時間內可以獲得最高的收集效率。 總結:收集器

JDK 1.7中的各種垃圾收集器到此已全部介紹完畢,在描述過程中提到了很多虛擬機器非穩定的執行引數,表3-2整理了這些引數以供讀者實踐時參考。

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

Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體以及回收分配給物件的記憶體。關於回收記憶體這一點,我們已經使用了大量的篇幅去介紹虛擬機器中的垃圾收集器體系及其運作原理,現在我們再一起來探討一下給物件分配記憶體的那點事兒。

物件的記憶體分配,往大方向上講,就是在堆上分配(但也可能經過JIT編譯後被拆散為標量型別並間接地在棧上分配),物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配緩衝,將按執行緒優先在TLAB上分配。少數情況下也可能會直接分配在老年代中,分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。 接下來我們將會講解幾條最普遍的記憶體分配規則,並通過程式碼去驗證這些規則。本節中的程式碼在測試時使用Client模式虛擬機器執行,沒有手工指定收集器組合,換句話說,驗證的是使用Serial / Serial Old收集器下(ParNew / Serial Old收集器組合的規則也基本一致)的記憶體分配和回收的策略。讀者不妨根據自己專案中使用的收集器寫一些程式去驗證一下使用其他幾種收集器的記憶體分配策略。

1、怎麼分配的?(物件優先在Eden分配)

大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠的空間進行分配時,虛擬機器將發起一次Minor GC。

虛擬機器提供了-XX:+PrintGCDetails這個收集器日誌引數,告訴虛擬機器在發生垃圾收集行為時列印記憶體回收日誌,並且在程序退出的時候輸出當前記憶體各區域的分配情況。在實際應用中,記憶體回收日誌一般是列印到檔案後通過日誌工具進行分析,不過本實驗的日誌並不多,直接閱讀就能看得很清楚。

程式碼清單3-3的testAllocation()方法中,嘗試分配3個2MB大小和1個4MB大小的物件,在執行時通過-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區的總容量)。

執行testAllocation()中分配allocation4物件的語句時會發生一次Minor GC,這次GC的結果是新生代6651KB變為148KB,而總記憶體佔用量則幾乎沒有減少(因為allocation1、2、3三個物件都是存活的,虛擬機器幾乎沒有找到可回收的物件)。這次GC發生的原因是給allocation4分配記憶體的時候,發現Eden已經被佔用了6MB,剩餘空間已不足以分配allocation4所需的4MB記憶體,因此發生Minor GC。GC期間虛擬機器又發現已有的3個2MB大小的物件全部無法放入Survivor空間(Survivor空間只有1MB大小),所以只好通過分配擔保機制提前轉移到老年代去。

這次GC結束後,4MB的allocation4物件被順利分配在Eden中。因此程式執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,老年代被佔用6MB(被allocation1、2、3佔用)。通過GC日誌可以證實這一點。

注意 作者多次提到的Minor GC和Full GC有什麼不一樣嗎?

新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。

老年代GC(Major GC / Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在ParallelScavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。MajorGC的速度一般會比Minor GC慢10倍以上。

0、在jvm上設定引數: -verbose:gc  -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

1、編寫測試程式碼

package com.gc; /**  * VM 引數: -verbose:gc  -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8  * @author mch  *  */ public class TestGC {       private static final int _1MB= 1024 * 1024;     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();     }      }

3、得到的輸出結果

[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs]  6651K->6292K(19456K), 0.0070426 secs] [Times:  user=0.00 sys=0.00, real=0.00 secs]   Heap   def new generation   total 9216K, used 4326K  [0x029d0000, 0x033d0000, 0x033d0000)    eden space 8192K,  51% used [0x029d0000,  0x02de4828, 0x031d0000)    from space 1024K,  14% used [0x032d0000,  0x032f5370, 0x033d0000)    to   space 1024K,   0% used [0x031d0000,  0x031d0000, 0x032d0000)   tenured generation   total 10240K, used 6144K  [0x033d0000, 0x03dd0000, 0x03dd0000)     the space 10240K,  60% used [0x033d0000,  0x039d0030, 0x039d0200, 0x03dd0000)   compacting perm gen  total 12288K, used 2114K  [0x03dd0000, 0x049d0000, 0x07dd0000)     the space 12288K,  17% used [0x03dd0000,  0x03fe0998, 0x03fe0a00, 0x049d0000)   No shared spaces configured. 

2、怎麼分配的?(大物件直接進入老年代)

所謂大物件就是指,需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串及陣列(筆者例子中的byte[]陣列就是典型的大物件)。大物件對虛擬機器的記憶體分配來說就是一個壞訊息(替Java虛擬機器抱怨一句,比遇到一個大物件更加壞的訊息就是遇到一群“朝生夕滅”的“短命大物件”,寫程式的時候應當避免),經常出現大物件容易導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。

虛擬機器提供了一個-XX:PretenureSizeThreshold引數,令大於這個設定值的物件直接在老年代中分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體拷貝(複習一下:新生代採用複製演算法收集記憶體)。

執行程式碼清單3-4中的testPretenureSizeThreshold()方法後,我們看到Eden空間幾乎沒有被使用,而老年代10MB的空間被使用了40%,也就是4MB的allocation物件直接就分配在老年代中,這是因為PretenureSizeThreshold被設定為3MB(就是3145728B,這個引數不能與-Xmx之類的引數一樣直接寫3MB),因此超過3MB的物件都會直接在老年代中進行分配。

注意 PretenureSizeThreshold引數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個引數,Parallel Scavenge收集器一般並不需要設定。如果遇到必須使用此引數的場合,可以考慮ParNew加CMS的收集器組合。 0、設定jvm引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8  -XX:PretenureSizeThreshold=3145728

1、編寫測試程式碼

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

2、執行結果

Heap   def new generation   total 9216K, used 671K [0x029d0000, 0x033d0000, 0x033d0000)    eden space 8192K,   8% used [0x029d0000,  0x02a77e98, 0x031d0000)    from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)    to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)   tenured generation   total 10240K, used 4096K  [0x033d0000, 0x03dd0000, 0x03dd0000)     the space 10240K,  40% used [0x033d0000,  0x037d0010, 0x037d0200, 0x03dd0000)   compacting perm gen  total 12288K, used 2107K  [0x03dd0000, 0x049d0000, 0x07dd0000)     the space 12288K,  17% used [0x03dd0000,  0x03fdefd0, 0x03fdf000, 0x049d0000)   No shared spaces configured.

3、怎麼分配的?(長期存活的物件將進入老年代)

虛擬機器既然採用了分代收集的思想來管理記憶體,那記憶體回收時就必須能識別哪些物件應當放在新生代,哪些物件應放在老年代中。為了做到這點,虛擬機器給每個物件定義了一個物件年齡(Age)計數器。如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並將物件年齡設為1。物件在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲)時,就會被晉升到老年代中。物件晉升老年代的年齡閾值,可以通過引數-XX:MaxTenuringThreshold來設定。 讀者可以試試分別以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15兩種設定來執行下面的程式碼清單中的testTenuringThreshold()方法,此方法中allocation1物件需要256KB的記憶體空間,Survivor空間可以容納。當MaxTenuringThreshold=1時,allocation1物件在第二次GC發生時進入老年代,新生代已使用的記憶體GC後會非常乾淨地變成0KB。而MaxTenuringThreshold=15時,第二次GC發生後,allocation1物件則還留在新生代Survivor空間,這時候新生代仍然有404KB的空間被佔用。 0、設定jvm引數

1、編寫測試程式碼

private static final int _1MB = 1024 * 1024;     /**   * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M  -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];   }

2、執行結果

[GC [DefNew   Desired Survivor size 524288 bytes, new threshold 1 (max 1)