27、Java常見的垃圾收集器有哪些?

目錄

今天我要問你的問題是,Java 常見的垃圾收集器有哪些?

典型回答

考點分析

知識擴展

垃圾收集的原理和基礎概念

常見的垃圾收集算法

垃圾收集過程的理解

GC 的新發展

一課一練


垃圾收集機制是 Java 的招牌能力,極大地提高了開發效率。如今,垃圾收集幾乎成爲現代語言的標配,即使經過如此長時間的發展, Java 的垃圾收集機制仍然在不斷的演進中,不同大小的設備、不同特徵的應用場景,對垃圾收集提出了新的挑戰,這當然也是面試的熱點。

今天我要問你的問題是,Java 常見的垃圾收集器有哪些?

典型回答

實際上,垃圾收集器(GC,Garbage Collector)是和具體 JVM 實現緊密相關的,不同廠商(IBM、Oracle),不同版本的 JVM,提供的選擇也不同。接下來,我來談談最主流的 Oracle JDK。

 

1、Serial GC,它是最古老的垃圾收集器,“Serial”體現在其收集工作是單線程的,並且在進行垃圾收集過程中,會進入臭名昭著的“Stop-The-World”狀態。當然,其單線程設計也意味着精簡的 GC 實現,無需維護複雜的數據結構,初始化也簡單,所以一直是 Client 模式下 JVM 的默認選項。

 從年代的角度,通常將其老年代實現單獨稱作 Serial Old,它採用了標記 - 整理(Mark-Compact)算法,區別於新生代的複製算法。
  Serial GC 的對應 JVM 參數是:

-XX:+UseSerialGC


2、ParNew GC,很明顯是個新生代 GC 實現,它實際是 Serial GC 的多線程版本,最常見的應用場景是配合老年代的 CMS GC 工作,下面是對應參數

-XX:+UseConcMarkSweepGC -XX:+UseParNewGC

 

3、CMS(Concurrent Mark Sweep) GC,基於標記 - 清除(Mark-Sweep)算法,設計目標是儘量減少停頓時間,這一點對於 Web 等反應時間敏感的應用非常重要,一直到今天,仍然有很多系統使用 CMS GC。但是,CMS 採用的標記 - 清除算法,存在着內存碎片化問題,所以難以避免在長時間運行等情況下發生 full GC,導致惡劣的停頓。另外,既然強調了併發(Concurrent),CMS 會佔用更多 CPU 資源,並和用戶線程爭搶。


 4、Parrallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默認 GC 選擇,也被稱作是吞吐量優先的 GC。它的算法和 Serial GC 比較相似,儘管實現要複雜的多,其特點是新生代和老年代 GC 都是並行進行的,在常見的服務器環境中更加高效。
   開啓選項是:

-XX:+UseParallelGC

另外,Parallel GC 引入了開發者友好的配置項,我們可以直接設置暫停時間或吞吐量等目標,JVM 會自動進行適應性調整,例如下面參數:

-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC 時間和用戶時間比例 = 1 / (N+1)


5、G1 GC 這是一種兼顧吞吐量和停頓時間的 GC 實現,是 Oracle JDK 9 以後的默認 GC 選項。G1 可以直觀的設定停頓時間的目標,相比於 CMS GC,G1 未必能做到 CMS 在最好情況下的延時停頓,但是最差情況要好很多。

G1 GC 仍然存在着年代的概念,但是其內存結構並不是簡單的條帶式劃分,而是類似棋盤的一個個 region。Region 之間是複製算法,但整體上實際可看作是標記 - 整理(Mark-Compact)算法,可以有效地避免內存碎片,尤其是當 Java 堆非常大的時候,G1 的優勢更加明顯。

G1 吞吐量和停頓表現都非常不錯,並且仍然在不斷地完善,與此同時 CMS 已經在 JDK 9 中被標記爲廢棄(deprecated),所以 G1 GC 值得你深入掌握。

 

考點分析

