深入理解JVM虛擬機3:垃圾回收器詳解

轉自 JavaDoop

HotSpot JVM 垃圾回收器

微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。作者黃小斜,專注 Java 相關技術:SSM、SpringBoot、MySQL、分佈式、中間件、集羣、Linux、網絡、多線程,偶爾講點Docker、ELK,同時也分享技術乾貨和學習經驗,致力於Java全棧開發!(關注公衆號後回覆”Java“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送作者原創的Java學習指南、Java程序員面試指南等乾貨資源)

           

關於 JVM 內存管理或者說垃圾收集,大家可能看過很多的文章了,筆者準備給大家總結下。這算是系列的第一篇,接下來一段時間會持續更新。

本文主要是翻譯《Memory Management in the Java HotSpot Virtual Machine》白皮書的前四章內容,這是 2006 的老文章了,當年發佈這篇文章的還是 Sun Microsystems,以後應該會越來越少人記得這家曾經無比偉大的公司了。

雖然這個白皮書有點老了,不過那個時候 Sun 在 J2SE 5.0 版本的 HotSpot 虛擬機上已經有了 Parallel 並行垃圾收集器和 CMS 這種併發收集器了,所以其實內容也沒那麼過時。

其實本文應該有挺多人都翻譯過,我大體上是意譯的,增、刪了部分內容。

其他的知識,包括 Java5 之後的垃圾收集器,如 Java8 的 MetaSpace 取代了永久代、G1 收集器等,將在日後的文章中進行介紹。

目錄

垃圾收集概念

GC 需要做 3 件事情:

  • 分配內存,爲每個新建的對象分配空間

  • 確保還在使用的對象的內存一直還在,不能把有用的空間當垃圾回收了

  • 釋放不再使用的對象所佔用的空間

我們把還被 GC Roots 引用的對象稱爲活的,把不再被引用的對象認爲是死的,也就是我們說的垃圾,GC 的工作就是找到死的對象,回收它們佔用的空間。

在這裏,我們總結一下 GC Roots 有哪些:

  • 當前各線程執行方法中的局部變量(包括形參)引用的對象

  • 已被加載的類的 static 域引用的對象

  • 方法區中常量引用的對象

  • JNI 引用

以上不完全,不過我覺得瞭解到這些就夠了,瞭解更多

我們把 GC 管理的內存稱爲 堆(heap),垃圾收集啓動的時機取決於各個垃圾收集器,通常,垃圾收集發生於整個堆或堆的部分已經被使用光了,或者使用的空間達到了某個百分比閾值。這些後面都會具體說,這裏的每一句話都是對應了某些場景的。

對於內存分配請求,實現的難點在於在堆中找到一塊沒有被使用的確定大小的內存空間。所以,對於大部分垃圾回收算法來說避免內存碎片化是非常重要的,它將使得空間分配更加高效。

垃圾收集器的理想特徵

  1. 安全和全面:活的對象一定不能被清理掉,死的對象一定不能在幾個回收週期結束後還在內存中。

  2. 高效:不能將我們的應用程序掛起太長時間。我們需要在時間、空間、頻次上作出權衡。比如,如果堆內存很小,每次垃圾收集就會很快,但是頻次會增加。如果堆內存很大,很久纔會被填滿,但是每一次回收需要的時間很長。

  3. 儘量少的內存碎片:每次將垃圾對象釋放以後,這些空間可能分佈在各個地方,最糟糕的情況就是,內存中到處都是碎片,在給一個大對象分配空間的時候沒有內存可用,實際上內存是夠的。消除碎片的方式就是壓縮

  4. 可擴展性:在多核多線程應用中,內存分配和垃圾回收都不應該成爲可擴展性的瓶頸。原文提到的這一點,我的理解是:單線程垃圾回收在多核系統中會浪費 CPU 資源,如果我理解錯誤,請指正我。

設計上的權衡

往下看之前,我們需要先分清楚這裏的兩個概念:併發和並行

  • 並行:多個垃圾回收線程同時工作,而不是隻有一個垃圾回收線程在工作

  • 併發:垃圾回收線程和應用程序線程同時工作,應用程序不需要掛起

在設計或選擇垃圾回收算法的時候,我們需要作出以下幾個權衡:

  • 串行 vs 並行

    串行收集的情況,即使是多核 CPU,也只有一個核心參與收集。使用並行收集器的話,垃圾收集的工作將分配給多個線程在不同的 CPU 上同時進行。並行可以讓收集工作更快,缺點是帶來的複雜性和內存碎片問題。

  • 併發 vs Stop-the-world

    當 stop-the-world 垃圾收集器工作的時候,應用將完全被掛起。與之相對的,併發收集器在大部分工作中都是併發進行的,也許會有少量的 stop-the-world。

    stop-the-world 垃圾收集器比並發收集器簡單很多,因爲應用掛起後堆空間不再發生變化,它的缺點是在某些場景下掛起的時間我們是不能接受的(如 web 應用)。

    相應的,併發收集器能夠降低掛起時間,但是也更加複雜,因爲在收集的過程中,也會有新的垃圾產生,同時,需要有額外的空間用於在垃圾收集過程中應用程序的繼續使用。

  • 壓縮 vs 不壓縮 vs 複製

    當垃圾收集器標記出內存中哪些是活的,哪些是垃圾對象後,收集器可以進行壓縮,將所有活的對象移到一起,這樣新的內存分配就可以在剩餘的空間中進行了。經過壓縮後,分配新對象的內存空間是非常簡單快速的。

    相對的,不壓縮的收集器只會就地釋放空間,不會移動存活對象。優點就是快速完成垃圾收集,缺點就是潛在的碎片問題。通常,這種情況下,分配對象空間會比較慢比較複雜,比如爲新的一個大對象找到合適的空間。

    還有一個選擇就是複製收集器,將活的對象複製到另一塊空間中,優點就是原空間被清空了,這樣後續分配對象空間非常迅速,缺點就是需要進行復制操作和佔用額外的空間。


