深入理解Java虛擬機器之垃圾回收篇

追風少年瀟歌發表於2021-10-18

垃圾回收簡介

​ Java 會對記憶體進行自動分配與回收管理,使上層業務更加安全,方便地使用記憶體實現程式邏輯。在不同的 JVM 實現及不同的回收機制中,堆記憶體的劃分方式是不一樣的。

​ 簡要地介紹下垃圾回收(Garbage Collection,GC)。垃圾回收的主要目的是清除掉沒有引用/不再使用的物件,自動釋放記憶體。在瞭解垃圾回收演算法之前,首先我們先要理解物件是怎麼定義可以用被回收的。

引用計數演算法

​ 那麼,GC 判斷物件可以回收的依據是什麼呢?有一種判斷物件是否存活的演算法是引用計數演算法,該演算法的原理是:給每一個物件分配一個引用計數器,每當有一個地方引用它時,計數器值就 +1 ;當引用失效時,計數器值就 -1 ;所以當物件的計數器值為 0 時,就可以判定該物件是可以被回收。引用計數法實現起來相對比較簡單,判定邏輯也不復雜。但是主流的 Java 虛擬機器裡面並沒有選用引用計數法來管理記憶體,因為該演算法有個很大的痛點就是難以解決物件之間的迴圈引用。舉個例子,現在有兩個物件 objA 和 objB 都宣告瞭 instance 欄位,程式碼如下

Object objA = new Object();
Object objB = new Object();
...
objA.instance = objB;
objB.instance = objA;

​ 除此之外,objA 和 objB 沒有任何的引用,也就是說這兩個物件除了彼此之外,再也不會被訪問,但就是因為它們倆互相引用著對方,導致它們的引用計數器不可能為0,引用計數演算法也無法通知 GC 將這倆物件進行回收。

可達性分析演算法

​ 所以目前主流虛擬機器採用最多的回收演算法是可達性分析演算法來判斷物件是否可以被回收,在 Java、C# 中都有大量的實現場景,JVM 也正是為了判斷物件存活,引入了GC Roots,下面簡要地介紹該演算法的思想:通過一系列的稱為“ GC Roots ”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用的鏈相連時(等同於物件與 GC Roots 之間沒有直接或間接的引用關係),則可證明此物件是不可用的,可以通知 GC 收集器回收。那麼什麼物件可以作為GC Roots呢?比如:類靜態屬性中引用的物件、常量引用的物件、虛擬機器棧中引用的物件、本地方法棧中引用的物件等等都可以充當 GC Roots 的角色。下面通過繪圖的形式,更好地理解可達性分析演算法的思想,物件 object 5、object 6、object7 雖然互相有關聯,但是它們到 GC Roots 是不可達的,所有被判定為可回收物件。

垃圾回收演算法

前面我們瞭解瞭如何去判斷物件是否存活,下面我們認識下垃圾回收演算法的基本思想。

標記-清除演算法

演算法思想:該演算法分為兩個階段,分別是標記清除階段,從每個 GC Roots 開始,依次標記有引用關係的物件,最後將沒有標記的物件清除。

​ 該演算法主要兩點不足之處,一個是效率問題,無論是標記還是清除,他們的效率都不是很高;另一個是空間問題,這種演算法會帶來大量的空間碎片,如果程式在執行過程當中,產生了一個很大的物件,需要較大的連續空間來分配該物件時,往往會出現老年代還有很大記憶體空間剩餘,但是卻無法找到足夠的連續記憶體空間,不得已去觸發另一次垃圾收集動作(FGC)。

標記-整理演算法

演算法思想:標記過程跟標記-清除演算法一樣,然後將存活的物件集中整理到記憶體空間的一端,形成一片連續的已使用的區域,最後再將該區域外的物件全部清除,這樣就避免了連續碎片的問題。

複製演算法(Mark-Copy)

演算法思想:為了能夠並行地標記和整理,將可用記憶體按容量劃分成大小相等的兩塊,每次只啟用其中一塊。這樣,當其中一塊的記憶體用完了,垃圾回收時只需把存活的物件複製到另一塊未被啟用的空間上,最後在清除掉除了未啟用空間之外,其他佔用記憶體空間的物件全部清除。

