1. 程式人生 > >Java的垃圾回收機制

Java的垃圾回收機制

是否存活 完成 java 重要 啟動 增加 min 大對象 評價

前言

在C++語言中, 程序員必須小心謹慎的處理每一項內存分配, 且內存使用完後必須手動釋放曾經占用的內存空間。當內存釋放不夠完全時, 即存在分配但永不釋放的內存塊, 就會引起"內存泄漏"問題。

而在Java語言中, 它給了程序員一個美好的承諾: 程序員無需管理內存, 因為JVM會有GC去自動進行垃圾回收。其實不然:

  • 垃圾回收並不會按照程序員的要求, 隨時進行GC。
  • 垃圾回收並不會及時的清理內存, 盡管有時程序需要額外的內存。
  • 程序員不能對垃圾回收進行控制。

基於上面的事實, 我們就有必要徹底了解JVM的自動內存管理機制, 如此才能將程序控制於鼓掌之中。本篇文章就是從垃圾回收內存分配這兩個知識點, 對JVM的內存管理機制做一個基本的了解。

為什麽要進行垃圾回收?

隨著程序的運行,內存中存在的實例對象、變量等信息占據的內存越來越多,如果不及時進行垃圾回收,必然會帶來程序性能的下降,甚至會因為可用內存不足造成一些不必要的系統異常。

哪些垃圾要進行回收?

在Java內存運行時區域的各個部分, 其中程序計數器、JVM棧、本地方法棧3個區域的生命周期是和線程同步的, 他們占用的內存會隨著線程銷毀而自動釋放, 所以這幾個區域不需要過多的考慮垃圾回收問題。

而Java堆和方法區則不一樣, 一個接口中的多個實現類需要的內存可能不一樣, 一個方法中的多個分支需要的內存也可能不一樣, 我們只有在程序處於運行期間才能知道會創建哪些對象, 這部分內存的分配和回收是動態的, 所以需要進行GC。

什麽時候進行垃圾回收?

垃圾收集器在對Java堆進行回收前, 會先去確定所有的對象實例之中哪些還"存活"著, 哪些已經"死去"(即已經不存在任何引用)。

在很多教科書中是根據引用計數算法來判斷對象是否可回收的: 給對象中添加一個引用計數器, 每被引用一次,計數器加1; 引用失效時,計數器減1; 當計數器在一段時間內保持為0時,該對象就被認為是可回收的。但是, 這個算法有明顯的缺陷: 當兩個對象相互引用,但是二者已經沒有作用時,按照常規,應該對其進行垃圾回收,但是其相互引用,又不符合垃圾回收的條件,因此無法完美處理這塊內存清理。因此Sun的JVM並沒有采用引用計數算法, 而是采用了可達性分析算法

來進行垃圾回收。

可達性分析算法的基本思想是: 通過一系列的稱為"GC Roots"的對象作為起始點, 從這些節點開始向下搜索, 搜索所走過的路徑稱為引用鏈, 當一個對象到GC Roots沒有任何引用鏈相連時, 則證明此對象是不可用的。如下圖所示, 對象object5、object6、object7雖然互相有關聯, 但是它們到GC Roots是不可達的, 所以它們將會被判定為是可回收的對象。

技術分享圖片

無論是引用計數算法, 還是可達性分析算法, 它們判定對象是否存活都與"引用"有關。在JDK 1.2之後, Java對引用的概念進行了擴充,引入了強、軟、弱、虛四種引用, 這4種引用強度依次逐漸減弱。關於這幾種引用的概念, 讀者可自行了解, 這裏就不多做贅述。

另外, 即使在可達性分析算法中不可達的對象,也並非是"非死不可"的。如果類重寫了finalize()方法, 且沒有被虛擬機調用過, 那麽虛擬機會調用一次finalize()方法, 以完成最後的工作, 在此期間, 如果對象重新與引用鏈上的任何一個對象建立關聯,則該對象可以“重生”; 如果對象這時候還沒有逃脫, 那麽它就真的被回收了。

垃圾收集器在對方法區進行回收前, 會先去判定一個類是否是"無用的類", 而類需要同時滿足下面3個條件才能算是"無用的類":

  • 該類的所有實例對象都已經被回收。
  • 加載該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用, 無法在任何地方通過反射訪問該類的方法。

如何進行垃圾回收?

在Java堆中, 內存被分為新生代舊生代, 兩者比例為1:2。新生代適合那些生命周期較短、頻繁創建及銷毀的對象, 舊生代適合生命周期相對較長的對象和需要大量連續內存空間的大對象。