什麼是stop the world

Java中Stop-The-World機制簡稱STW,是在執行垃圾收集算法時,Java應用程序的其他所有線程都被掛起(除了垃圾收集幫助器之外)。Java中一種全局暫停現象,全局停頓,所有Java代碼停止,native代碼可以執行,但不能與JVM交互;這些現象多半是由於gc引起。


GC時的Stop the World(STW)是大家最大的敵人。但可能很多人還不清楚,除了GC,JVM下還會發生停頓現象。

JVM裏有一條特殊的線程--VM Threads,專門用來執行一些特殊的VM Operation,比如分派GC,thread dump等,這些任務,都需要整個Heap,以及所有線程的狀態是靜止的,一致的才能進行。所以JVM引入了安全點(Safe Point)的概念,想辦法在需要進行VM Operation時,通知所有的線程進入一個靜止的安全點。

除了GC,其他觸發安全點的VM Operation包括:

1. JIT相關,比如Code deoptimization, Flushing code cache ;

2. Class redefinition (e.g. javaagent,AOP代碼植入的產生的instrumentation) ;

3. Biased lock revocation 取消偏向鎖 ;

4. Various debug operation (e.g. thread dump or deadlock check);


性能指標

以下幾個是評估垃圾收集器性能的一些指標:

  • 吞吐量:應用程序的執行時間佔總時間的百分比,當然是越高越好

  • 垃圾收集開銷:垃圾收集時間佔總時間的百分比(1 - 吞吐量)

  • 停頓時間:垃圾收集過程中導致的應用程序掛起時間

  • 頻次:相對於應用程序來說,垃圾收集的頻次

  • 空間:垃圾收集佔用的內存

  • 及時性:一個對象從成爲垃圾到該對象空間再次可用的時間

在交互式程序中,通常希望是低延時的,而對於非交互式程序,總運行時間比較重要。實時應用程序既要求每次停頓時間足夠短,也要求總的花費在收集的時間足夠短。在小型個人計算機和嵌入式系統中,則希望佔用更小的空間。

分代收集介紹

當我們使用分代垃圾收集器時,內存將被分爲不同的代(generation),最常見的就是分爲年輕代老年代

在不同的分代中,可以根據不同的特點使用不同的算法。分代垃圾收集基於 weak generational hypothesis 假設(通常國人會翻譯成 弱分代假設):

  • 大部分對象都是短命的,它們在年輕的時候就會死去

  • 極少老年對象對年輕對象的引用

年輕代中的收集是非常頻繁的、高效的、快速的,因爲年輕代空間中,通常都是小對象,同時有非常多的不再被引用的對象。

那些經歷過多次年輕代垃圾收集還存活的對象會晉升到老年代中,老年代的空間更大,而且佔用空間增長比較慢。這樣,老年代的垃圾收集是不頻繁的,但是進行一次垃圾收集需要的時間更長。

對於新生代,需要選擇速度比較快的垃圾回收算法,因爲新生代的垃圾回收是頻繁的。

對於老年代,需要考慮的是空間,因爲老年代佔用了大部分堆內存,而且針對該部分的垃圾回收算法,需要考慮到這個區域的垃圾密度比較低

J2SE 5.0 HotSpot JVM 中的垃圾收集器

J2SE 5.0 HotSpot 虛擬機包含四種垃圾收集器,都是採用分代算法。包括串行收集器並行收集器並行壓縮收集器 和 CMS 垃圾收集器

HotSpot 分代

在 HotSpot 虛擬機中,內存被組織成三個分代:年輕代、老年代、永久代。

大部分對象初始化的時候都是在年輕代中的。

老年代存放經過了幾次年輕代垃圾收集依然還活着的對象,還有部分大對象因爲比較大所以分配的時候直接在老年代分配。

如 -XX:PretenureSizeThreshold=1024,這樣大於 1k 的對象就會直接分配在老年代

永久代,通常也叫 方法區,用於存儲已加載類的元數據,以及存儲運行時常量池等。

垃圾回收類型

當年輕代被填滿後,會進行一次年輕代垃圾收集(也叫做 minor GC)。

下面這兩段我也沒有完全弄明白,弄明白會更新。至少讀者要明白一點,"minor gc 收集年輕代,full gc 收集老年代" 這句話是錯的。

