1. 程式人生 > >JVM之垃圾收集器

JVM之垃圾收集器

Java 語言的一大特點就是可以進行自動垃圾回收處理,而無需開發人員過於關注系統資源,例如記憶體資源的釋放情況。自動垃圾收集雖然大大減輕了開發人員的工作量,但是也增加了軟體系統的負擔。擁有垃圾收集器可以說是 Java 語言與 C++語言的一項顯著區別。在 C++語言中,程式設計師必須小心謹慎地處理每一項記憶體分配,且記憶體使用完後必須手工釋放曾經佔用的記憶體空間。當記憶體釋放不夠完全時,即存在分配但永不釋放的記憶體塊,就會引起記憶體洩漏,嚴重時甚至導致程式癱瘓。

  在上一篇博文中,我大概介紹了JVM的記憶體模型,其中程式計數器,虛擬機器棧,本地方法棧3個區域隨執行緒而生滅,當方法結束或者執行緒結束時,記憶體自然就回收了,所以這3個區域就不需要過多考慮回收的問題。

如何判斷物件已死?

  在堆裡面存放著Java世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些物件之中哪些還活著或者死亡。有下面2種方法判斷:

  • 引用計數法   引用計數器的實現很簡單,對於一個物件 A,只要有任何一個物件引用了 A,則 A 的引用計數器就加 1,當引用失效時,引用計數器就減 1。只要物件 A 的引用計數器的值為 0,則物件 A 就不可能再被使用。引用計數器的實現也非常簡單,只需要為每個物件配置一個整形的計數器即可。   但是引用計數器有一個嚴重的問題,即無法處理迴圈引用的情況。因此,在 Java 的垃圾回收器中沒有使用這種演算法。一個簡單的迴圈引用問題描述如下:有物件 A 和物件 B,物件 A 中含有物件 B 的引用,物件 B 中含有物件 A 的引用。此時,物件 A 和物件 B 的引用計數器都不為 0。但是在系統中卻不存在任何第 3 個物件引用了 A 或 B。也就是說,A 和 B 是應該被回收的垃圾物件,但由於垃圾物件間相互引用,從而使垃圾回收器無法識別,引起記憶體洩漏。例如,如下程式碼就不會GC:
public class Test {

    public Object instance = null;

    public static void main(String[] args) {
        Test object1 = new Test();
        Test object2 = new Test();

        object1.instance = object2;
        object2.instance = object1;

        object1 = null;
        object2 = null;
        System.
gc(); } }
  • 可達性分析(根搜尋法)   為了解決上面的迴圈引用問題,Java採用了一種新的演算法:可達性分析演算法。 從GC Roots(每種具體實現對GC Roots有不同的定義)作為起點,向下搜尋它們引用的物件,可以生成一棵引用樹,樹的節點視為可達物件,反之視為不可達。 可達性分析演算法 在Java語言中,可作為GCRoots的物件包括下面幾種:
  • 虛擬機器棧(棧幀中的本地變量表)中引用的物件。
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧中JNI(即一般說的Native方法)引用的物件

回收方法區

  很多人認為方法區(或者HotSpot虛擬機器中的永久代)是沒有垃圾回收的,Java虛擬機器規範中確實說過可以不要求虛擬機器在方法區實現垃圾收集,而且在方法區中進行垃圾收集的價效比一般比較低。   永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類

由於 PermGen 記憶體經常會溢位,所以在JDK1.8中,PermGen 最終被移除,方法區移至 Metaspace,字串常量移至 Java Heap。JDK 8 開始把類的元資料放到本地堆記憶體(native heap)中,這一塊區域就叫 Metaspace,中文名叫元空間。預設的類的元資料分配只受本地記憶體大小的限制

