1. 程式人生 > >Java虛擬機器記憶體管理(二)--垃圾收集器及記憶體分配策略

Java虛擬機器記憶體管理(二)--垃圾收集器及記憶體分配策略

概述

    Java記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由JIT編譯器進行一些優化,但在本章基於概念模型的討論中,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。

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

物件已死嗎

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

引用計數演算法

    判斷物件是否存活的一種演算法:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器就減1;任何時刻計數器為0的物件就是不可能再被使用的物件。

    但是,在主流的Java虛擬機器中沒有選用引用計數演算法來管理記憶體,最主要的原因是它很難解決物件之間相互迴圈引用的問題。

可達性分析演算法

    在主流的商用程式語言(Java、C#)的主流實現中,都是通過可達性分析(Reachability Analysis)來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的。


    如上圖所示,物件object 5、object 6、object 7雖然互相有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的物件。

    在Java語音中,可作為GC Roots的物件包括下面幾種:

    (1)、虛擬機器棧(棧幀中的本地變量表/區域性變量表)中引用的物件;

    (2)、方法區中類靜態屬性引用的物件;

    (3)、方法區中常量引用的物件;

    (4)、本地方法棧中JNI(即一般說的Native方法)引用的物件。

再談引用

    無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。很多系統的快取功能都能實現這樣的功能:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體空間在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。

    Java將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度逐漸減弱。

    強引用就是在程式程式碼之中普遍存在的,類似“Object obj=new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。

    軟引用是用來描述一些還有用但並非必須的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。Java中提供了SoftReference類來實現軟引用。

    弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。Java中提供了WeakReference類來實現弱引用。

    虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。Java提供了PhantomReference類來實現虛引用。

生存還是死亡

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

/**
*此程式碼演示了兩點:
*1.物件可以在被GC時自我拯救。
*2.這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次
*/
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 mehtod 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 dead:(");
         }
         //下面這段程式碼與上面的完全相同,但是這次自救卻失敗了
         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:(");
        }
    }
}
    執行結果如下:
finalize mehtod executed!
yes,i am still alive:)
no,i am dead:(

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

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

回收方法區

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

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

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

    (1)、該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。

    (2)、載入該類的ClassLoader已經被回收。

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

    虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機器提供了-Xnoclassgc引數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading檢視類載入和解除安裝資訊,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機器中使用,-XX:+TraceClassUnLoading引數需要FastDebug版的虛擬機器支援。

    在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

垃圾收集演算法

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

標記-清除演算法

    最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,如同它的名字一樣,演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。它的標記過程其實在前一節講述物件標記判定時已經介紹過了。之所以說它是最基礎的收集演算法,是因為後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。

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


複製演算法

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


    現在的商業虛擬機器都採用這種收集演算法來回收新生代,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)。

標記-整理演算法

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

    標記-整理演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,“標記-整理”演算法的示意圖如下圖所示。

分代收集演算法

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

垃圾收集器

    如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器都可能會有很大差別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。這裡討論的收集器基於JDK 1.7 Update 14之後的HotSpot虛擬機器(在這個版本中正式提供了商用的G1收集器,之前G1仍處於實驗狀態),這個虛擬機器包含的所有收集器如圖所示。

    圖展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機器所處的區域,則表示它是屬於新生代收集器還是老年代收集器。接下來筆者將逐一介紹這些收集器的特性、基本原理和使用場景,並重點分析CMS和G1這兩款相對複雜的收集器,瞭解它們的部分運作細節。在介紹這些收集器各自的特性之前,我們先來明確一個觀點:雖然我們是在對各個收集器進行比較,但並非為了挑選出一個最好的收集器。因為直到現在為止還沒有最好的收集器出現,更加沒有萬能的收集器,所以我們選擇的只是對具體應用最合適的收集器。這點不需要多加解釋就能證明:如果有一種放之四海皆準、任何場景下都適用的完美收集器存在,那HotSpot虛擬機器就沒必要實現那麼多不同的收集器了。

Serial收集器

    Serial收集器是最基本、發展歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是虛擬機器新生代收集的唯一選擇。這個收集器是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停其他所有的工作執行緒,直到收集結束(“Stop The World”)但這項工作實際上是由虛擬機器在後臺自動發起和自動完成的,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,這對很多使用者來說都是難以接受的。下圖示意了Serial/Serial Old收集器的執行過程。

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

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