當老年代或永久代被填滿了,會觸發 full GC(也叫做 major GC),full GC 會收集所有區域,先進行年輕代的收集,使用年輕代專用的垃圾回收算法,然後使用老年代的垃圾回收算法回收老年代和永久代。如果算法帶有壓縮,每個代分別獨立地進行壓縮。

如果先進行年輕代垃圾收集,會使得老年代不能容納要晉升上來的對象,這種情況下,不會先進行 young gc,所有的收集器都會(除了 CMS)直接採用老年代收集算法對整個堆進行收集(CMS 收集器比較特殊,因爲它不能收集年輕代的垃圾)。

基於統計,計算出每次年輕代晉升到老年代的平均大小,if (老年代剩餘空間 < 平均大小) 觸發 full gc。

快速分配

如果垃圾收集完成後,存在大片連續的內存可用於分配給新對象,這種情況下分配空間是非常簡單快速的,只要一個簡單的指針碰撞就可以了(bump-the-pointer),每次分配對象空間只要檢測一下是否有足夠的空間,如果有,指針往前移動 N 位就分配好空間了,然後就可以初始化這個對象了。

對於多線程應用,對象分配必須要保證線程安全性,如果使用全局鎖,那麼分配空間將成爲瓶頸並降低程序性能。HotSpot 使用了稱之爲 Thread-Local Allocation Buffers (TLABs) 的技術,該技術能改善多線程空間分配的吞吐量。首先,給予每個線程一部分內存作爲緩存區,每個線程都在自己的緩存區中進行指針碰撞,這樣就不用獲取全局鎖了。只有當一個線程使用完了它的 TLAB,它才需要使用同步來獲取一個新的緩衝區。HotSpot 使用了多項技術來降低 TLAB 對於內存的浪費。比如,TLAB 的平均大小被限制在 Eden 區大小的 1% 之內。TLABs 和使用指針碰撞的線性分配結合,使得內存分配非常簡單高效,只需要大概 10 條機器指令就可以完成。

串行收集器

使用串行收集器,年輕代和老年代都使用單線程進行收集(使用一個 CPU),收集過程中會 stop-the-world。所以當在垃圾收集的時候,應用程序是完全停止的。

在年輕代中使用串行收集器

下圖展示了年輕代中使用串行收集器的流程。

3

年輕代分爲一個 Eden 區和兩個 Survivor 區(From 區和 To 區)。年輕代垃圾收集時,將 Eden 中活着的對象複製到空的 Survivor-To 區,Survivor-From 區的對象分兩類,一類是年輕的,也是複製到 Survivor-To 區,還有一類是老傢伙,晉升到老年代中。

Survivor-From 和 Survivor-To 是我瞎取的名字。。。

如果複製的過程中,發現 Survivor-To 空間滿了,將剩下還沒複製到 Survivor-To 的來自於 Eden 和 Survivor-From 區的對象直接晉升到老年代。

年輕代垃圾收集完成後,Eden 區和 Survivor-From 就乾淨了,此時,將 Survivor-From 和 Survivor-To 交換一下角色。得到下面這個樣子:

4

在老年代中使用串行收集器

如果使用串行收集器,在老年代和永久代將通過使用 標記 -> 清除 -> 壓縮 算法。標記階段,收集器識別出哪些對象是活的;清除階段將遍歷一下老年代和永久代,識別出哪些是垃圾;然後執行壓縮,將活的對象左移到老年代的起始端(永久代類似),這樣就留下了右邊一片連續可用的空間,後續就可以通過指針碰撞的方式快速分配對象空間。

5

何時應該使用串行收集器

串行收集器適用於運行在 client 模式下的大部分程序,它們不要求低延時。在現代硬件條件下,串行收集器可以高效管理 64M 堆內存,並且能將 full GC 控制在半秒內完成。

使用串行收集器

它是 J2SE 5.0 版本 HotSpot 虛擬機在非服務器級別硬件的默認選擇。你也可以使用 -XX:+UseSerialGC 來強制使用串行收集器。

並行收集器

現在大多數 Java 應用都運行在大內存、多核環境中,並行收集器,也就是大家熟知的吞吐量收集器,利用多核的優勢來進行垃圾收集,而不是像串行收集器一樣將程序掛起後只使用單線程來收集垃圾。

在年輕代中使用並行收集器

並行收集器在年輕代中其實就是串行收集器收集算法的並行版本。它仍然使用 stop-the-world 和複製算法,只不過使用了多核的優勢並行執行,降低垃圾收集的時間,從而提高吞吐量。下圖示意了在年輕代中,串行收集器和並行收集器的區別:

6

在老年代中使用並行收集器

在老年代中,並行收集器使用的是和串行收集器一樣的算法:單線程,標記 -> 清除 -> 壓縮

是的,並行收集器只能在年輕代中並行

何時使用並行收集器

其適用於多核、不要求低停頓的應用,因爲老年代的收集雖然不頻繁,但是每次老年代的單線程垃圾收集依然可能會需要很長時間。比如說,它可以應用在批處理、賬單計算、科學計算等。

你應該不會想要這個收集器,而是要一個可以對每個代都採用並行收集的並行壓縮收集器,下一節將介紹這個。