對於垃圾收集,面試官可以循序漸進從理論、實踐各種角度深入,也未必是要求面試者什麼都懂。但如果你懂得原理,一定會成爲面試中的加分項。在今天的講解中,我側重介紹比較通用、基礎性的部分:

  •   垃圾收集的算法有哪些?如何判斷一個對象是否可以回收?
  •   垃圾收集器工作的基本流程。

另外,Java 一直處於非常迅速的發展之中,在最新的 JDK 實現中,還有多種新的 GC,我會在最後補充,除了前面提到的垃圾收集器,看看還有哪些值得關注的選擇。

 

知識擴展

垃圾收集的原理和基礎概念

第一,自動垃圾收集的前提是清楚哪些內存可以被釋放。這一點可以結合我前面對 Java 類加載和內存結構的分析,來思考一下。

主要就是兩個方面,最主要部分就是對象實例,都是存儲在堆上的;還有就是方法區中的元數據等信息,例如類型不再使用,卸載該 Java 類似乎是很合理的。

對於對象實例收集,主要是兩種基本算法,引用計數和可達性分析。

  •   引用計數算法,顧名思義,就是爲對象添加一個引用計數,用於記錄對象被引用的情況,如果計數爲  0,即表示對象可回收。這是很多語言的資源回收選擇,例如因人工智能而更加火熱的   Python,它更是同時支持引用計數和垃圾收集機制。具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。
  •   Java 並沒有選擇引用計數,是因爲其存在一個基本的難題,也就是很難處理循環引用關係。
  •   另外就是 Java 選擇的可達性分析,Java 的各種引用關係,在某種程度上,將可達性問題還進一步複雜化,具體請參考專欄第 4 講,這種類型的垃圾收集通常叫作追蹤性垃圾收集(Tracing Garbage Collection)。其原理簡單來說,就是將對象及其引用關係看作一個圖,選定活動的對象作爲 GC Roots,然後跟蹤引用鏈條,如果一個對象和 GC  Roots 之間不可達,也就是不存在引用鏈條,那麼即可認爲是可回收對象。JVM 會把虛擬機棧和本地方法棧中正在引用的對象、靜態屬性引用的對象和常量,作爲  GC Roots。

方法區無用元數據的回收比較複雜,我簡單梳理一下。還記得我對類加載器的分類吧,一般來說初始化類加載器加載的類型是不會進行類卸載(unload)的;而普通的類型的卸載,往往是要求相應自定義類加載器本身被回收,所以大量使用動態類型的場合,需要防止元數據區(或者早期的永久代)不會爲
OOM。在 8u40 以後的 JDK 中,下面參數已經是默認的:

-XX:+ClassUnloadingWithConcurrentMark

 

常見的垃圾收集算法

第二,常見的垃圾收集算法,我認爲總體上有個瞭解,理解相應的原理和優缺點,就已經足夠了,其主要分爲三類:

  •   複製(Copying)算法,我前面講到的新生代 GC,基本都是基於複製算法,過程就如專欄上一講所介紹的,將活着的對象複製到 to 區域,拷貝過程中將對象順序放置,就可以避免內存碎片化。

   這麼做的代價是,既然要進行復制,既要提前預留內存空間,有一定的浪費;另外,對於 G1 這種分拆成爲大量 region 的 GC,複製而不是移動,意味着 GC 需要維護 region 之間對象引用關係,這個開銷也不小,不管是內存佔用或者時間開銷。

 

  •   標記 -  清除(Mark-Sweep)算法,首先進行標記工作,標識出所有要回收的對象,然後進行清除。這麼做除了標記、清除過程效率有限,另外就是不可避免的出現碎片化問題,這就導致其不適合特別大的堆;否則,一旦出現用  Full GC,暫停時間可能根本無法接受。

 

  •   標記 - 整理(Mark-Compact),類似於標記 - 清除,但爲避免內存碎片化,它會在清理過程中將對象移動,以確保移動後的對象佔用連續的內存空間。