垃圾收集演算法

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

  標記-清除演算法將垃圾回收分為兩個階段:標記階段和清除階段。一種可行的實現是,在標記階段首先通過根節點,標記所有從根節點開始的較大物件。因此,未被標記的物件就是未被引用的垃圾物件。然後,在清除階段,清除所有未被標記的物件。該演算法最大的問題是存在大量的空間碎片,因為回收後的空間是不連續的。在物件的堆空間分配過程中,尤其是大物件的記憶體分配,不連續的記憶體空間的工作效率要低於連續的空間。

     複製演算法 (Copying)

  將現有的記憶體空間分為兩快,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後,清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,完成垃圾回收。   如果系統中的垃圾物件很多,複製演算法需要複製的存活物件數量並不會太大。因此在真正需要垃圾回收的時刻,複製演算法的效率是很高的。又由於物件在垃圾回收過程中統一被複制到新的記憶體空間中,因此,可確保回收後的記憶體空間是沒有碎片的。該演算法的缺點是將系統內存摺半。   Java 的新生代序列垃圾回收器中使用了複製演算法的思想。例如堆中的新生代分為 eden 空間、from 空間、to 空間 3 個部分。其中 from 空間和 to 空間可以視為用於複製的兩塊大小相同、地位相等,且可進行角色互換的空間塊。from 和 to 空間也稱為 survivor 空間,即倖存者空間,用於存放未被回收的物件。 Java堆 在垃圾回收時,eden 空間中的存活物件會被複制到未使用的 survivor 空間中 (假設是 to),正在使用的 survivor 空間 (假設是 from) 中的年輕物件也會被複制到 to 空間中 (大物件,或者老年物件會直接進入老年帶,如果 to 空間已滿,則物件也會直接進入老年代)。此時,eden 空間和 from 空間中的剩餘物件就是垃圾物件,可以直接清空,to 空間則存放此次回收後的存活物件。這種改進的複製演算法既保證了空間的連續性,又避免了大量的記憶體空間浪費。 Java應用不斷建立物件,通常是分配在Eden區域,當其空間佔用達到一定閾值時,觸發minor GC。仍然被引用的物件(存活物件,綠色)

     標記-壓縮演算法 (Mark-Compact)

  複製演算法的高效性是建立在存活物件少、垃圾物件多的前提下的。這種情況在年輕代經常發生,但是在老年代更常見的情況是大部分物件都是存活物件。如果依然使用複製演算法,由於存活的物件較多,複製的成本也將很高。   標記-壓縮演算法是一種老年代的回收演算法,它在標記-清除演算法的基礎上做了一些優化。也首先需要從根節點開始對所有可達物件做一次標記,但之後,它並不簡單地清理未標記的物件,而是將所有的存活物件壓縮到記憶體的一端。之後,清理邊界外所有的空間。這種方法既避免了碎片的產生,又不需要兩塊相同的記憶體空間,因此,其價效比比較高。

     分代收集演算法 (Generational Collecting)

  根據垃圾回收物件的特性,不同階段最優的方式是使用合適的演算法用於本階段的垃圾回收,分代演算法即是基於這種思想,它將記憶體區間根據物件的特點分成幾塊,根據每塊記憶體區間的特點,使用不同的回收演算法,以提高垃圾回收的效率。以 Hot Spot 虛擬機器為例,它將所有的新建物件都放入稱為年輕代的記憶體區域,年輕代的特點是物件會很快回收,因此,在年輕代就選擇效率較高的複製演算法。當一個物件經過幾次回收後依然存活,物件就會被放入稱為老生代的記憶體空間。在老生代中,幾乎所有的物件都是經過幾次垃圾回收後依然得以倖存的。因此,可以認為這些物件在一段時期內,甚至在應用程式的整個生命週期中,將是常駐記憶體的。所以根據分代的思想,可以對老年代的回收使用與新生代不同的標記-壓縮演算法,以提高垃圾回收效率。