使用並行收集器

前面我們說了,J2SE 5.0 中 client 模式自動選擇使用串行收集器,如果是 server 模式,那麼將自動使用並行收集器。在其他版本中,顯示使用 -XX:+UseParallelGC 可以指定並行收集器。

並行壓縮收集器

並行壓縮收集器於 J2SE 5.0 update 6 引入,和並行收集器的區別在於它在老年代也使用並行收集算法。注意:並行壓縮收集器終將會取代並行收集器。

在年輕代中使用並行壓縮收集器

並行壓縮收集器在年輕代中使用了和並行收集器一樣的算法。即使用 並行、stop-the-world、複製 算法。

在老年代中使用並行壓縮收集器

在老年代和永久代中,其使用 並行、stop-the-world、滑動壓縮 算法。

一次收集分三個階段,首先,將老年代或永久代邏輯上分爲固定大小的區塊。

  1. 標記階段,將 GC Roots 分給多個垃圾收集線程,每個線程並行地去標記存活的對象,一旦標記一個存活對象,在該對象所在的區塊記錄這個對象的大小和對象所在的位置。

  2. 彙總階段,此階段針對區塊進行。由於之前的垃圾回收影響,老年代和永久代的左側是 存活對象密集區,對這部分區域直接進行壓縮的代價是不值得的,能清理出來的空間有限。所以第一件事就是,檢查每個區塊的密度,從左邊第一個開始,直到找到一個區塊滿足:對右側的所有區塊進行壓縮獲得的空間抵得上壓縮它們的成本。這個區塊左邊的區域過於密集,不會有對象移動到這個區域中。然後,計算並保存右側區域中每個區塊被壓縮後的新位置首字節地址。

    右側的區域將被壓縮,對於右側的每個區塊,由於每個區塊中保存了該區塊的存活對象信息,所以很容易計算每個區塊的新位置。注意:彙總階段目前被實現爲串行進行,這個階段修改爲並行也是可行的,不過沒有在標記階段和下面的壓縮階段並行那麼重要。

  3. 壓縮階段,在彙總階段已經完成了每個區塊新位置的計算,所以壓縮階段每個回收線程並行將每個區塊複製到新位置即可。壓縮結束後,就清出來了右側一大片連續可用的空間。

何時使用並行壓縮收集器

首先是多核上的並行優勢,這個就不重複了。其次,前面的並行收集器對於老年代和永久代使用串行,而並行壓縮收集器在這些區域使用並行,能降低停頓時間。

並行壓縮收集器不適合運行在大型共享主機上(如 SunRays),因爲它在收集的時候會獨佔幾個 CPU,在這種機器上,可以考慮減少垃圾收集的線程數(通過 –XX:ParallelGCThreads=n),或者就選擇其他收集器。

使用並行壓縮收集器

顯示指定:-XX:+UseParallelOldGC

Concurrent Mark-Sweep(CMS)收集器

重頭戲 CMS 登場了,至少對於我這個 web 開發者來說,目前 CMS 最常用(使用 JDK8 的應用一般都切換到 G1 收集器了)。前面介紹的都是並行收集,這裏要介紹併發收集了,也就是垃圾回收線程和應用程序線程同時運行。

對於許多程序來說,吞吐量不如響應時間來得重要。通常年輕代的垃圾收集不會停頓多長時間,但是,老年代垃圾回收,雖然不頻繁,但是可能導致長時間的停頓,尤其當堆內存比較大的時候。爲了解決這個問題,HotSpot 虛擬機提供了 CMS 收集器,也叫做 低延時收集器

在年輕代中使用 CMS 收集器

在年輕代中,CMS 和 並行收集器 一樣,即:並行、stop-the-world、複製

在老年代中使用 CMS 收集器

在老年代的垃圾收集過程中,大部分收集任務是和應用程序併發執行的。

CMS 收集過程首先是一段小停頓 stop-the-world,叫做 初始標記階段(initial mark),用於確定 GC Roots。然後是 併發標記階段(concurrent mark),標記 GC Roots 可達的所有存活對象,由於這個階段應用程序同時也在運行,所以併發標記階段結束後,並不能標記出所有的存活對象。爲了解決這個問題,需要再次停頓應用程序,稱爲 再次標記階段(remark),遍歷在併發標記階段應用程序修改的對象(標記出應用程序在這個期間的活對象),由於這次停頓比初始標記要長得多,所以會使用多線程並行執行來增加效率

再次標記階段結束後,能保證所有存活對象都被標記完成,所以接下來的 併發清理階段(concurrent sweep) 將就地回收垃圾對象所佔空間。下圖示意了老年代中 串行、標記 -> 清理 -> 壓縮收集器和 CMS 收集器的區別:

7

由於部分任務增加了收集器的工作,如遍歷併發階段應用程序修改的對象,所以增加了 CMS 收集器的負載。對於大部分試圖降低停頓時間的收集器來說,這是一種權衡方案。

CMS 收集器是唯一不進行壓縮的收集器,在它釋放了垃圾對象佔用的空間後,它不會移動存活對象到一邊去。

8

