1. 程式人生 > >JVM記憶體管理---垃圾收集器

JVM記憶體管理---垃圾收集器

說起垃圾收集(Garbage Collection,GC),大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的歷史遠比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用記憶體動態分配和垃圾收集技術的語言。當List還在胚胎時期時,人們就在思考GC需要完成的3件事情:

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

一、哪些記憶體需要回收?

從JVM區域結構看,可將這些區域劃分為“靜態記憶體”和“動態記憶體”兩類。程式計數器、虛擬機器棧、本地方法3個區域是“靜態”的,因為這幾個區域的記憶體分配和回收都具備確定性,都隨著執行緒而生,隨著執行緒而滅。但Java堆和方法區不一樣,記憶體分配都存在不確定性,只有在程式處於執行期間才能知道會建立哪些物件,這部分記憶體和回收都是動態的,垃圾收集器所關注的是這部分記憶體。

在堆裡面存放著Java世界幾乎所有的物件例項,垃圾回收器在對堆進行回收前,第一件事情就是就是要確定這些物件哪些還”存活”著,哪些已經”死去”。那麼又怎麼確定物件已經”死去”呢?

1.引用計數法:

分配物件時給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是沒有再被使用了。客觀地說,引用計數法(Reference Counting)的實現簡單,判斷效率也很高,但是在主流的Java虛擬機器裡面沒有選用引用計數法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。例如:

public class ReferenceCountingGC {

    public Object instance = null;
    private byte[] bigsize = new byte[2*1024*1024];

    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        System.gc();
    }
}

當設定objA = null;objB = null後這兩個物件再無任何引用,實際上這兩個物件已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為0,於是引用計數演算法無法通知GC收集器回收它們。如果這個物件特別大,則會造成嚴重的記憶體洩露。

2.可達性分析演算法:

可達性分析(Reachability Analysis)的基本思想是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時(也就是GC Roots到這個物件不可達),則證明此物件是不可用的。如下圖所示:

物件Object5、Object6、Object7相互雖然有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的物件。在Java語言中,可作為GC Roots的物件包括下面幾種:

虛擬機器棧(棧幀中的本地變量表)中引用的物件。
方法區中類靜態屬性引用的物件。
方法區中常量引用的物件。
本地方法棧中JNI(即一般說的Native方法)引用的物件。

二、什麼時候回收?

虛擬機器為了分析GC Roots這項工作必須在一個能確保一致性的快照中進行,這裡的“一致性”的意思就是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上——這叫安全點。當然,程式執行時並非在所有地方都能停頓下來開始GC,只有到達安全點時才能暫停。安全點選址也有規定的,選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的。這裡的長時間執行的最明顯特徵是指令列複用,例如方法呼叫、迴圈跳轉、異常跳轉等。

虛擬機器為了能讓所有執行緒都“跑”到安全點上停頓下來,設計了兩個方案:搶先式中斷和主動式中斷。其中搶先式中斷是虛擬機發生GC時,首先把所有執行緒全部中斷,如果發生有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它“跑”到安全點上。這種方式現在比較用了。而主動式中斷是虛擬機器需要GC時僅僅簡單的設定一個標誌,各個執行緒執行到安全點時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。

三、如何回收?

3.1 垃圾收集演算法:

(1)標記-清除(Mark-Sweep)演算法

這是最基礎的演算法,就像它名字一樣,演算法分為“標記”和“清除”兩個階段:首先標記處所有需要回收的物件(如哪些記憶體需要回收所描述的物件),對標記完成後統一回收所有被標記的物件,如下圖所示:

缺點:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除後悔產生大量的不連續的記憶體碎片,可能會導致後續無法分配大物件而導致再一次觸發垃圾收集動作。

(2)複製演算法

為了針對標記-清除演算法的不足,複製演算法將可用記憶體容量劃分為大小相等的兩塊,每次只使用一塊。當一塊的記憶體用完了,就將還存活的物件複製到另一塊上面去。然後把已使用過的記憶體空間一次清理掉,如下圖所示:

缺點:使用記憶體比原來縮小了一半。

現在的商業虛擬機器都採用這種收集演算法來回收新生代,有企業分析的得出其實並不需求將記憶體按1:1的比例劃分,因為新生代中的物件大部分都是“朝生夕死”的。所以,HotSpot虛擬機器預設的Eden和Survivor的大小比例是8:1。一塊Eden和兩塊Survivor,每次使用一塊Eden和一塊Survivor,也就是說只有10%是浪費的。如果另一塊Survivor都無法存放上次垃圾回收的物件時,那這些物件將通過“擔保機制”進入老年代了。

(3)標記-整理(Mark-Compact)演算法
複製演算法一般是對物件存活率較低的一種回收操作,但對於物件存活率較高的記憶體區域(老年代)來說,效果就不是那麼理想了,標記-整理演算法因此誕生了。標記-整理演算法和標記-清除演算法差不多,都是一開始對回收物件進行標記,但後續不是直接對物件清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,如下圖所示:

(4)分代收集演算法

分代收集演算法是目前大部分JVM的垃圾收集器採用的演算法。它的核心思想是根據物件存活的生命週期將記憶體劃分為若干個不同的區域。一般情況下將堆區劃分為老年代(Tenured Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量物件需要被回收,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。

3.2 垃圾收集器:

(1)七種垃圾收集器:

  • Serial(序列GC)-複製
  • ParNew(並行GC)-複製
  • Parallel Scavenge(並行回收GC)-複製
  • Serial Old(MSC)(序列GC)-標記-整理
  • CMS(併發GC)-標記-清除
  • Parallel Old(並行GC)–標記-整理
  • G1(JDK1.7update14才可以正式商用)

說明:

  • 1~3用於年輕代垃圾回收:年輕代的垃圾回收稱為minor GC
  • 4~6用於年老代垃圾回收(當然也可以用於方法區的回收):年老代的垃圾回收稱為full GC
  • G1獨立完成”分代垃圾回收”

注意:並行與併發

  • 並行:多條垃圾回收執行緒同時操作
  • 併發:垃圾回收執行緒與使用者執行緒一起操作

(2)常用五種組合:

  • Serial/Serial Old
  • ParNew/Serial Old:與上邊相比,只是比年輕代多了多執行緒垃圾回收而已
  • ParNew/CMS:當下比較高效的組合
  • Parallel Scavenge/Parallel Old:自動管理的組合
  • G1:最先進的收集器,但是需要JDK1.7update14以上

(2.1)Serial/Serial Old:

特點:

  • 年輕代Serial收集器採用單個GC執行緒實現”複製”演算法(包括掃描、複製)
  • 年老代Serial Old收集器採用單個GC執行緒實現”標記-整理”演算法
  • Serial與Serial Old都會暫停所有使用者執行緒(即STW)

說明:

STW(stop the world):編譯程式碼時為每一個方法注入safepoint(方法中迴圈結束的點、方法執行結束的點),在暫停應用時,需要等待所有的使用者執行緒進入safepoint,之後暫停所有執行緒,然後進行垃圾回收。

適用場合:

  • CPU核數<2,實體記憶體<2G的機器(簡單來講,單CPU,新生代空間較小且對STW時間要求不高的情況下使用)
  • -XX:UseSerialGC:強制使用該GC組合
  • -XX:PrintGCApplicationStoppedTime:檢視STW時間
  • 由於它實現相對簡單,沒有執行緒相關的額外開銷(主要指執行緒切換與同步),因此非常適合運行於客戶端PC的小型應用程式,或者桌面應用程式(比如swing編寫的使用者介面程式),以及我們平時的開發、除錯、測試等。

(2.2)ParNew/Serial Old:

說明:

  • ParNew除了採用多GC執行緒來實現複製演算法以外,其他都與Serial一樣,但是此組合中的Serial Old又是一個單GC執行緒,所以該組合是一個比較尷尬的組合,在單CPU情況下沒有Serial/Serial Old速度快(因為ParNew多執行緒需要切換),在多CPU情況下又沒有之後的三種組合快(因為Serial Old是單GC執行緒),所以使用其實不多。
  • -XX:ParallelGCThreads:指定ParNew GC執行緒的數量,預設與CPU核數相同,該引數在於CMS GC組合時,也可能會用到

(2.3)Parallel Scavenge/Parallel Old:

特點:

  • 年輕代Parallel Scavenge收集器採用多個GC執行緒實現”複製”演算法(包括掃描、複製)
  • 年老代Parallel Old收集器採用多個GC執行緒實現”標記-整理”演算法
  • Parallel Scavenge與Parallel Old都會暫停所有使用者執行緒(即STW)

說明:

  • 吞吐量:CPU執行程式碼時間/(CPU執行程式碼時間+GC時間)
  • CMS主要注重STW的縮短(該時間越短,使用者體驗越好,所以主要用於處理很多的互動任務的情況)
  • Parallel Scavenge/Parallel Old主要注重吞吐量(吞吐量越大,說明CPU利用率越高,所以主要用於處理很多的CPU計算任務而使用者互動任務較少的情況)

引數設定:

  • -XX:+UseParallelOldGC:使用該GC組合
  • -XX:GCTimeRatio:直接設定吞吐量大小,假設設為19,則允許的最大GC時間佔總時間的1/(1 +19),預設值為99,即1/(1+99)
  • -XX:MaxGCPauseMillis:最大GC停頓時間,該引數並非越小越好
  • -XX:+UseAdaptiveSizePolicy:開啟該引數,-Xmn/-XX:SurvivorRatio/-XX:PretenureSizeThreshold這些引數就不起作用了,虛擬機器會自動收集監控資訊,動態調整這些引數以提供最合適的的停頓時間或者最大的吞吐量(GC自適應調節策略),而我們需要設定的就是-Xmx,-XX:+UseParallelOldGC或-XX:GCTimeRatio兩個引數就好(當然-Xms也指定上與-Xmx相同就好)

適用場合:

  • 很多的CPU計算任務而使用者互動任務較少的情況
  • 不想自己去過多的關注GC引數,想讓虛擬機器自己進行調優工作
  • 對吞吐量要求較高,或需要達到一定的量。

(2.4)ParNew/CMS:

說明:

  • 以上只是年老代CMS收集的過程,年輕代ParNew看”2.2、ParNew/Serial Old”就好
  • CMS是多回收執行緒的,不要被上圖誤導,預設的執行緒數:(CPU數量+3)/4
  • CMS主要注重STW的縮短(該時間越短,使用者體驗越好,所以主要用於處理很多的互動任務的情況)
    特點:

  • 年輕代ParNew收集器採用多個GC執行緒實現”複製”演算法(包括掃描、複製)

  • 年老代CMS收集器採用多執行緒實現”標記-清除”演算法

    • 初始標記:標記與根集合節點直接關聯的節點。時間非常短,需要STW
    • 併發標記:遍歷之前標記到的關聯節點,繼續向下標記所有存活節點。時間較長。
    • 重新標記:重新遍歷trace併發期間修改過的引用關係物件。時間介於初始標記與併發標記之間,通常不會很長。需要STW
    • 併發清理:直接清除非存活物件,清理之後,將該執行緒佔用的CPU切換給使用者執行緒
  • 初始標記與重新標記都會暫停所有使用者執行緒(即STW),但是時間較短;併發標記與併發清理時間較長,但是不需要STW

關於併發標記期間怎樣記錄發生變動的引用關係物件,在重新標記期間怎樣掃描這些物件

缺點:

  • 併發標記與併發清理:按照說明的第二點來講,假設有2個CPU,那麼其中有一個CPU會用於垃圾回收,而另一個用於使用者執行緒,這樣的話,之前是兩CPU執行使用者執行緒,現在是一個,那麼效率就會急劇下降。也就是說,降低了吞吐量(即降低了CPU使用率)。
  • 併發清理:在這一過程中,產生的垃圾無法被清理(因為發生在重新標記之後)
  • 併發標記與併發清理:由於是與使用者執行緒併發的,所以使用者執行緒可能會分配物件,這樣既可能物件直接進入年老代(例如,大物件),也可能進入年輕代後,年輕代發生minor GC,這樣的話,實際上要求我們的年老代需要預留一定空間,也就是說要在年老代還有一定空間的情況下就要進行垃圾回收,留出一定記憶體空間來供其他執行緒使用,而不能等到年老代快爆滿了才進行垃圾回收,通過-XX:CMSInitiatingOccupancyFraction來指定當年老代空間滿了多少後進行垃圾回收
  • 標記-清理演算法:會產生記憶體碎片,由於是在老年代,可能會提前觸發Full GC(這正是我們要儘量減少的)
    引數設定:

  • -XX:+UseConcMarkSweepGC:使用該GC組合

  • -XX:CMSInitiatingOccupancyFraction:指定當年老代空間滿了多少後進行垃圾回收
  • -XX:+UseCMSCompactAtFullCollection:(預設是開啟的)在CMS收集器頂不住要進行FullGC時開啟記憶體碎片整理過程,該過程需要STW
  • -XX:CMSFullGCsBeforeCompaction:指定多少次FullGC後才進行整理
  • -XX:ParallelCMSThreads:指定CMS回收執行緒的數量,預設為:(CPU數量+3)/4

適用場合:

  • 用於處理很多的互動任務的情況
  • 方法區的回收一般使用CMS,配置兩個引數:-XX:+CMSPermGenSweepingEnabled與-XX:+CMSClassUnloadingEnabled
  • 適用於一些需要長期執行且對相應時間有一定要求的後臺程式

(2.5)G1

說明:

  • 從上圖來看,G1與CMS相比,僅在最後的”篩選回收”部分不同(CMS是併發清除),實際上G1回收器的整個堆記憶體的劃分都與其他收集器不同。
  • CMS需要配合ParNew,G1可單獨回收整個空間

原理:

  • G1收集器將整個堆劃分為多個大小相等的Region
  • G1跟蹤各個region裡面的垃圾堆積的價值(回收後所獲得的空間大小以及回收所需時間長短的經驗值),在後臺維護一張優先列表,每次根據允許的收集時間,優先回收價值最大的region,這種思路:在指定的時間內,掃描部分最有價值的region(而不是掃描整個堆記憶體),並回收,做到儘可能的在有限的時間內獲取儘可能高的收集效率。

運作流程:

  • 初始標記:標記出所有與根節點直接關聯引用物件。需要STW
  • 併發標記:遍歷之前標記到的關聯節點,繼續向下標記所有存活節點。
    在此期間所有變化引用關係的物件,都會被記錄在Remember Set Logs中
  • 最終標記:標記在併發標記期間,新產生的垃圾。需要STW
  • 篩選回收:根據使用者指定的期望回收時間回收價值較大的物件(看”原理”第二條)。需要STW

優點:

  • 停頓時間可以預測:我們指定時間,在指定時間內只回收部分價值最大的空間,而CMS需要掃描整個年老代,無法預測停頓時間
  • 無記憶體碎片:垃圾回收後會整合空間,CMS採用”標記-清理”演算法,存在記憶體碎片
  • 篩選回收階段:
    • 由於只回收部分region,所以STW時間我們可控,所以不需要與使用者執行緒併發爭搶CPU資源,而CMS併發清理需要佔據一部分的CPU,會降低吞吐量。
    • 由於STW,所以不會產生”浮動垃圾”(即CMS在併發清理階段產生的無法回收的垃圾)

適用範圍:

  • 追求STW短:若ParNew/CMS用的挺好,就用這個;若不符合,用G1
  • 追求吞吐量:用Parallel Scavenge/Parallel Old,而G1在吞吐量方面沒有優勢