垃圾收集器

  上面講述了4種垃圾收集演算法,但這僅僅是理論,那麼垃圾收集器就是對這些理論的具體實現。Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任務規定,因此不同的廠商,不同版本的虛擬機器所提供的垃圾收集器都可能會有很大差別,並且一般都會提供引數供使用者自己的應用特點和要求組合出各個年代(區域)所使用的收集器。   從不同角度分析垃圾收集器,可以將其分為不同的型別。

  • 按執行緒數分,可以分為序列垃圾回收器和並行垃圾回收器。序列垃圾回收器一次只使用一個執行緒進行垃圾回收;並行垃圾回收器一次將開啟多個執行緒同時進行垃圾回收。在並行能力較強的 CPU 上,使用並行垃圾回收器可以縮短 GC 的停頓時間。
  • 按照工作模式分,可以分為併發式垃圾回收器和獨佔式垃圾回收器。併發式垃圾回收器與應用程式執行緒交替工作,以儘可能減少應用程式的停頓時間;獨佔式垃圾回收器 (Stop the world) 一旦執行,就停止應用程式中的其他所有執行緒,直到垃圾回收過程完全結束。
  • 按碎片處理方式可分為壓縮式垃圾回收器和非壓縮式垃圾回收器。壓縮式垃圾回收器會在回收完成後,對存活物件進行壓縮整理,消除回收後的碎片;非壓縮式的垃圾回收器不進行這步操作。
  • 按工作的記憶體區間,又可分為新生代垃圾回收器和老年代垃圾回收器。 HotSpot虛擬機器垃圾收集器

     Serial收集器

       有兩個特點:第一,它僅僅使用單執行緒進行垃圾回收;第二,它獨佔式的垃圾回收。使用-XX:+UseSerialGC引數指定使用序列回收器,Jvm執行在Client模式下的預設值,使用Serial + Serial Old的收集器組合進行記憶體回收。 Serial收集器 Serial進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。

     Serial Old收集器

       它是Serial收集器的老年代版本,和新生代序列收集器一樣,它也是一個序列的、獨佔式的垃圾回收器。它可以作為CMS收集器的後被預案,在併發收集發生 Concurrent Model Failure時使用。        使用以下引數:-XX:+UseSerialGC: 新生代、老年代都使用序列回收器。

     ParNew收集器

       它僅僅是Serial收集器的多執行緒版本。並行回收器也是獨佔式的回收器,在收集過程中,應用程式會全部暫停。但由於並行回收器使用多執行緒進行垃圾回收,因此,在併發能力比較強的 CPU 上,它產生的停頓時間要短於序列回收器,而在單 CPU 或者併發能力較弱的系統中,並行回收器的效果不會比序列回收器好,由於多執行緒的壓力,它的實際表現很可能比序列回收器差。        開啟並行回收器可以使用引數-XX:+UseParNewGC,該引數設定新生代使用並行收集器,老年代也使用序列收集器。 ParNew/SerialOld收集器   設定引數-XX:+UseConcMarkSweepGC 可以要求新生代使用並行收集器,老年代使用 CMS。   並行收集器工作時的執行緒數量可以使用-XX:ParallelGCThreads 引數指定。一般,最好與 CPU 數量相當,避免過多的執行緒數影響垃圾收集效能。在預設情況下,當 CPU 數量小於 8 個,ParallelGCThreads 的值等於 CPU 數量,大於 8 個,ParallelGCThreads 的值等於 3+[5*CPU_Count]/8]。以下測試顯示了筆者筆記本上執行 8 個執行緒時耗時最短,本人筆記本是 8 核 IntelCPU。

     Parallel Scavenge收集器

      該收集器是一個新生代收集器,使用複製演算法。從表面上看,它和並行收集器一樣都是多執行緒、獨佔式的收集器。但是,並行回收收集器有一個重要的特點:它非常關注系統的吞吐量。       -XX:+UseParallelGC:新生代使用並行回收收集器,老年代使用序列收集器。

  所謂的吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即 吞吐量 = 執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間),例如,虛擬機器總共運行了100分鐘,其中垃圾收集花掉了1分鐘,那麼執行使用者程式碼時間就花費了99分鐘,所以,吞吐量就是99%   在其啟動時,可以指定如下引數:     -XX:+MaxGCPauseMills:設定最大垃圾收集停頓時間,它的值是一個大於 0 的整數。收集器在工作時會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。如果希望減少停頓時間,而把這個值設定得很小,為了達到預期的停頓時間,JVM 可能會使用一個較小的堆 (一個小堆比一個大堆回收快),而這將導致垃圾回收變得很頻繁,從而增加了垃圾回收總時間,降低了吞吐量。     -XX:+GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。比如 GCTimeRatio 等於 19,則系統用於垃圾收集的時間不超過 1/(1+19)=5%。預設情況下,它的取值是 99,即不超過 1%的時間用於垃圾收集。     除此之外,並行回收收集器與並行收集器另一個不同之處在於,它支援一種自適應的 GC 調節策略,使用-XX:+UseAdaptiveSizePolicy 可以開啟自適應 GC 策略。在這種模式下,新生代的大小、eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的吞吐量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機器自己完成調優工作。

     Parallel Old

       它是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。        -XX:+UseParallelOldGC:新生代和老年代都是用並行回收收集器。        引數-XX:ParallelGCThreads 也可以用於設定垃圾回收時的執行緒數量。

     CMS收集器

  與並行回收收集器不同,CMS 收集器主要關注於系統停頓時間。CMS 是 Concurrent Mark Sweep 的縮寫,意為併發標記清除,從名稱上可以得知,它使用的是標記-清除演算法,同時它又是一個使用多執行緒併發回收的垃圾收集器。   CMS 工作時,主要步驟有:初始標記、併發標記、重新標記、併發清除和併發重置。其中初始標記和重新標記是獨佔系統資源的,而併發標記、併發清除和併發重置是可以和使用者執行緒一起執行的。因此,從整體上來說,CMS 收集不是獨佔式的,它可以在應用程式執行過程中進行垃圾回收。   根據標記-清除演算法,初始標記、併發標記和重新標記都是為了標記出需要回收的物件。併發清理則是在標記完成後,正式回收垃圾物件;併發重置是指在垃圾回收完成後,重新初始化 CMS 資料結構和資料,為下一次垃圾回收做好準備。併發標記、併發清理和併發重置都是可以和應用程式執行緒一起執行的。   CMS 收集器在其主要的工作階段雖然沒有暴力地徹底暫停應用程式執行緒,但是由於它和應用程式執行緒併發執行,相互搶佔 CPU,所以在 CMS 執行期內對應用程式吞吐量造成一定影響。CMS 預設啟動的執行緒數是 (ParallelGCThreads+3)/4),ParallelGCThreads 是新生代並行收集器的執行緒數,也可以通過-XX:ParallelCMSThreads 引數手工設定 CMS 的執行緒數量。當 CPU 資源比較緊張時,受到 CMS 收集器執行緒的影響,應用程式的效能在垃圾回收階段可能會非常糟糕。   由於 CMS 收集器不是獨佔式的回收器,在 CMS 回收過程中,應用程式仍然在不停地工作。在應用程式工作過程中,又會不斷地產生垃圾。這些新生成的垃圾在當前 CMS 回收過程中是無法清除的。同時,因為應用程式沒有中斷,所以在 CMS 回收過程中,還應該確保應用程式有足夠的記憶體可用。因此,CMS 收集器不會等待堆記憶體飽和時才進行垃圾回收,而是當前堆記憶體使用率達到某一閾值時,便開始進行回收,以確保應用程式在 CMS 工作過程中依然有足夠的空間支援應用程式執行。   這個回收閾值可以使用-XX:CMSInitiatingOccupancyFraction 來指定,預設是 68。即當老年代的空間使用率達到 68%時,會執行一次 CMS 回收。如果應用程式的記憶體使用率增長很快,在 CMS 的執行過程中,已經出現了記憶體不足的情況,此時,CMS 回收將會失敗,JVM 將啟動老年代序列收集器進行垃圾回收。如果這樣,應用程式將完全中斷,直到垃圾收集完成,這時,應用程式的停頓時間可能很長。因此,根據應用程式的特點,可以對-XX:CMSInitiatingOccupancyFraction 進行調優。如果記憶體增長緩慢,則可以設定一個稍大的值,大的閾值可以有效降低 CMS 的觸發頻率,減少老年代回收的次數可以較為明顯地改善應用程式效能。反之,如果應用程式記憶體使用率增長很快,則應該降低這個閾值,以避免頻繁觸發老年代序列收集器。   標記-清除演算法將會造成大量記憶體碎片,離散的可用空間無法分配較大的物件。在這種情況下,即使堆記憶體仍然有較大的剩餘空間,也可能會被迫進行一次垃圾回收,以換取一塊可用的連續記憶體,這種現象對系統性能是相當不利的,為了解決這個問題,CMS 收集器還提供了幾個用於記憶體壓縮整理的演算法。   -XX:+UseCMSCompactAtFullCollection 引數可以使 CMS 在垃圾收集完成後,進行一次記憶體碎片整理。記憶體碎片的整理並不是併發進行的。-XX:CMSFullGCsBeforeCompaction 引數可以用於設定進行多少次 CMS 回收後,進行一次記憶體壓縮。

     G1 收集器 (Garbage First)

  G1 收集器的目標是作為一款伺服器的垃圾收集器,因此,它在吞吐量和停頓控制上,預期要優於 CMS 收集器。   與 CMS 收集器相比,G1 收集器是基於標記-壓縮演算法的。因此,它不會產生空間碎片,也沒有必要在收集完成後,進行一次獨佔式的碎片整理工作。G1 收集器還可以進行非常精確的停頓控制。它可以讓開發人員指定當停頓時長為 M 時,垃圾回收時間不超過 N。使用引數-XX:+UnlockExperimentalVMOptions –XX:+UseG1GC 來啟用 G1 回收器,設定 G1 回收器的目標停頓時間:-XX:MaxGCPauseMills=20,-XX:GCPauseIntervalMills=200。