這將節省垃圾回收的時間,但是由於之後空閒空間不是連續的,所以也就不能使用簡單的 指針碰撞(bump-the-pointer) 進行對象空間分配了。它需要維護一個 空閒列表,將所有的空閒區域連接起來,當分配空間時,需要尋找到一個可以容納該對象的區域。顯然,它比使用簡單的指針碰撞成本要高。同時它也會加大年輕代垃圾收集的負載,因爲年輕代中的對象如果要晉升到老年代中,需要老年代進行空間分配。

另外一個缺點就是,CMS 收集器相比其他收集器需要使用更大的堆內存。因爲在併發標記階段,程序還需要執行,所以需要留足夠的空間給應用程序。另外,雖然收集器能保證在標記階段識別出所有的存活對象,但是由於應用程序併發運行,所以剛剛標記的存活對象很可能立馬成爲垃圾,而且這部分由於已經被標記爲存活對象,所以只能到下次老年代收集纔會被清理,這部分垃圾稱爲 浮動垃圾

最後,由於缺少壓縮環節,堆將會出現碎片化問題。爲了解決這個問題,CMS 收集器需要追蹤統計最常用的對象大小,評估將來的分配需求,可能還需要分割或合併空閒區域。

不像其他垃圾收集器,CMS 收集器不能等到老年代滿了纔開始收集。否則的話,CMS 收集器將退化到使用更加耗時的 stop-the-world、標記-清除-壓縮 算法。爲了避免這個,CMS 收集器需要統計之前每次垃圾收集的時間和老年代空間被消耗的速度。另外,如果老年代空間被消耗了 預設佔用率(initiating occupancy),也將會觸發一次垃圾收集,這個佔用率通過 –XX:CMSInitiatingOccupancyFraction=n 進行設置,n 爲老年代空間的佔用百分比,默認值是 68

這個數字到 Java8 的時候已經變爲默認 92 了。如果老年代空間不足以容納從新生代垃圾回收晉升上來的對象,那麼就會發生 concurrent mode failure,此時會退化到發生 Full GC,清除老年代中的所有無效對象,這個過程是單線程的,比較耗時

另外,即使在晉升的時候判斷出老年代有足夠的空間,但是由於老年代的碎片化問題,其實最終沒法容納晉升上來的對象,那麼此時也會發生 Full GC,這次的耗時將更加嚴重,因爲需要對整個堆進行壓縮,壓縮後年輕代徹底就空了。

總結下來,和並行收集器相比,CMS 收集器降低了老年代收集時的停頓時間(有時是顯著降低),稍微增加了一些年輕代收集的時間降低了吞吐量 以及 需要更多的堆內存

增量模式

CMS 收集器可以使用增量模式,在併發標記階段,週期性地將自己的 CPU 時鐘週期讓出來給應用程序。這個功能適用於需要 CMS 的低延時,但是 CPU 核心只有 1 個或 2 個的情況。

增量模式在 Java8 已經不推薦使用。

目前我瞭解到的是,在所有的併發或並行收集器中,都提供了控制垃圾收集線程數量的參數設置。

何時使用 CMS 收集器

適用於應用程序要求低停頓,同時能接受在垃圾收集階段和垃圾收集線程一起共享 CPU 資源的場景,典型的就是 web 應用了。

在 web 應用中,低延時非常重要,所以 CMS 幾乎就是唯一選擇,直到後來 G1 的出現。

使用 CMS 收集器

顯示指定:-XX:+UseConcMarkSweepGC

如果需要增量模式:–XX:+CMSIncrementalModeoption

當然,CMS 還有好些參數可以設置,這裏就不展開了,想要了解更多 CMS 細節,建議讀者可以參考《Java 性能權威指南》,非常不錯的一本書。

小結

雖然是翻譯的文章,也小結一下吧。

串行收集器:在年輕代和老年代都採用單線程,年輕代中使用 stop-the-world、複製 算法;老年代使用 stop-the-world、標記 -> 清理 -> 壓縮算法。

並行收集器:在年輕代中使用 並行、stop-the-world、複製 算法;老年代使用串行收集器的 串行、stop-the-world、標記 -> 清理 -> 壓縮 算法。

並行壓縮收集器:在年輕代中使用並行收集器的 並行、stop-the-world、複製 算法;老年代使用 並行、stop-the-world、標記 -> 清理 -> 壓縮算法。和並行收集器的區別是老年代使用了並行。

CMS 收集器:在年輕使用並行收集器的 並行、stop-the-world、複製 算法;老年代使用 併發、標記 -> 清理 算法,不壓縮。本文介紹的唯一一個併發收集器,也是唯一一個不對老年代進行壓縮的收集器。

另外,在 HotSpot 中,永久代使用的是和老年代一樣的算法。到了 J2SE 8.0 的 HotSpot JVM 中,永久代被 MetaSpace 取代了,這個以後再介紹。

(全文完)



G1 垃圾收集器介紹

更新時間:2018-05-07

之前根據 Sun 的內存管理白皮書介紹了在 HotSpot JVM 分代算法中的幾個垃圾收集器,本文將介紹 G1 垃圾收集器。

