JVM筆記-垃圾收集算法與垃圾收集器

1. 一些概念

1.1 垃圾&垃圾收集

  • 垃圾:在 JVM 語境下,“垃圾”指的是死亡的對象所佔據的堆空間。

  • 垃圾收集:所謂“垃圾收集”,就是將已分配出去、但不再使用的內存回收回來,以便能再次分配。

1.2 對象是否死亡

如何判斷一個對象是否死亡(即不可能再被任何途徑使用)?通常有以下兩種方法:

1.2.1 引用計數法

引用計數法(Reference Counting):爲每個對象添加一個引用計數器,用來統計指向該對象的引用個數。當有地方引用它時,計數器加一;引用失效時減一。當某個對象的引用計數爲零時,說明該對象已死亡,便可以被回收了。

  • 主要優點:原理簡單,判定效率高。

  • 主要缺點:無法解決循環依賴的問題(對象之間相互循環引用)。

1.2.2 可達性分析算法

可達性分析(Reachability Analysis)的基本思路如下:

通過一系列稱爲 GC Roots 的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲"引用鏈"(Reference Chain),若某個對象到 GC Roots 間沒有任何引用鏈相連(或者用圖論的話來說就是從 GC Roots 到這個對象不可達時)則證明此對象是不可能再被使用的。

示意圖如下:

GC Roots 可理解爲「堆外指向堆內的引用」。在 Java 技術體系中,固定可作爲 GC Roots 的對象包括以下幾種:

  1. 虛擬機棧(棧幀中的本地變量表)中引用的對象

  2. 方法區中類靜態屬性引用的對象(比如引用類型的靜態變量)

  3. 方法區中常量引用的對象(比如字符串常量池的引用)

  4. 本地方法棧中 Native 方法引用的對象

  5. JVM 內部的引用(例如:基本數據類型的 Class 對象、常駐異常、系統類加載器)

  6. 同步鎖(synchronized 關鍵字)持有的對象

  7. 反應 JVM 內部情況的 JMXBean、JVMTI 中註冊的回調、本地代碼緩存等

1.3 引用的分類

無論是引用計數法還是可達性分析算法,二者都離不開「引用」。JDK 1.2 之後,Java 中「引用」的概念得以擴充,主要分爲以下四種:

  • 強引用(Strongly Reference)

    • 例如:Object obj = new Object()

    • 特點:無論任何情況,只要強引用存在,垃圾收集器永不回收被引用的對象

  • 軟引用(Soft Reference)

    • 場景:用於一些還有用、但非必須的對象

    • 時機:被軟引用關聯的對象,在系統將發生 OOM 前,回收這些內存

    • 實現:java.lang.ref.SoftReference

  • 弱引用(Weak Reference)

    • 場景:非必須對象,比軟引用更弱

    • 時機:被弱引用關聯的對象只能生存到下一次垃圾收集發生(無論內存是否充足都會回收)

    • 實現:java.lang.ref.WeakReference

  • 虛引用(Phantom Reference)

    • 又稱“幽靈引用”或“幻影引用”

    • 特點:最弱的引用,是否存在完全不會影響其生存時間,無法通過它獲取對象實例

    • 唯一目的:該對象被回收時收到一個系統通知

    • 實現:java.lang.ref.PhantomReference

1.4 回收方法區

《Java 虛擬機規範》並未要求虛擬機在方法區實現垃圾收集。方法區垃圾收集“性價比較低。

但在大量使用反射、動態代理、CGLib 等字節碼框架,動態生成 JSP 及 OSGi 這類頻繁自定義類加載器的場景中,通常都需要 JVM 具備類型卸載的能力,以避免方法區內存壓力過大。

方法區垃圾回收的主要內容包括:廢棄的常量和不再使用的類型。它們的判定條件如下:

  • 廢棄的常量

    • 無任何對象引用常量池中的常量

    • 虛擬機中無任何其他地方引用該字面量

  • 不再使用的類型

    • 該類所有的實例(包括子類)都已被回收

    • 該類的類加載器已被回收

    • 該類的 Class 對象任何地方未被引用,任何地方無法通過反射訪問該類的方法

虛擬機參數:

# 是否對類型回收
-Xnoclassgc


# 查看類加載和卸載
-verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnLoading

2. 垃圾收集算法

從如何判定對象消亡的角度出發,垃圾收集器可分爲“引用計數式垃圾收集”(Reference Counting GC)和“追蹤式垃圾收集”(Tracing GC)兩大類,也稱“直接垃圾收集”和“間接垃圾收集”。

這裏的垃圾回收算法都屬於“追蹤式垃圾收集”的範疇。

2.0 分代收集理論

當代商業虛擬機的垃圾收集器,多數都遵循了“分代收集”(Generational Collection)的理論。

分代收集理論,實質是一套符合大多數程序運行實際情況的經驗法則,有如下三條:

  1. 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。

  2. 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。

  3. 跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對於同代引用來說僅佔極少數。