比較不幸的是CMS GC,因為其演算法的理論缺陷等原因,雖然現在還有非常大的使用者群體,到那時已經被標記為廢棄,如果沒有組織主動承擔CMS的維護,很有可能在未來版本移除。如果你有關注目前尚處於開發中的JDK11,你會發現,JDK又增加了兩種全新的GC方式,分別是:

  • Epsilon GC:簡單說就是個不做垃圾收集的GC,似乎有點奇怪,有的情況下,例如在進行效能測試的時候,可能需要明確判斷GC本身產生了多大的開銷,這就是其典型的應用場景
  • ZGC:這是Oracle 開源出來的一個超級GC實現,具備令人驚訝的擴充套件能力,比如支援T byte級別的堆大小,並且保證絕大部分情況下,延遲都不會超過10 ms。雖然目前還處於實驗階段,僅支援Linux64位的平臺,但其已經表現出的能力和潛力都非常令人期待。

垃圾回收的使用沒有那麼絕對,調優永遠是針對 特定場景、特定需求、不存在一勞永逸的指標,一般建議堆30G以上慎用CMS,Cassandra的官方指南建議用在16G以內。

垃圾回收器效能測試

通過清單 15 所示程式碼執行 1 萬次迴圈,每次分配 512*100B 空間,採用不同的垃圾回收器,輸出程式執行所消耗的時間。 測試環境:Intel Core I7-7700,記憶體 16GB,jdk-1.8.0_121