注意,這些只是基本的算法思路,實際 GC 實現過程要複雜的多,目前還在發展中的前沿 GC 都是複合算法,並且並行和併發兼備。

如果對這方面的算法有興趣,可以參考一本比較有意思的書《垃圾回收的算法與實現》,雖然其內容並不是圍繞 Java 垃圾收集,但是對通用算法講解比較形象。

 

垃圾收集過程的理解

我在專欄上一講對堆結構進行了比較詳細的劃分,在垃圾收集的過程,對應到 Eden、Survivor、Tenured 等區域會發生什麼變化呢?

這實際上取決於具體的 GC 方式,先來熟悉一下通常的垃圾收集流程,我畫了一系列示意圖,希望能有助於你理解清楚這個過程。

 

第一,Java 應用不斷創建對象,通常都是分配在 Eden 區域,當其空間佔用達到一定閾值時,觸發 minor GC。仍然被引用的對象(綠色方塊)存活下來,被複制到 JVM 選擇的 Survivor 區域,而沒有被引用的對象(黃色方塊)則被回收。注意,我給存活對象標記了“數字 1”,這是爲了表明對象的存活時間。

第二, 經過一次 Minor GC,Eden 就會空閒下來,直到再次達到 Minor GC 觸發條件,這時候,另外一個 Survivor 區域則會成爲 to 區域,Eden 區域的存活對象和 From 區域對象,都會被複制到 to 區域,並且存活的年齡計數會被加 1。


第三, 類似第二步的過程會發生很多次,直到有對象年齡計數達到閾值,這時候就會發生所謂的晉升(Promotion)過程,如下圖所示,超過閾值的對象會被晉升到老年代。這個閾值是可以通過參數指定:

-XX:MaxTenuringThreshold=<N>

後面就是老年代 GC,具體取決於選擇的 GC 選項,對應不同的算法。下面是一個簡單標記 - 整理算法過程示意圖,老年代中的無用對象被清除後, GC 會將對象進行整理,以防止內存碎片化。


通常我們把老年代 GC 叫作 Major GC,將對整個堆進行的清理叫作 Full GC,但是這個也沒有那麼絕對,因爲不同的老年代 GC 算法其實表現差異很大,例如 CMS,“concurrent”就體現在清理工作是與工作線程一起併發運行的。

 

GC 的新發展

GC 仍然處於飛速發展之中,目前的默認選項 G1 GC 在不斷的進行改進,很多我們原來認爲的缺點,例如串行的 Full GC、Card Table 掃描的低效等,都已經被大幅改進,例如, JDK 10 以後,Full GC 已經是並行運行,在很多場景下,其表現還略優於 Parallel GC 的並行 Full GC 實現。

即使是 Serial GC,雖然比較古老,但是簡單的設計和實現未必就是過時的,它本身的開銷,不管是 GC 相關數據結構的開銷,還是線程的開銷,都是非常小的,所以隨着雲計算的興起,在 Serverless 等新的應用場景下,Serial GC 找到了新的舞臺。

比較不幸的是 CMS GC,因爲其算法的理論缺陷等原因,雖然現在還有非常大的用戶羣體,但是已經被標記爲廢棄,如果沒有組織主動承擔 CMS 的維護,很有可能會在未來版本移除。

 

如果你有關注目前尚處於開發中的 JDK 11,你會發現,JDK 又增加了兩種全新的 GC 方式,分別是:

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


當然,其他廠商也提供了各種獨具一格的 GC 實現,例如比較有名的低延遲 GC,Zing和Shenandoah等,有興趣請參考我提供的鏈接。

今天,作爲 GC 系列的第一講,我從整體上梳理了目前的主流 GC 實現,包括基本原理和算法,並結合我前面介紹過的內存結構,對簡要的垃圾收集過程進行了介紹,希望能夠對你的相關實踐有所幫助。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎?今天談了一堆的理論,思考一個實踐中的問題,你通常使用什麼參數去打開 GC 日誌呢?還會額外添加哪些選項?

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