根據以上幾條規則,可以推測:

  • 若一個區域中大多數對象都是朝生夕滅,把它們集中在一起,每次回收只需關注如何保留少量存活的對象;

  • 若一個區域剩下的都是難以消亡的對象,把它們集中在一起,便可以較低的頻率回收該區域。

如何解決跨代引用問題?

依據跨代引用假說,爲了解決極少數跨代引用,只需在新生代建立一個“記憶集(Remembered Set)”,把老年代劃分爲若干小塊,標識出哪一塊內存會存在跨代引用,此後發生 Minor GC 時,只有包含跨代引用的小塊內存中的對象纔會被加入到 GC Roots 進行掃描(避免掃描整個老年代)。

一些 GC 概念:

  • 部分收集(Partial GC):目標不是完整收集整個 Java 堆的垃圾收集

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。

    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前只有 CMS 收集器會有單獨收集老年代的行爲。

    • 混合收集(Mixed GC):是收集整個新生代以及部分老年代的垃圾收集。目前只有 G1 收集器會有這種行爲。

  • 整堆收集(Full GC):收集整個 Java 堆和方法區的垃圾收集。

下面介紹常見的垃圾收集算法。

2.1 標記-清除算法

標記-清除(Mark-Sweep)算法:最早、最基礎的算法,分爲“標記”和“清除”兩個階段:

  1. 標記出所有需要回收的對象(或反之);

  2. 在標記完成後統一回收所有被標記的對象(或反之)。

後續的收集算法大多都是以“標記-清除”算法爲基礎,對其缺點進行改進而得。

該算法的示意圖如下:

優缺點分析:

  • 主要優點:實現簡單;

  • 主要缺點:

    • 執行效率不穩定,標記和清除過程效率都不高(標記對象較多時);

    • 內存空間碎片化問題(碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作)。

圖片來自:http://www.cnblogs.com/dolphin0520/p/3783345.html

2.2 標記-複製算法

簡稱複製(Copying)算法。現在的商用 Java 虛擬機大都優先採用了這種收集算法回收新生代。

“半區複製”(Semispace Copying)算法將可用內存按容量分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊用完時,就將還存活的對象複製到另外一塊上,然後再把已使用的內存空間一次清理掉。

該算法主要爲了解決“標記-清除”算法面對大量可回收對象時執行效率低的問題。

示意圖如下:

“半區複製”算法的優缺點:

  • 優點:實現簡單,運行高效且不易產生內存碎片;

  • 缺點:可用內存縮小爲原來的一半,空間浪費太多。

一般不需要按照 1:1 的比例劃分內存空間,而是將內存分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的對象一次性地複製到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛纔用過的 Survivor 空間。

HotSpot 虛擬機默認 Eden 和 Survivor 的大小比例是 8:1 (即“浪費”了 10% 的新生代空間)。

由於無法保證每次每次回收都只有不多於 10% 的對象存活,當 Survivor 空間不夠用時,需要依賴其他內存(大多指老年代)進行分配擔保(Handle Promotion),也就是直接進入老年代(相當於“兜底方案”)。

2.3 標記-整理算法

複製算法在對象存活率較高時就要進行較多的複製操作,效率將會降低。

標記-整理(Mark-Compact)算法:標記過程與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

標記-清除算法與標記-整理算法區別:前者是一種非移動式的回收算法,後者是移動式的。

示意圖:

主要問題:

  • 移動存活對象:回收內存時會更復雜(Stop The World);

  • 不移動存活對象:分配內存時會更復雜(空間碎片問題)。

2.4 分代收集算法

當前商業虛擬機的垃圾收集都採用“分代收集”(Generational Collection)算法(不是一種具體的算法實現,可以理解爲「組合模式」):根據對象存活週期的不同,將內存劃分爲幾塊。

一般把 Java 堆分爲新生代和老年代,根據各個年代的特點採用最適當的收集算法。

  • 新生代

每次垃圾收集時,都有大批對象死去,只有少量存活,採用複製算法(只需複製少量存活對象)。

  • 老年代

對象存活率較高,沒有額外空間對它進行分配擔保,必須使用“標記-清除”或“標記-整理”算法進行回收。

3. 垃圾收集器

前面的收集算法只是內存回收的方法論,而垃圾收集器纔是內存回收的具體實現(可理解爲“接口”與“實現類”的關係)。

3.1 Serial 收集器

Serial 收集器是最基礎、歷史最悠久的收集器。特點:

  1. 單線程收集,且垃圾收集時,必須暫停其他所有的工作線程(Stop The World, STW),直到它收集結束。

  2. HotSpot 虛擬機運行在 Client 模式下默認新生代收集器。

  3. 優於其他收集器的地方:簡單而高效(與其他收集器的單線程比)。對於限定單個 CPU 的環境來說,Serial 收集器由於沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。

運行示意圖如下:

3.2 ParNew 收集器

ParNew 收集器實質上是 Serial 收集器的多線程並行版本。