G1 的主要關注點在於達到可控的停頓時間,在這個基礎上儘可能提高吞吐量,這一點非常重要。

G1 被設計用來長期取代 CMS 收集器,和 CMS 相同的地方在於,它們都屬於併發收集器,在大部分的收集階段都不需要掛起應用程序。區別在於,G1 沒有 CMS 的碎片化問題(或者說不那麼嚴重),同時提供了更加可控的停頓時間。

如果你的應用使用了較大的堆(如 6GB 及以上)而且還要求有較低的垃圾收集停頓時間(如 0.5 秒),那麼 G1 是你絕佳的選擇,是時候放棄 CMS 了。

閱讀建議:本文力求用簡單的話介紹清楚 G1 收集器,但是並不會重複介紹每一個細節,所以希望讀者瞭解其他幾個收集器的工作過程,尤其是 CMS 收集器。

G1 總覽

首先是內存劃分上,之前介紹的分代收集器將整個堆分爲年輕代、老年代和永久代,每個代的空間是確定的。

而 G1 將整個堆劃分爲一個個大小相等的小塊(每一塊稱爲一個 region),每一塊的內存是連續的。和分代算法一樣,G1 中每個塊也會充當 Eden、Survivor、Old 三種角色,但是它們不是固定的,這使得內存使用更加地靈活。

1

執行垃圾收集時,和 CMS 一樣,G1 收集線程在標記階段和應用程序線程併發執行,標記結束後,G1 也就知道哪些區塊基本上是垃圾,存活對象極少,G1 會先從這些區塊下手,因爲從這些區塊能很快釋放得到很大的可用空間,這也是爲什麼 G1 被取名爲 Garbage-First 的原因

這裏只不過是先介紹些概念,沒看懂沒關係,往下看

在 G1 中,目標停頓時間非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停頓時間。

G1 使用了停頓預測模型來滿足用戶指定的停頓時間目標,並基於目標來選擇進行垃圾回收的區塊數量。G1 採用增量回收的方式,每次回收一些區塊,而不是整堆回收。

我們要知道 G1 不是一個實時收集器,它會盡力滿足我們的停頓時間要求,但也不是絕對的,它基於之前垃圾收集的數據統計,估計出在用戶指定的停頓時間內能收集多少個區塊。

注意:G1 有和應用程序一起運行的併發階段,也有 stop-the-world 的並行階段。但是,Full GC 的時候還是單線程運行的,所以我們應該儘量避免發生 Full GC,後面我們也會介紹什麼時候會觸發 Full GC。

G1 內存佔用

注:這裏不那麼重要。

G1 比 ParallelOld 和 CMS 會需要更多的內存消耗,那是因爲有部分內存消耗於簿記(accounting)上,如以下兩個數據結構:

  • Remembered Sets:每個區塊都有一個 RSet,用於記錄進入該區塊的對象引用(如區塊 A 中的對象引用了區塊 B,區塊 B 的 Rset 需要記錄這個信息),它用於實現收集過程的並行化以及使得區塊能進行獨立收集。總體上 Remembered Sets 消耗的內存小於 5%。

  • Collection Sets:將要被回收的區塊集合。GC 時,在這些區塊中的對象會被複制到其他區塊中,總體上 Collection Sets 消耗的內存小於 1%。

G1 工作流程

前面囉裏囉嗦說了挺多的,唯一要記住的就是,G1 的設計目標就是盡力滿足我們的目標停頓時間上的要求。

本節介紹 G1 的收集過程,G1 收集器主要包括了以下 4 種操作:

  • 1、年輕代收集

  • 2、併發收集,和應用線程同時執行

  • 3、混合式垃圾收集

  • *、必要時的 Full GC

接下來,我們進行一一介紹。

年輕代收集

首先,我們來看下 G1 的堆結構:

3

年輕代中的垃圾收集流程(Young GC):

4

我們可以看到,年輕代收集概念上和之前介紹的其他分代收集器大差不差的,但是它的年輕代會動態調整。

Old GC / 併發標記週期

接下來是 Old GC 的流程(含 Young GC 階段),其實把 Old GC 理解爲併發週期是比較合理的,不要單純地認爲是清理老年代的區塊,因爲這一步和年輕代收集也是相關的。下面我們介紹主要流程:

  1. 初始標記:stop-the-world,它伴隨着一次普通的 Young GC 發生,然後對 Survivor 區(root region)進行標記,因爲該區可能存在對老年代的引用。

    因爲 Young GC 是需要 stop-the-world 的,所以併發週期直接重用這個階段,雖然會增加 CPU 開銷,但是停頓時間只是增加了一小部分。

  2. 掃描根引用區:掃描 Survivor 到老年代的引用,該階段必須在下一次 Young GC 發生前結束。

    這個階段不能發生年輕代收集,如果中途 Eden 區真的滿了,也要等待這個階段結束才能進行 Young GC。

  3. 併發標記:尋找整個堆的存活對象,該階段可以被 Young GC 中斷。

    這個階段是併發執行的,中間可以發生多次 Young GC,Young GC 會中斷標記過程

  4. 重新標記:stop-the-world,完成最後的存活對象標記。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

    Oracel 的資料顯示,這個階段會回收完全空閒的區塊

  5. 清理:清理階段真正回收的內存很少。