public class GCTimeTest {
    static HashMap map = new HashMap();

    public static void main(String[] args){
        long begintime = System.currentTimeMillis();
        for(int i=0;i<10000;i++){
            if(map.size()*512/1024/1024>=400){
                map.clear();//保護記憶體不溢位
                System.out.println("clean map");
            }
            byte[] b1;
            for(int j=0;j<100;j++){
                b1 = new byte[512];
                map.put(System.nanoTime(), b1);//不斷消耗記憶體
            }
        }
        long endtime = System.currentTimeMillis();
        System.out.println(endtime-begintime);
    }
}

使用引數-Xmx512M -Xms512M -XX:+UseParNewGC 執行程式碼,輸出如下: 325 Java HotSpot™ 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release

使用引數-Xmx512M -Xms512M -XX:+UseParallelOldGC -XX:ParallelGCThreads=8,輸出如下: 299

使用引數-Xmx512M -Xms512M -XX:+UseSerialGC 執行程式碼,輸出如下: 505

使用引數-Xmx512M -Xms512M -XX:+UseConcMarkSweepGC 執行程式碼,輸出如下: 388

使用引數-Xmx512M -Xms512M -XX:+UseG1GC 執行程式碼,輸出如下 373

GC 相關引數總結

  與序列回收器相關的引數

  • -XX:+UseSerialGC:在新生代和老年代使用序列回收器。
  • -XX:+SurvivorRatio:設定 eden 區大小和 survivor 區大小的比例。
  • -XX:+PretenureSizeThreshold:設定大物件直接進入老年代的閾值。當物件的大小超過這個值時,將直接在老年代分配。
  • -XX:MaxTenuringThreshold:設定物件進入老年代的年齡的最大值。每一次 Minor GC 後,物件年齡就加 1。任何大於這個年齡的物件,一定會進入老年代。

  與並行 GC 相關的引數

  • -XX:+UseParNewGC: 在新生代使用並行收集器。
  • -XX:+UseParallelOldGC: 老年代使用並行回收收集器。
  • -XX:ParallelGCThreads:設定用於垃圾回收的執行緒數。通常情況下可以和 CPU 數量相等。但在 CPU 數量比較多的情況下,設定相對較小的數值也是合理的。
  • -XX:MaxGCPauseMills:設定最大垃圾收集停頓時間。它的值是一個大於 0 的整數。收集器在工作時,會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。
  • -XX:GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。
  • -XX:+UseAdaptiveSizePolicy:開啟自適應 GC 策略。在這種模式下,新生代的大小,eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。

  與 CMS 回收器相關的引數

  • -XX:+UseConcMarkSweepGC: 新生代使用並行收集器,老年代使用 CMS+序列收集器。
  • -XX:+ParallelCMSThreads: 設定 CMS 的執行緒數量。
  • -XX:+CMSInitiatingOccupancyFraction:設定 CMS 收集器在老年代空間被使用多少後觸發,預設為 68%。
  • -XX:+UseFullGCsBeforeCompaction:設定進行多少次 CMS 垃圾回收後,進行一次記憶體壓縮。
  • -XX:+CMSClassUnloadingEnabled:允許對類元資料進行回收。
  • -XX:+CMSParallelRemarkEndable:啟用並行重標記。
  • -XX:CMSInitatingPermOccupancyFraction:當永久區佔用率達到這一百分比後,啟動 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。
  • -XX:UseCMSInitatingOccupancyOnly:表示只在到達閾值的時候,才進行 CMS 回收。
  • -XX:+CMSIncrementalMode:使用增量模式,比較適合單 CPU。

  與 G1 回收器相關的引數

  • -XX:+UseG1GC:使用 G1 回收器。
  • -XX:+UnlockExperimentalVMOptions:允許使用實驗性引數。
  • -XX:+MaxGCPauseMills:設定最大垃圾收集停頓時間。
  • -XX:+GCPauseIntervalMills:設定停頓間隔時間。

  其他引數

  • -XX:+DisableExplicitGC: 禁用顯示 GC。