ParNew收集器

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

    ParNew收集器除了多執行緒收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關但很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。在JDK 1.5時期,HotSpot推出了一款在強互動應用中幾乎可認為有劃時代意義的垃圾收集器--CMS收集器(Concurrent Mark Sweep),這款收集器是HotSpot虛擬機器中第一款真正意義上的併發(Concurrent)收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。

    ParNew收集器也是使用-XX:+UseConcMarkSweepGC選項後的預設新生代收集器,也可以使用-XX:+UseParNewGC選項來強制指定它。

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

    這裡瞭解兩個概念:

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

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

Parallel Scavenge收集器

    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收集器與ParNew收集器的一個重要區別。

Serial Old收集器

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

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 RootsTracing的過程;而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。

    由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以,從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。通過圖可以比較清楚地看到CMS收集器的運作步驟中併發和需要停頓的時間。


G1收集器

    G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一。G1是一款面向服務端應用的垃圾收集器。與其他GC收集器相比,G1收集器具備如下特點。

    1.並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU來縮短Stop-The-World停頓時間,部分其他收集器原本需要停頓Java執行緒執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。

    2.分代收集:G1收集器依然保留了分代的概念。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新建立的物件和已經存活了一段時間、熬過多次GC的舊物件以獲取更好的收集效果。

    3.空間整合:與CMS的“標記--清理”演算法不同,G1從整體來看是基於“標記--整理”演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現的。但無論如何,這兩種法演算法都意味著G1運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC。

    4.可預測的停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

    在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的記憶體佈局就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留著新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,他們都是一部分Region(不需要連續)的集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。

理解GC日誌

    閱讀GC日誌是處理Java虛擬機器記憶體問題的基礎技能。每一種收集器的日誌形式都是由它們自身的實現所決定的,換而言之,每個收集器的日誌格式都可以不一樣。但虛擬機器設計者為了方便使用者閱讀,將各個收集器的日誌都維持一定的共性,例如以下兩段典型的GC日誌:

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
1 0 0.6 6 7:[F u l l G C[T e n u r e d:0 K->2 1 0 K(1 0 2 4 0 K),0.0 1 4 2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]9 1 4 2 s e c s]4603K->210K(19456K),[Perm:2999K->
    最前面的數字“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時間是完全正常的。

記憶體分配與回收策略

    Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體以及回收分配給物件的記憶體。上面我們基本瞭解了垃圾收集器體系以及運作原理,下面再來看下給物件分配記憶體。

    物件的記憶體分配,往大方向講,就是在堆上分配(但也可能經過JIT編譯後被拆散為標量型別並間接地棧上分配 ),物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配緩衝,將按執行緒優先在TLAB上分配。少數情況下也可能會直接分配在老年代中。分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。

    接下來我們將會講解幾條最普遍的記憶體分配規則,並通過程式碼去驗證這些規則。本節下面的程式碼在測試時使用Client模式虛擬機器執行,沒有手工指定收集器組合,換句話說,驗證的是在使用Serial/Serial Old收集器下(ParNew/Serial Old收集器組合的規則也基本一致)的記憶體分配和回收的策略。

物件優先在Eden分配

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

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

    下面的程式碼清單中的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區的總容量)。

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
}
執行結果如下:
[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.

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

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

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

    新生代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倍以上。

大物件直接進入老年代

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

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

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];//直接分配在老年代中
}
    執行結果如下:
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.
    執行上面的程式碼清單中的testPretenureSizeThreshold()方法後,我們看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40%,也就是4MB的allocation物件直接就分配在老年代中,這是因為PretenureSizeThreshold被設定為3MB(就是3145728,這個引數不能像-Xmx之類的引數一樣直接寫3MB),因此超過3MB的物件都會直接在老年代進行分配。注意PretenureSizeThreshold引數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個引數,Parallel Scavenge收集器一般並不需要設定。如果遇到必須使用此引數的場合,可以考慮ParNew加CMS的收集器組合。

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

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

動態物件年齡判定

    為了能更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

空間分配擔保

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

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

    取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關開啟,避免Full GC過於頻繁。