到這裏,G1 的一個併發週期就算結束了,其實就是主要完成了垃圾定位的工作,定位出了哪些分區是垃圾最多的。

混合垃圾回收週期

併發週期結束後是混合垃圾回收週期,不僅進行年輕代垃圾收集,而且回收之前標記出來的老年代的垃圾最多的部分區塊。

混合垃圾回收週期會持續進行,直到幾乎所有的被標記出來的分區(垃圾佔比大的分區)都得到回收,然後恢復到常規的年輕代垃圾收集,最終再次啓動併發週期。

Full GC

到這裏我們已經說了年輕代收集、併發週期、混合回收週期了,大家要熟悉這幾個階段的工作。

下面我們來介紹特殊情況,那就是會導致 Full GC 的情況,也是我們需要極力避免的:

  1. concurrent mode failure:併發模式失敗,CMS 收集器也有同樣的概念。G1 併發標記期間,如果在標記結束前,老年代被填滿,G1 會放棄標記。

    這個時候說明

  • 堆需要增加了,

  • 或者需要調整併發週期,如增加併發標記的線程數量,讓併發標記儘快結束

  • 或者就是更早地進行併發週期,默認是整堆內存的 45% 被佔用就開始進行併發週期。

晉升失敗:併發週期結束後,是混合垃圾回收週期,伴隨着年輕代垃圾收集,進行清理老年代空間,如果這個時候清理的速度小於消耗的速度,導致老年代不夠用,那麼會發生晉升失敗。

說明混合垃圾回收需要更迅速完成垃圾收集,也就是說在混合回收階段,每次年輕代的收集應該處理更多的老年代已標記區塊。

疏散失敗:年輕代垃圾收集的時候,如果 Survivor 和 Old 區沒有足夠的空間容納所有的存活對象。這種情況肯定是非常致命的,因爲基本上已經沒有多少空間可以用了,這個時候會觸發 Full GC 也是很合理的。

最簡單的就是增加堆大小

大對象分配失敗,我們應該儘可能地不創建大對象,尤其是大於一個區塊大小的那種對象。

簡單小結

看完上面的 Young GC 和 Old GC 等,很多讀者可能還是很懵的,這裏說幾句不嚴謹的白話文幫助讀者進行理解:

首先,最好不要把上面的 Old GC 當做是一次 GC 來看,而應該當做併發標記週期來理解,雖然它確實會釋放出一些內存。

併發標記結束後,G1 也就知道了哪些區塊是最適合被回收的,那些完全空閒的區塊會在這這個階段被回收。如果這個階段釋放了足夠的內存出來,其實也就可以認爲結束了一次 GC。

我們假設併發標記結束了,那麼下次 GC 的時候,還是會先回收年輕代,如果從年輕代中得到了足夠的內存,那麼結束;過了幾次後,年輕代垃圾收集不能滿足需要了,那麼就需要利用之前併發標記的結果,選擇一些活躍度最低的老年代區塊進行回收。直到最後,老年代會進入下一個併發週期。

那麼什麼時候會啓動併發標記週期呢?這個是通過參數控制的,下面馬上要介紹這個參數了,此參數默認值是 45,也就是說當堆空間使用了 45% 後,G1 就會進入併發標記週期。

G1 參數配置和最佳實踐

G1 調優的目標是儘量避免出現 Full GC,其實就是給老年代足夠的空間,或相對更多的空間。

有以下幾點我們可以進行調整的方向:

  • 增加堆大小,或調整老年代和年輕代的比例,這個很好理解

  • 增加併發週期的線程數量,其實就是爲了加快併發週期快點結束

  • 讓併發週期儘早開始,這個是通過設置堆使用佔比來調整的(默認 45%)

  • 在混合垃圾回收週期中回收更多的老年代區塊

G1 的很重要的目標是達到可控的停頓時間,所以很多的行爲都以這個目標爲出發點開展的。

我們通過設置 -XX:MaxGCPauseMillis=N 來指定停頓時間(單位 ms,默認 200ms),如果沒有達到這個目標,G1 會通過各種方式來補救:調整年輕代和老年代的比例,調整堆大小,調整晉升的年齡閾值,調整混合垃圾回收週期中處理的老年代的區塊數量等等。

當然了,調整每個參數滿足了一個條件的同時往往也會引入另一個問題,比如爲了降低停頓時間,我們可以減小年輕代的大小,可是這樣的話就會增加年輕代垃圾收集的頻率。如果我們減少混合垃圾回收週期處理的老年代區塊數量,雖然可以更容易滿足停頓時間要求,可是這樣就會增加 Full GC 的風險等等。

下面介紹最常用也是最基礎的一些參數的設置,涉及到更高級的調優參數設置,請讀者自行參閱其他資料。