技術分享圖片

如上圖所示, 新生代中分為Eden區和Survivor區, 而Survivor區又分為大小相同的兩部分:FromSpace 和ToSpace。其中Eden區和一個Survivor區的默認空間比例為8:1, 可以用-XX:SurvivorRatio來設置大小。大多數情況下, 對象在新生代Eden區中分配, 當Eden空間不足時, 虛擬機將發起一次Minor GC把存活的對象轉移到Survivor區中。新生代采用復制算法收集內存。

舊生代中用於存放新生代中經過多次垃圾回收仍然存活的對象, 和一些需要大量連續內存空間的大對象。另外在JVM中還有一種動態對象年齡判定: 如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半, 年齡大於或等於該年齡的對象就可以直接進入舊生代。舊生代采用標記-整理(壓縮)算法收集內存。

垃圾收集算法

在上文中, 我們提及到復制算法和標記-整理(壓縮)算法, 這兩種算法就是常見的GC算法之一。

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

標記-清除是最基礎的GC算法, 分為"標記"和"清除"兩個階段: 首先標記出所有需要回收的對象, 然後掃描和回收所有被標記的對象。它有兩個不足: 其一, 標記和清除兩個過程的效率都不高;其二, 標記清除之後會產生大量不連續的內存碎片, 空間碎片太多可能會導致以後在分配大對象時, 無法找到足夠的連續內存而不得不提前觸發另一次GC動作。

技術分享圖片

復制算法(Copying)

前文提到, 新生代分為1塊Eden區和2塊Survivor區, 其中Eden區和一個Survivor區的默認空間比例為8:1, 即另一塊Survivor區是空閑的。在垃圾回收時, 將Eden區和Survivor區還存活著的對象一次性地復制到另一塊Survivor空間上, 然後清理掉剛才用過的Eden和Survivor空間。當第二塊Survivor空間不夠用時, 就需要依賴舊生代進行分配擔保。復制算法適用於新生代。

技術分享圖片

標記-整理(壓縮)算法(Mark-compact)

標記過程仍然與"標記-清除"算法一樣, 但後續步驟不是直接對可回收對象進行清理, 而是讓所有存活的對象都向一端移動, 然後直接清理掉端邊界以外的內存。標記-整理算法適用於舊生代。

技術分享圖片

分代收集算法(Generational Collecting)

根據垃圾回收對象的特性, 不同階段最優的方式是使用合適的算法用於本階段的垃圾回收, 分代算法即是基於這種思想, 它將內存區間根據對象的特點分成幾塊, 根據每塊內存區間的特點, 使用不同的回收算法, 以提高垃圾回收的效率。以Hot Spot 虛擬機為例, 它將Java堆分為新生代和舊生代, 這樣就能根據各個年代的特點采用最適當的收集算法。

垃圾收集器分類

基於JDK 1.7 Update 14之後的HotSpot虛擬機包含的所有收集器如下圖所示。

技術分享圖片

Serial收集器

串行收集器主要有兩個特點:第一,它僅僅使用單線程進行垃圾回收;第二,它獨占式的垃圾回收。

在串行收集器進行垃圾回收時,Java 應用程序中的線程都需要暫停,等待垃圾回收的完成,這樣給用戶體驗造成較差效果。雖然如此,串行收集器卻是一個成熟、經過長時間生產環境考驗的極為高效的收集器。新生代串行處理器使用復制算法,實現相對簡單,邏輯處理特別高效,且沒有線程切換的開銷。在諸如單 CPU 處理器或者較小的應用內存等硬件平臺不是特別優越的場合,它的性能表現可以超過並行回收器和並發回收器。在 HotSpot 虛擬機中,使用-XX:+UseSerialGC參數可以指定使用新生代串行收集器和舊生代串行收集器。當 JVM 在 Client 模式下運行時,它是默認的垃圾收集器。

ParNew收集器

並行收集器是工作在新生代的垃圾收集器,它只簡單地將串行回收器多線程化。它的回收策略、算法以及參數和串行回收器一樣。

並行回收器也是獨占式的回收器,在收集過程中,應用程序會全部暫停。但由於並行回收器使用多線程進行垃圾回收,因此,在並發能力比較強的 CPU 上,它產生的停頓時間要短於串行回收器,而在單 CPU 或者並發能力較弱的系統中,並行回收器的效果不會比串行回收器好,由於多線程的壓力,它的實際表現很可能比串行回收器差。