​ 比如將Java堆記憶體空間分為較大的 Eden 和兩塊較小的 Survivor ,每次只使用 Eden 區和 Survivor 區其中的一塊,當垃圾回收時,就將 Eden 和 Survivor 區中存活的物件複製到另一塊未被使用的 Survivor 區,再清除掉 Eden 和用過的一塊 Survivor 區空間。HotSpot 虛擬機器預設 Eden 和 Survivor 的大小比例是 8:1 ,也就是每次新生代中可用記憶體空間為整個新生代容量的 90%,只有 10% 的記憶體會被浪費。複製演算法現在就作為 YGC 演算法進行新生代的垃圾回收

​ 這樣做的好處是每次只需要對整個空間一半的區域塊進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片、較少了記憶體空間的浪費等複雜情況,只要移動堆頂指標,按順序分配記憶體即可。

分代收集演算法

​ 這種演算法並沒有新的思想,只是根據物件存活週期的不同將記憶體劃分成幾塊。一般是 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就採用複製演算法,只需要將存活的物件複製到未被使用的區域塊,效率很高;老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用標記-清除標記-整理演算法進行回收。

垃圾回收器

垃圾回收器(Garbage Collector,GC)是實現垃圾回收演算法並應用在 JVM 環境中的記憶體管理模組。

Serial 回收器/Serial Old 回收器

​ Serial 回收器是早期(JDK1.3.1 之前)虛擬機器新生代回收的唯一選擇,是一個主要應用於 YGC 的垃圾回收器,採用的垃圾回收演算法是標記-整理演算法,通過序列單執行緒的方式完成任務,序列就意味著每次只會使用一個 CPU 或一條回收執行緒去完成垃圾回收工作,並且在進行垃圾回收時,不允許其他執行緒與它一起工作,必須要停掉其他所有的工作執行緒,直至收集結束。這種情況就稱為:“Stop The World” 簡稱 STW ,即垃圾回收的某個階段會暫停整個應用程式的執行。Serial 回收流程圖如下:

​ FGC 的執行時間較長,如果頻繁引起 FGC 會嚴重影響應用程式的效能。此外,還有一種回收器叫 Serial Old回收器 ,它是 Serial 回收器的老年代版本,所以它也一樣是單執行緒回收器,採用的也是標記-整理演算法。

​ 即使是這樣,與其他回收器的單執行緒比,Serial 回收器也是有著優於它們的地方,對於限定單個 CPU 的環境來說,Serial 回收器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒回收效率。不論是 Serial 還是 Serial Old 回收器,它們的主要意義是在於給 Client 模式下的虛擬機器使用。

CMS回收器

​ CMS(Concurrent Mark Sweep)回收器是一種以獲取最短回收停頓時間為目標,是目前比較流行的垃圾回收器。對於Java 程式語言實現網際網路或者 B/S 系統的服務端,並且十分重視服務的響應速度,希望停頓時間越短越好,方便給予使用者更好的使用體驗,採取 CMS 回收器的策略就十分符合這種應用場景。

​ CMS 回收器是基於標記-清除演算法實現的,整個垃圾回收工作步驟分為4個步驟:

  1. 初始標記(CMS initial mark)
  2. 併發標記(CMS concurrent mark)
  3. 重新標記(CMS remark)
  4. 併發清除(CMS concurrent sweep)

​ 對於1、3步驟,也就是初始標記重新標記階段還是會引發 STW(Stop The World),而2、4步驟的併發標記併發清除兩個階段可以和應用程式併發執行,所以也屬於比較耗時的操作,但是無須擔心 CMS 回收器會影響到應用程式的正常執行。

初始標記階段僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快;併發標記階段就是進行 GC Roots Tracing 的過程;重新標記階段是為了修正併發標記期間,因使用者程式繼續執行而可能會導致標記產生變動的那一部分物件,進行標記記錄,這一階段的停頓時間一般會比初始標記階段長點,但不會比併發標記階段的時間長。