參數介紹

  • -XX:+UseG1GC

    使用 G1 收集器

  • -XX:MaxGCPauseMillis=200

    指定目標停頓時間,默認值 200 毫秒。

    在設置 -XX:MaxGCPauseMillis 值的時候,不要指定爲平均時間,而應該指定爲滿足 90% 的停頓在這個時間之內。記住,停頓時間目標是我們的目標,不是每次都一定能滿足的。

  • -XX:InitiatingHeapOccupancyPercent=45

    整堆使用達到這個比例後,觸發併發 GC 週期,默認 45%。

    如果要降低晉升失敗的話,通常可以調整這個數值,使得併發週期提前進行

  • -XX:NewRatio=n

    老年代/年輕代,默認值 2,即 1/3 的年輕代,2/3 的老年代

    不要設置年輕代爲固定大小,否則:

    • G1 不再需要滿足我們的停頓時間目標

    • 不能再按需擴容或縮容年輕代大小

  • -XX:SurvivorRatio=n

    Eden/Survivor,默認值 8,這個和其他分代收集器是一樣的

  • -XX:MaxTenuringThreshold =n

    從年輕代晉升到老年代的年齡閾值,也是和其他分代收集器一樣的

  • -XX:ParallelGCThreads=n

    並行收集時候的垃圾收集線程數

  • -XX:ConcGCThreads=n

    併發標記階段的垃圾收集線程數

    增加這個值可以讓併發標記更快完成,如果沒有指定這個值,JVM 會通過以下公式計算得到:

    ConcGCThreads=(ParallelGCThreads + 2) / 4^3

  • -XX:G1ReservePercent=n

    堆內存的預留空間百分比,默認 10,用於降低晉升失敗的風險,即默認地會將 10% 的堆內存預留下來。

  • -XX:G1HeapRegionSize=n

    每一個 region 的大小,默認值爲根據堆大小計算出來,取值 1MB~32MB,這個我們通常指定整堆大小就好了。

小結

我自己仔細檢查了幾遍,主要內容都囊括了,我也不知道讀者看完本文會不會是一臉懵逼。

如果有什麼問題,可以在留言板上給我留言,我是 GC 的門外漢,如果有些問題我覺得自己能解答,我會盡力幫助大家。

最後,在這裏推薦一些資料給感興趣的讀者:

Oracle 官方出品,本文的很多內容是翻譯並解讀這篇文章的:

Getting Started with the G1 Garbage Collector

幫助大家理解 G1 的日誌:

Understanding G1 GC Logs

這裏介紹 GC 的內容非常好:

https://plumbr.io/handbook/garbage-collection-algorithms-implementations#g1

《Java 性能權威指南》:非常好的一本書,建議讀者有時間可以看看這本書中關於 GC 的介紹。

(全文完)


GC監控工具

Java GC監視和分析工具

下面是一些可用的工具,每個都有自己的優勢和缺點。我們可以通過選擇正確的工具並分析,來提升應用程序的性能。這篇教程中,我們選用Java VisualVM。

  • Java VisualVM

  • Naarad

  • GCViewer

  • IBM Pattern Modeling and Analysis Tool for Java Garbage Collector

  • HPjmeter

  • IBM Monitoring and Diagnostic Tools for Java-Garbage Collection and Memory

  • Visualizer

  • Verbose GC Analyzer

Java VisualVM

Java VisualVM使用是免費的,其需要安裝Java SE SDK。看一下Java JDK的bin文件夾中(路徑:\Java\jdk1.8.0\bin),這裏面有很多javac和java工具,jvisualvm就是其中之一。

Java VisualVM能夠被用於:

  •   生成並分析堆的內存轉儲;

  •   在MBeans上觀察並操作;

  •   監視垃圾回收;

  •   內存和CPU性能分析;

1、啓動VisualVM

jvisualvm位於JDK bin文件夾下,直接點擊就可以。

2、安裝可視化GC插件

我們需要安裝可視化GC插件,以便在Java GC過程中有良好的視覺感受。

3、監視GC

現在,是時候監視垃圾回收進程了,開啓你的Java程序,它將自動被檢測到並顯示到Java VisualVM界面,左側“Application”(應用程序)窗口下,“Local”(本地節點)下,所有本地運行的Java程序都會被列出。

Java VisualVM是一個Java應用程序,因此它也會被列在其中,教程的意圖在於使用VisualVM來監視它自己的GC進程。

雙擊“Local”(本地)下的VisualVM圖標。

現在,程序監控窗口在右側打開,這有許多不同關於應用程序性能的相關監視指數的tab頁,目前爲止,我們最感興趣的是“Visual GC”,點擊它。

上面圖片顯示在Old、Eden、S0和S1上空間利用情況,下圖顯示了每部分空間的分配和釋放情況。它按照指定的刷新率保持持續刷新。

上面圖片所展示的是正常運行程序的情況,當出現內存泄露或者反常的行爲時,它會在圖表中明確的顯示出來。最少我們能理解他是與對象內存分配和垃圾回收相關的事情。隨後,通過其他tab頁(像“Threads”)和Thread Dump的幫助,我們能夠減少這個問題。

在“Monitor”tab頁中,我們能夠監控並定時展示所有堆內存使用情況圖。通過“Perform GC”按鈕可以啓動垃圾回收進程。

在“Sampler”tab頁中,我們能夠啓動內存和CPU性能分析,它將顯示詳細每個實例使用的實時報告,它將幫助我們明確性能問題。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章