開啟並行回收器可以使用參數-XX:+UseParNewGC,該參數設置新生代使用並行收集器,舊生代使用串行收集器。

Parallel Scavenge收集器

新生代並行回收收集器也是使用復制算法的收集器。從表面上看,它和並行收集器一樣都是多線程、獨占式的收集器。但是,並行回收收集器有一個重要的特點:它非常關註系統的吞吐量。

新生代並行回收收集器可以使用以下參數啟用:

  • -XX:+UseParallelGC:新生代使用並行回收收集器,舊生代使用串行收集器。
  • -XX:+UseParallelOldGC:新生代和舊生代都使用並行回收收集器。

另外, 並行回收收集器與並行收集器另一個不同之處在於,它支持一種自適應的 GC 調節策略,使用-XX:+UseAdaptiveSizePolicy可以打開自適應 GC 策略。在這種模式下,新生代的大小、eden 和 survivor 的比例、晉升舊生代的對象年齡等參數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機的最大堆、目標的吞吐量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機自己完成調優工作。

Serial Old收集器

舊生代串行收集器使用的是標記-壓縮算法。和新生代串行收集器一樣,它也是一個串行的、獨占式的垃圾回收器。由於舊生代垃圾回收通常會使用比新生代垃圾回收更長的時間,因此,在堆空間較大的應用程序中,一旦舊生代串行收集器啟動,應用程序很可能會因此停頓幾秒甚至更長時間。雖然如此,舊生代串行回收器可以和多種新生代回收器配合使用,同時它也可以作為 CMS 回收器的備用回收器。若要啟用舊生代串行回收器,可以嘗試使用參數-XX:+UseSerialGC指定新生代、舊生代都使用串行回收器。

Parallel Old收集器

舊生代的並行回收收集器也是一種多線程並行的收集器。和新生代並行回收收集器一樣,它也是一種關註吞吐量的收集器。舊生代並行回收收集器使用標記-壓縮算法,JDK1.6 之後開始啟用。

使用-XX:+UseParallelOldGC可以在新生代和舊生代都使用並行回收收集器,這是一對非常關註吞吐量的垃圾收集器組合,在對吞吐量敏感的系統中,可以考慮使用。參數-XX:ParallelGCThreads也可以用於設置垃圾回收時的線程數量。

CMS (Concurrent Mark Sweep)收集器

與並行回收收集器不同,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收集器

G1 收集器的目標是作為一款服務器的垃圾收集器,因此,它在吞吐量和停頓控制上,預期要優於 CMS 收集器。

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

上述幾種垃圾收集器, 從不同角度分析, 可以將其分為不同的類型:

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

而要評價一個垃圾收集器的好壞, 可以用以下指標:

  • 吞吐量:指在應用程序的生命周期內,應用程序所花費的時間和系統總運行時間的比值。系統總運行時間=應用程序耗時+GC 耗時。如果系統運行了 100min,GC 耗時 1min,那麽系統的吞吐量就是 (100-1)/100=99%。
  • 垃圾回收器負載:和吞吐量相反,垃圾回收器負載指垃圾回收器耗時與系統運行總時間的比值。
  • 停頓時間:指垃圾回收器正在運行時,應用程序的暫停時間。對於獨占回收器而言,停頓時間可能會比較長。使用並發的回收器時,由於垃圾回收器和應用程序交替運行,程序的停頓時間會變短,但是,由於其效率很可能不如獨占垃圾回收器,故系統的吞吐量可能會較低。
  • 垃圾回收頻率:指垃圾回收器多長時間會運行一次。一般來說,對於固定的應用而言,垃圾回收器的頻率應該是越低越好。通常增大堆空間可以有效降低垃圾回收發生的頻率,但是可能會增加回收產生的停頓時間。
  • 反應時間:指當一個對象被稱為垃圾後多長時間內,它所占據的內存空間會被釋放。
  • 堆分配:不同的垃圾回收器對堆內存的分配方式可能是不同的。一個良好的垃圾收集器應該有一個合理的堆內存區間劃分。

小結

在本篇文章中, 我們主要從為什麽、哪些、什麽時候、以及如何進行垃圾回收等4個方面對Java的垃圾回收機制做一個基本的認識, 另外也了解了GC的4種算法, 和垃圾收集器的分類概述及評價指標。

參考資料

《成神之路-基礎篇》JVM——垃圾回收

Java的垃圾回收機制