除了同時使用多條線程進行垃圾收集之外,其餘的行爲包括 Serial 收集器可用的所有控制參數(-XX:SurvivorRatio, -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、對象分配原則、回收策略等都與 Serial 收集器完全一致。

運行示意圖:

  • 使用/禁用該收集器的 VM 參數

# 注:JDK 9 取消了 -XX:+UseParNewGC 參數
-XX:+/-UseParNewGC

3.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器是新生代收集器,也是使用標記-複製算法實現的、並行收集的多線程收集器,也稱“吞吐量優先收集器”。

與 ParNew 類似,但關注點不同:

  • CMS 等收集器:儘可能地縮短垃圾收集時用戶線程的停頓時間;

  • Parallel Scavenge 收集器:達到一個可控的吞吐量(Throughput)。

吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間),即運行用戶代碼時間所佔比重。

響應速度快能提升用戶體驗;而吞吐量高則能更高效地利用 CPU 資源,儘快完成程序的計算任務(主要適合在後臺運算而不需要太多交互的任務)。

運行示意圖如下:

  • 虛擬機參數

# 最大垃圾收集停頓時間(毫秒)
-XX:MaxGCPauseMillis


# 設置吞吐量(0~100)
-XX:GCTimeRatio


# 自適應調節策略
-XX:UseAdaptiveSizePolicy

3.4 Serial Old 收集器

Serial 收集器的老年代版本,單線程,使用“標記-整理”算法。主要用於客戶端模式下的 HotSpot 虛擬機。

運行示意圖如下:

3.5 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本,支持多線程併發收集,使用多線程和“標記-整理”算法實現。

運行示意圖如下:

在注重吞吐量或者處理器資源較爲稀缺的場合,都可以考慮 Parallel Scavenge + Parallel Old 收集器的組合。

3.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以「獲取最短回收停頓時間」爲目標的收集器。

它基於“標記-清除”算法實現,運作過程分爲四步:

  1. 初步標記(CMS initial mark):只標記 GC Roots 能直接關聯到的對象,速度很快;

  2. 併發標記(CMS concurrent mark):從 GC Roots 遍歷整個對象圖,耗時較長,但無需停頓用戶線程(可與用戶線程併發執行);

  3. 重新標記(CMS remark):修正併發標記期間,因用戶線程導致標記產生變動的標記記錄;

  4. 併發清除(CMS concurrent sweep):清理刪除標記階段判斷的已經死亡的對象,可與用戶線程併發執行。

運行示意圖如下:

目前很大一部分 Java 應用集中在互聯網網站或者 B/S 系統的服務上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS 收集器非常符合這類應用的需求。

CMS 的主要優缺點

  • 主要優點:發收集、低停頓

  • 主要缺點

    • 對處理器資源非常敏感,降低吞吐量。

    • 無法處理“浮動垃圾”,有可能出現“Concurrent Mode Failure”失敗而導致另一次完全 Stop The World 的 Full GC 的產生。

    • 內存空間碎片問題

浮動垃圾(Floating Garbage):在 CMS 的併發標記和併發清理階段,用戶線程是還在繼續運行的,程序在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分垃圾對象是出現在標記過程以後,CMS 無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉。這部分垃圾就是“浮動垃圾”。

  • 虛擬機參數

# 使用 CMS 收集器
-XX:+UseConcMarkSweepGC


# 老年代使用空間的比例(需根據實際情況權衡)
-XX:CMSInitiatingOccupancyFraction=80


# Full GC 時開啓內存碎片的合併整理
-XX:+UseCMSCompactAtFullCollection

3.7 Garbage First 收集器

Garbage First(G1) 收集器是垃圾收集器發展史上里程碑式的成果,開創了「面向局部收集」的設計思路和「基於 Region」的內存佈局形式。

G1 收集器的定位是「CMS 收集器的替代者和繼承人」。由於實現較複雜,後文另行分析。這裏只做簡單描述:

  • JDK 7 Update 40 時,Oracle 認爲它達到了足夠成熟的商用程度;

  • JDK 8 Update 40 時;G1 收集器提供了併發的類卸載支持,被 Oracle 稱爲“全功能的垃圾收集器(Fully-Featured Garbage Collector)”。

此外,還有一些更爲先進的低延遲收集器,比如 OracleJDK 11 加入的 ZGC,RedHat 公司的 Shenandoah 收集器。另外,還有一個有點“奇葩”的 Epsilon 收集器,等等。

衡量垃圾收集器優劣的指標主要有三個:內存佔用(Footprint)、吞吐量(Throughput)和延遲(Latency)。此三者構成了一個「三元悖論」(類似分佈式系統中的 CAP 原則),難以同時滿足。

4. 小結

本文簡要介紹了一些垃圾收集的相關概念,常用的垃圾收集算法以及經典的垃圾收集器。由於 G1 收集器實現稍複雜,因此後面單獨分析。本文主要內容概括如下圖:

(後臺回覆「垃圾收集」可獲取高清圖片鏈接)


發佈了114 篇原創文章 · 獲贊 40 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章