​ 在垃圾回收的4個步驟中,併發標記併發清除過程中所耗時最長,並且它們是可以跟使用者的執行緒在同一時間工作,所以從時間上來看,CMS 回收器的記憶體回收過程和使用者執行緒是一起併發執行的。CMS回收流程圖大致如下:

​ 所以 CMS 回收器是一款十分優秀的收集器,有著併發收集低停頓的優點,所以也稱為併發低停頓收集器,儘管如此,CMS 還是存在不足之處:

  1. CMS 回收器對CPU資源十分敏感。雖然說,在併發階段,CMS 回收器可以跟使用者執行緒併發執行,但還是會佔用一部分的 CPU 資源,從而導致應用程式響應變慢,系統壓力過高,導致系統最終的吞吐量降低。

  2. CMS 回收器無法處理浮動垃圾,可能會導致出現 “Concurrent Mode Failure” 失敗而導致另一次 FGC 的產生。

  3. CMS 回收器執行完垃圾回收後,會產生大量的空間碎片。這是由於 CMS 回收器採取的標記-清除演算法所帶來的影響(具體可以往上看標記-清除演算法部分)。為了解決這一問題,CMS 回收器可以通過配置 -XX:+UseCMSCompactAtFullCollection 開關引數(預設是開啟的)。用於在 CMS 回收器頂不住要進行 FGC 的時候,開啟記憶體碎片的合併整理過程,解決了空間碎片問題,但由於空間整理期間是無法併發的,無法併發就會引起 STW 的情況。但是好在 CMS 回收器的設計者為了減少STW次數,允許通過配置 -XX:+CMSFullGCsBeforeCompaction=n 引數,該引數 n 意味著,在執行了 n 次 FGC 之後,JVM 才能在老年代執行空間碎片整理;引數預設值為 0 ,則表示每次執行完 FGC 之後,都要進行空間碎片整理。

G1回收器

​ Hotspot 在 JDK7 中推出了新一代 G1 (Garbage-First)垃圾回收,通過 -XX:+UseG1GC 引數啟用。在 JDK11 中,已經把 G1 設為預設垃圾回收器,可通過 jstat 命令檢視垃圾回收情況。和 CMS 相比,G1 具備壓縮功能,能避免碎片問題。並且 G1 的暫停問題更加可控,總體上效能還是很不錯的。

​ 在 G1 之前,其他回收器進行垃圾收集時,收集的範圍都是整個新生代或老年代,而 G1 是 將 Java 堆空間分割成了若干相同大小的獨立區域,即 region ,其中包括 Eden 、Survivor 、Old 、Humongous 四種型別。其中, Humongous 是特殊的 Old 型別,專門放置大型物件。圖中可以看出,新生代和老年代不再是物理隔離,它們都是一部分 Region(不再連續)的集合。

​ G1 回收器之所以能夠建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個 Java 堆中進行全區域的垃圾收集。G1 跟蹤各個 Region 裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需要的時間),在後臺維護起一個優先順序別列表;所以它每次只需要根據允許收集的時間,優先收集價值回收率最高的 Region(這也是 Garbage-First 名稱的由來)。這種使用 Region 方式劃分成若干份大小相同的記憶體空間,以及有優先順序別根據的區域回收方式,保證了G1 回收器在有限的時間內提高了回收效率。

​ 與其他的 GC 回收器比,G1 回收器有著以下的特點:

  • 並行與併發:G1 能充分利用多核多CPU環境下的硬體優勢,使用多個CPU來縮短 STW 停頓的時間;部分回收器需要停頓其他 Java 執行緒執行的 GC 動作,而 G1 回收器可以與 Java 程式併發執行。
  • 回收演算法:G1 採用的是 Mark-Copy複製演算法),有很好的空間整合能力,在 G1 執行期間不會產生大量的空間碎片,並且回收完成之後能夠提供規整的可用記憶體,有利於程式長時間執行。
  • 可預測的停頓:能夠儘可能快地在指定時間內完成垃圾回收任務,能夠讓使用者明確指定在一個長度在 M 毫秒的時間片段內,消耗在垃圾收集上的時間不能超過 N 毫秒(M > N)。

參考資料《深入理解Java虛擬機器》、《碼出高效》

相關文章