CMS垃圾收集器深度解析教程
- 1. 前言:為什么需要CMS?
- 2. CMS 工作原理:一場與時間的賽跑
- 2.1. 初始標記(Initial Mark)
- 2.2. 并發標記(Concurrent Mark)
- 2.3. 重新標記(Remark)
- 2.4. 并發清除(Concurrent Sweep)
- 3. CMS 的優勢與劣勢:權衡的藝術
- 3.1. 優勢 (Pros)
- 3.2. 劣勢 (Cons)
- 4. "并發" vs "并行":別再傻傻分不清
- 5. 三色標記法:CMS并發標記的理論基礎
- 6. 寫屏障與增量更新:并發標記的救星
- 6.1. 寫屏障 (Write Barrier)
- 6.2. 增量更新 (Incremental Update)
- 6.3. 卡表 (Card Table):優化重新標記
- 7. CMS 的核心痛點詳解
- 7.1. 并發失敗 (Concurrent Mode Failure)
- 7.2. 內存碎片 (Memory Fragmentation)
- 7.3. 浮動垃圾 (Floating Garbage)
- 8. CMS 適用場景與被取代的原因
- 8.1. 何時考慮使用 CMS?
- 8.2. 為什么 CMS 被廢棄和移除?
- 9. 總結:CMS 的歷史價值
1. 前言:為什么需要CMS?
在Java虛擬機(JVM)的眾多垃圾收集器(Garbage Collector, GC)中,CMS(Concurrent Mark Sweep)占有特殊的歷史地位。雖然它在較新的JDK版本中已被標記為廢棄(Deprecated)并最終移除,但理解CMS的設計理念、工作原理以及優缺點,對于深入掌握JVM內存管理、理解后續更先進的GC(如G1、ZGC)的演進思路,仍然具有非常重要的價值。
CMS的核心目標是什么?
簡單來說,CMS的設計目標是 獲取盡可能短的回收停頓時間。
假如有一個高并發的在線購物網站。在用戶瀏覽商品、下單支付的關鍵時刻,如果JVM因為執行垃圾回收而突然卡頓(Stop The World, STW)幾百毫秒甚至幾秒鐘,那將是災難性的,會導致用戶流失和交易失敗。
CMS正是為了解決這類對 低延遲(Low Latency) 有著苛刻要求的應用場景而誕生的。
它嘗試在應用程序運行的同時,并發地執行大部分垃圾回收工作,從而將原本可能很長的STW時間,分解成幾次非常短暫的STW停頓,極大地改善了應用的響應性能和用戶體驗。
注意: CMS已在JDK 9中被標記為廢棄,并在JDK 14中被移除。本教程旨在幫助理解其原理,而非推薦在新的項目中使用。對于現代Java應用,G1、ZGC或Shenandoah通常是更好的選擇。
2. CMS 工作原理:一場與時間的賽跑
CMS的核心思想是“并發”,即垃圾收集線程與應用程序線程在大部分時間內可以同時運行。為了實現這個目標,CMS將整個垃圾回收過程精心劃分為四個主要階段,以及一些穿插其中的預處理和收尾工作。
核心算法:標記-清除(Mark-Sweep)
首先要明確,CMS是基于 標記-清除 算法實現的。這意味著它在回收后 不會 對內存空間進行整理,這也是后續我們會討論到的“內存碎片”問題的根源。
四個主要階段:
CMS的回收過程主要包含以下四個步驟:
- 初始標記(Initial Mark)
- 并發標記(Concurrent Mark)
- 重新標記(Remark)
- 并發清除(Concurrent Sweep)
其中,初始標記 和 重新標記 這兩個階段需要 “Stop The World”(STW),即暫停所有應用程序線程。而 并發標記 和 并發清除 階段則可以與應用程序線程 并發 執行。
下面我們來詳細解析每個階段的工作:
2.1. 初始標記(Initial Mark)
- 目標: 標記出所有從 GC Roots 直接 關聯到的對象。
- 執行方式: 需要 STW。
- 耗時: 非常短暫。
這個階段就像是在繁忙的高速公路上設置了一個極短的檢查點。交警(GC線程)需要迅速攔下所有車輛(暫停用戶線程),然后快速識別并標記出那些“有明確目的地”(直接被GC Roots引用)的車輛(對象)。GC Roots 包括虛擬機棧中引用的對象、方法區靜態屬性引用的對象、方法區常量引用的對象、本地方法棧JNI引用的對象等。
由于現代JVM的方法區、虛擬機棧等區域通常不會太大,而且只需要標記GC Roots直接關聯的對象,無需深度遍歷,因此這個階段的速度非常快,通常只持續幾十毫料。
理解幫助: 為什么需要STW?因為GC Roots集合是不斷變化的,如果在標記過程中用戶線程還在運行,可能會導致GC Roots增加或減少,從而影響標記的準確性。必須在一個靜止的快照上進行操作。
2.2. 并發標記(Concurrent Mark)
- 目標: 從“初始標記”階段找到的對象出發,遞歸遍歷整個對象引用鏈,標記所有存活的對象。
- 執行方式: 并發執行(GC線程與用戶線程同時運行)。
- 耗時: 較長,是CMS整個回收過程中耗時最長的階段。
這是CMS最核心、最具特色的階段。在初始標記完成后,應用程序線程恢復運行。同時,專門的GC線程開始工作,它們沿著初始標記階段找到的那些“種子對象”,逐步追蹤整個對象引用圖。就像是在高速公路上,普通車輛(用戶線程)在正常行駛,而道路養護車(GC線程)在旁邊車道或者利用夜間進行詳細的道路狀況檢查(標記存活對象)。
理解幫助: 并發標記的挑戰?這個階段最大的挑戰在于,用戶線程仍在運行并可能修改對象的引用關系。比如:
- 原本被標記為存活的對象,在標記過程中被用戶線程斷開了引用,變成了垃圾。
- 原本某個對象沒有被GC線程訪問到(標記為白色),但用戶線程突然讓一個已經被標記過的對象(黑色)引用了它。
這些變化可能會導致標記結果不準確(漏標或錯標)。CMS需要后續的“重新標記”階段來修正這些問題。我們將在后面詳細討論CMS如何解決這些并發問題。
2.3. 重新標記(Remark)
- 目標: 修正“并發標記”期間,因用戶線程修改引用關系而導致標記發生變動的那一部分對象的標記記錄。
- 執行方式: 需要 STW。
- 耗時: 比初始標記長,但遠比并發標記短。
并發標記階段雖然完成了大部分工作,但它是在一個“動態”的環境下進行的。為了確保標記的最終準確性,需要一個短暫的STW階段來進行“查漏補缺”。這就像道路養護車在并發檢查后,再次短暫封閉道路(STW),對那些在檢查期間有車輛進出或新出現問題的路段(被用戶線程修改過引用的對象及相關區域)進行最后的確認。
這個階段主要處理兩類變化:
- 并發標記期間,新加入引用關系的對象。
- 并發標記期間,被移除引用關系的對象。
CMS通過一些聰明的機制(如卡表、增量更新,稍后詳述)來記錄并發標記期間的這些變化,使得重新標記階段不必重新掃描整個堆,而只需要關注那些“有變動”的小范圍區域,從而有效控制了STW的時間。
理解幫助: 為什么重新標記比初始標記慢?因為重新標記需要處理整個并發標記階段積累的變化信息,掃描范圍比初始標記(只看GC Roots直連對象)要大。但相比于重新掃描整個堆,它的效率已經大大提高了。
2.4. 并發清除(Concurrent Sweep)
- 目標: 清除在標記階段被判定為“已死亡”(未被標記)的對象,釋放它們占用的內存空間。
- 執行方式: 并發執行(GC線程與用戶線程同時運行)。
- 耗時: 較長,取決于垃圾對象的數量和分布。
在重新標記階段確保了所有存活對象都被正確標記后,應用程序線程再次恢復運行。GC線程則開始最后的清理工作。它們遍歷堆內存,將那些沒有被標記(白色)的對象識別為垃圾,并將它們占用的內存回收,加入到空閑內存列表(Free List)中,以備后續分配新對象使用。
這個階段也是并發的,用戶線程可以正常訪問那些已被標記為存活的對象,同時GC線程在后臺默默地回收垃圾。
整體流程回顧:
通過將耗時最長的標記和清除階段設計為并發執行,CMS成功地將大部分GC工作與應用程序運行重疊,從而顯著降低了整體的STW時間,實現了其低延遲的目標。
3. CMS 的優勢與劣勢:權衡的藝術
沒有哪種垃圾收集器是完美的,CMS也不例外。它通過犧牲一些其他方面的性能來換取低延遲的特性。理解其優缺點對于判斷它是否適合特定應用場景至關重要。
3.1. 優勢 (Pros)
- 并發收集 (Concurrent Collection): 這是CMS最核心的優勢。標記和清除兩個主要耗時階段可以與用戶線程并發執行,避免了長時間的應用停頓。
- 低延遲 (Low Latency): 由于STW時間被顯著縮短(主要由初始標記和重新標記貢獻,通常很短),CMS非常適合對響應時間有嚴格要求的應用,例如:
- 網站服務器(如Tomcat, Jetty)
- API網關
- 實時交易系統
- 交互式桌面應用
3.2. 劣勢 (Cons)
CMS的并發特性和基于標記-清除算法的設計,也帶來了幾個不容忽視的缺點:
-
對CPU資源敏感 (CPU Intensive):
- 原因: 并發階段,GC線程需要與用戶線程一起搶占CPU資源。默認情況下,CMS啟動的回收線程數是
(CPU核心數 + 3) / 4
。當CPU核心數較少時(例如少于4個),GC線程可能會占用相當一部分(甚至超過25%)的CPU運算能力,導致用戶程序的執行速度變慢,總吞吐量下降。 - 理解幫助: 想象一下,原本專心開車的司機(用戶線程)旁邊多了一個不斷指手畫腳、分散注意力的乘客(GC線程),雖然車沒有停,但整體開車效率降低了。吞吐量指的是單位時間內用戶代碼運行時間占總時間的比例。CMS為了低延遲犧牲了吞吐量。
- 原因: 并發階段,GC線程需要與用戶線程一起搶占CPU資源。默認情況下,CMS啟動的回收線程數是
-
無法處理“浮動垃圾” (Floating Garbage):
- 原因: 在并發清除階段,用戶線程還在運行,并且可能會產生新的垃圾對象。然而,這些新產生的垃圾是在標記階段之后出現的,CMS本次無法識別它們,只能等到下一次GC周期才能回收。這些在本輪GC中無法回收、但實際上已經是垃圾的對象,就被稱為“浮動垃圾”。
- 影響:
- 降低了內存利用率,部分內存被無效占用。
- 需要預留一部分堆空間來容納這些浮動垃圾以及并發運行時用戶線程可能繼續分配的新對象。這也是為什么CMS不能等到老年代幾乎完全滿了再啟動回收,而是需要在一個較低的閾值(默認68%或92%,取決于JDK版本和配置)就開始回收。參數
-XX:CMSInitiatingOccupancyFraction
控制這個閾值。
- 理解幫助: 清潔工(GC線程)正在打掃房間(并發清除),但主人(用戶線程)還在不斷扔新的垃圾。清潔工這次只能清理之前標記好的垃圾,新扔的只能等下次再說了。
-
產生內存碎片 (Memory Fragmentation):
- 原因: CMS基于 標記-清除 算法。該算法只標記、清除,不移動對象。回收后,內存空間會變得不連續,存在大量小的空閑塊,這就是內存碎片。
- 影響:
- 當應用程序需要分配一個較大的對象時,即使總的空閑內存足夠,也可能因為找不到一塊足夠大的 連續 空間而分配失敗。
- 內存碎片過多最終會提前觸發一次 Full GC(通常是使用 Serial Old 或 Parallel Old 進行帶壓縮的回收),導致更長時間的STW。
- 緩解措施: CMS提供了兩個參數來控制碎片整理:
-XX:+UseCMSCompactAtFullCollection
(默認開啟): 在不得不進行Full GC時,開啟內存整理(壓縮)。-XX:CMSFullGCsBeforeCompaction
(默認值為0): 設置在執行多少次不壓縮的Full GC之后,進行一次帶壓縮的Full GC。值為0表示每次Full GC都進行壓縮。
- 理解幫助: 圖書館管理員(GC)把借走的書(垃圾對象)下架了,但書架上留下了很多零散的空位(碎片)。當需要放一本大部頭(大對象)時,雖然總空位數很多,但找不到一個足夠寬的連續空位。管理員可以選擇在某個時候(Full GC)把所有書重新排列整齊(壓縮),但這需要閉館一段時間(STW)。
-
并發失敗風險 (Concurrent Mode Failure):
- 原因: 如果在CMS并發標記或并發清除的過程中,老年代的內存增長速度過快(比如用戶線程分配大對象、大量對象從年輕代晉升),導致預留的空間不足以容納新對象,CMS就會發生“并發失敗”。
- 后果: 一旦發生并發失敗,JVM會凍結用戶線程(STW),然后啟用后備的、單線程的、帶壓縮的 Serial Old 收集器來重新進行整個老年代的垃圾回收。這會導致一次非常漫長的STW,比CMS正常運行時的短暫停頓要長得多,嚴重影響應用性能。
- 觸發條件:
- 老年代空間不足以容納從Young GC晉升的對象。
- 并發過程中分配大對象,老年代沒有足夠連續空間。
CMSInitiatingOccupancyFraction
設置過高,預留空間不足。- 回收速度跟不上內存分配速度。
- 理解幫助: 商場(老年代)一邊營業(用戶線程運行)一邊打掃(CMS并發回收)。但突然涌入大量顧客(對象晉升/大對象分配),或者垃圾產生速度太快,清潔工來不及清理,商場空間不夠用了。這時不得不緊急關門謝客(STW),請來效率較低但能徹底整理的保潔隊(Serial Old)進行大掃除。
總結:
特性 | 優勢 | 劣勢 |
---|---|---|
核心 | 并發收集、低延遲 | 對CPU敏感、吞吐量降低 |
算法 | (無直接優勢) | 標記-清除導致內存碎片 |
并發執行 | 減少STW時間 | 無法處理浮動垃圾、需要預留空間、可能發生并發失敗(Concurrent Mode Failure) |
適用場景 | 對響應時間要求高的應用(Web服務、API等) | CPU資源緊張、內存分配率極高、無法容忍內存碎片的場景 |
選擇CMS,就是選擇用CPU資源、部分內存空間和一定的復雜性來換取應用響應時間的提升。
4. “并發” vs “并行”:別再傻傻分不清
在垃圾收集的語境下,“并發”(Concurrent)和“并行”(Parallel)是兩個非常重要且容易混淆的概念。理解它們的區別有助于我們把握不同GC的設計哲學。
-
并行 (Parallel):
- 定義: 指 多條垃圾收集器線程 同時工作。
- 關注點: 縮短 垃圾收集本身 的時間,提高GC的 效率。
- 用戶線程狀態: 在并行GC執行期間,用戶線程仍然處于等待狀態(STW)。
- 例子: Parallel Scavenge(新生代)、Parallel Old(老年代)。它們在進行垃圾回收時,會啟動多個GC線程協同工作,以加快回收速度,但整個過程應用是暫停的。
- 目標: 提高 吞吐量 (Throughput)。即讓用戶代碼執行時間占總時間的比例最大化。適合后臺計算、數據處理等不需要實時響應的任務。
- 類比: 多個人(多GC線程)一起快速打掃一個房間(GC過程),打掃期間房間里不允許有人(用戶線程STW)。
-
并發 (Concurrent):
- 定義: 指 垃圾收集器線程 與 用戶線程 同時執行(不一定是嚴格的同時,可能交替執行)。
- 關注點: 減少 應用程序的停頓時間。
- 用戶線程狀態: 在并發GC執行期間的大部分時間里,用戶線程可以繼續運行。
- 例子: CMS、G1(部分階段)、ZGC、Shenandoah。它們的核心特點是將耗時操作分散到與用戶線程并發執行的階段。
- 目標: 降低 延遲 (Latency)。即縮短因GC引起的STW時間。適合交互式應用、Web服務等對響應時間敏感的場景。
- 類比: 一個人(GC線程)在房間有人活動(用戶線程運行)的情況下進行打掃,盡量不影響房間里的人。
CMS是哪一種?
CMS的名字 Concurrent Mark Sweep 就明確告訴我們,它是一個 并發 收集器。它的主要工作(并發標記、并發清除)是與用戶線程并發執行的。
需要注意:
- CMS的初始標記和重新標記階段雖然需要STW,但也可以是 并行 的。可以通過
-XX:+CMSParallelInitialMarkEnabled
和-XX:+CMSParallelRemarkEnabled
(后者通常默認開啟) 來讓這兩個STW階段使用多線程執行,進一步縮短停頓時間。 - 現代的垃圾收集器(如G1、ZGC)往往 同時利用了并行和并發 的優勢。它們既能在STW階段并行執行,也能在大部分時間里與用戶線程并發執行。
總結:
特性 | 并行 (Parallel) | 并發 (Concurrent) |
---|---|---|
線程關系 | 多個 GC線程 協同工作 | GC線程 與 用戶線程 同時運行 |
用戶線程 | STW (暫停) | 大部分時間 Running (運行) |
目標 | 高吞吐量 (Throughput) | 低延遲 (Latency) |
關注 | 縮短 GC 時間 | 縮短 應用停頓時間 |
代表 | Parallel Scavenge, Parallel Old | CMS, G1, ZGC, Shenandoah |
核心優勢 | GC效率高 | 應用停頓少 |
核心代價 | STW時間可能較長 | 可能犧牲吞吐量、增加CPU開銷、實現復雜 |
5. 三色標記法:CMS并發標記的理論基礎
為了在用戶線程并發修改對象引用的同時,正確地標記出所有存活對象,CMS(以及G1、ZGC等并發或增量GC)采用了 三色標記(Tri-color Marking) 算法作為理論基礎。
三色標記法將垃圾收集器在標記過程中遇到的對象,根據其訪問狀態,劃分為三種顏色:
-
白色 (White):
- 含義: 對象尚未被垃圾收集器訪問過。
- 初始狀態: 在標記開始時,所有對象都是白色的。
- 結束狀態: 在標記結束后,如果一個對象仍然是白色,意味著它從GC Roots不可達,是垃圾,將被回收。
-
灰色 (Gray):
- 含義: 對象已經被垃圾收集器訪問過,但它的直接引用還沒有全部處理完畢(即它的“鄰居”還沒有全部被掃描)。
- 狀態變化: 當一個白色對象被GC Roots直接引用或者被灰色對象引用時,它會變成灰色。當一個灰色對象的所有直接引用都被掃描處理后,它會變成黑色。
- 作用: 灰色對象是標記過程中的中間狀態,代表著“待處理”的任務列表。
-
黑色 (Black):
- 含義: 對象已經被垃圾收集器訪問過,并且它的所有直接引用(Field)都已經被掃描處理完畢。
- 保證: 黑色對象代表它本身是存活的,并且從它出發能直接到達的對象也已經被正確處理了(要么變成灰色待處理,要么已經是黑色)。
標記過程:
- 初始: 所有對象都是白色。
- 根掃描: 將所有GC Roots直接引用的對象標記為灰色,放入待處理集合。
- 遍歷:
- 從灰色集合中取出一個灰色對象。
- 遍歷該灰色對象的所有直接引用:
- 如果引用指向一個白色對象,將該白色對象標記為灰色,放入待處理集合。
- 如果引用指向灰色或黑色對象,不做任何處理(因為它們已經被訪問或正在處理中)。
- 將當前處理的灰色對象標記為黑色。
- 重復: 重復步驟3,直到灰色集合為空。
- 結束: 此時,所有仍然是白色的對象就是不可達的垃圾,可以被回收。所有黑色對象都是存活對象。
可視化理解:
并發執行帶來的問題:
如果三色標記法在嚴格的STW下單線程執行,是完全正確的。但CMS的并發標記階段,用戶線程和GC線程同時運行,這就可能破壞三色標記法正常工作的前提,導致兩種嚴重錯誤:
-
對象消失 (Object Loss) / 漏標 (Missing Mark):
- 場景: 一個黑色對象 A,原本引用著一個白色對象 B。在并發標記過程中:
- 用戶線程 斷開 了 A 到 B 的引用 (
A.ref = null;
)。 - 同時,用戶線程讓一個 灰色 對象 C 新增 了到 B 的引用 (
C.ref = B;
)。 - 但是,GC線程此時已經 掃描完 了 A(A已經是黑色),并且 還沒來得及 掃描 C(C還是灰色)。
- 用戶線程 斷開 了 A 到 B 的引用 (
- 后果: 當GC線程后續掃描完 C 時,它可能不會再回頭看 B(具體取決于實現策略)。最終,對象 B 雖然是存活的(被 C 引用),但沒有被任何黑色或灰色對象直接引用掃描到,它仍然是 白色 的,最終被錯誤地當成垃圾回收了。這是 絕對不能接受 的錯誤!
- 發生的條件(同時滿足):
- 賦值器(用戶線程)插入了一條或多條從黑色對象到白色對象的新引用。
- 賦值器刪除了所有從灰色對象到該白色對象的直接或間接引用。
- 場景: 一個黑色對象 A,原本引用著一個白色對象 B。在并發標記過程中:
-
浮動垃圾 (Floating Garbage):
- 場景: 一個已經被標記為灰色或黑色的對象,在并發標記或并發清除階段,被用戶線程斷開了所有引用,變成了垃圾。
- 后果: 由于它已經被標記為“存活”(非白色),本輪GC不會回收它。它成為了“浮動垃圾”,只能等待下一輪GC。這雖然 不影響正確性,但會 降低內存利用率。
CMS必須解決“對象消失”這個致命問題,同時盡量減少“浮動垃圾”。它主要通過 寫屏障(Write Barrier) 和 增量更新(Incremental Update) 技術來實現這一點。
6. 寫屏障與增量更新:并發標記的救星
為了解決三色標記在并發環境下可能出現的“對象消失”問題,CMS 引入了 寫屏障(Write Barrier) 和 增量更新(Incremental Update) 機制。
6.1. 寫屏障 (Write Barrier)
什么是寫屏障?
寫屏障 不是 硬件層面的內存屏障(Memory Barrier),而是JVM層面的一種 代碼注入技術。當JVM在編譯Java代碼時,如果發現代碼執行的是 引用類型字段的賦值操作(例如 obj.field = someOtherObj;
),它會在這個賦值操作的 前后 插入一些額外的、特殊的處理代碼。這些被插入的代碼就稱為“寫屏障”。
寫屏障的作用?
它的核心作用是 攔截或記錄 用戶線程對對象引用關系的修改。就像在每個對象引用賦值的地方安插了一個“監視器”,一旦發生修改,就觸發特定的動作,通知GC系統。
寫屏障的種類:
- 寫前屏障 (Pre-Write Barrier): 在 賦值發生之前 執行。它通常關注的是“即將失去的引用”,比如記錄下
obj.field
原本指向的對象。 - 寫后屏障 (Post-Write Barrier): 在 賦值發生之后 執行。它通常關注的是“新建立的引用”,比如記錄下
obj.field
現在指向了someOtherObj
。
CMS的選擇:
不同的并發GC策略會使用不同的寫屏障組合。CMS為了解決漏標問題,主要依賴 寫后屏障 配合 增量更新 策略。
偽代碼示例(寫后屏障):
// 原始代碼
// obj.field = newValue;// JVM 加入寫屏障后的偽代碼 (Post-Write Barrier)
void setField(Object obj, Field field, Object newValue) {// <--- 寫屏障開始 --->// 記錄下引用變化的信息,供GC后續處理// 例如,如果 obj 是黑色,newValue 是白色,// 可能需要將 obj 重新標記為灰色,或者記錄下這個 (obj, newValue) 的關系postWriteBarrier(obj, field, newValue);// <--- 寫屏障結束 --->// 執行原始的賦值操作obj.field = newValue;
}// 寫屏障的具體實現 (偽代碼)
void postWriteBarrier(Object obj, Field field, Object newValue) {// 判斷是否滿足特定條件 (例如:破壞了三色標記的不變性)if (isBlack(obj) && isWhite(newValue)) {// 執行增量更新邏輯incrementalUpdate(obj, newValue);}
}
6.2. 增量更新 (Incremental Update)
增量更新是CMS用來 解決漏標(對象消失) 問題所采用的具體策略。它關注的是 黑色對象指向白色對象 這種情況的發生。
核心思想:
當一個黑色對象 A 新增了對一個白色對象 B 的引用時 (A.ref = B;
),為了防止 B 被漏標,增量更新策略會通過寫屏障捕捉到這個事件,并采取措施 記錄 下這個變化。
具體做法:
當寫屏障檢測到 isBlack(A) && isWhite(B)
的情況時,它 不會 立即把 B 變成灰色(因為并發訪問灰色集合也可能存在問題),而是將 A 重新標記回灰色,或者更常見的是,將這個 新增的引用關系 (A, B) 記錄在一個 專門的、需要額外掃描的列表 中。
為什么叫“增量”更新?
因為它只關注并發標記過程中 新增 的黑色到白色的引用關系。它假設在標記開始時建立的對象圖快照是基礎,然后只處理后續發生的“增量”變化。
重新標記階段的作用:
在 重新標記(Remark) 這個STW階段,GC線程會:
- 暫停所有用戶線程。
- 處理在并發標記期間,由增量更新機制記錄下來的所有 引用變化信息(比如那個專門的列表)。
- 從這些記錄出發,重新掃描受影響的對象,確保所有可達對象最終都被正確標記(變成黑色)。
偽代碼示例(增量更新邏輯):
// 增量更新記錄列表
List<ReferenceChange> incrementalUpdates = new CopyOnWriteArrayList<>(); // 線程安全列表// 寫屏障中的增量更新實現
void incrementalUpdate(Object blackObj, Object whiteObj) {// 記錄下這個新增的引用關系// 注意:這里只是示意,實際實現會更復雜和高效incrementalUpdates.add(new ReferenceChange(blackObj, whiteObj));// 或者,更簡單的做法可能是將 blackObj 重新標記為灰色// markGray(blackObj); // 但CMS主要采用記錄方式
}// 重新標記階段的處理邏輯 (偽代碼)
void remarkPhase() {stopTheWorld(); // STW// 處理增量更新記錄for (ReferenceChange change : incrementalUpdates) {Object source = change.getSource();Object target = change.getTarget();if (isBlack(source) && isWhite(target)) {// 從 source 開始重新掃描,確保 target 及其可達對象被標記scanObject(source); // 或者直接標記 target 為灰色 scanObject(target)}}incrementalUpdates.clear(); // 清空記錄// ... 其他重新標記邏輯 (如處理卡表) ...resumeTheWorld(); // 恢復用戶線程
}
與SATB的區別(簡單提一下):
G1垃圾收集器采用的是另一種叫做 SATB(Snapshot-At-The-Beginning) 的策略。SATB關注的是 刪除 的引用。它通過 寫前屏障 記錄下那些 即將被刪除 的從灰色/黑色對象到白色對象的引用。即使這個引用后來真的被用戶線程刪除了,SATB也會認為這個白色對象在標記開始時的那個“快照”中是存活的,從而在本輪GC中保留它。SATB能更好地處理浮動垃圾,但實現也更復雜。
總結: CMS通過寫后屏障捕捉引用賦值操作,利用增量更新策略記錄下并發標記期間黑色對象新增對白色對象的引用,最后在重新標記STW階段統一處理這些記錄,從而保證了并發標記的正確性,防止了“對象消失”的致命錯誤。
6.3. 卡表 (Card Table):優化重新標記
雖然增量更新解決了正確性問題,但如果在重新標記階段需要掃描所有記錄下來的對象以及它們引用的對象,開銷仍然可能很大。為了進一步 優化重新標記階段的掃描范圍,CMS(以及很多現代GC)引入了 卡表(Card Table) 機制。
換句話說:
卡表(Card Table)是實現增量更新(Incremental Update)策略的一種高效的技術手段,它優化了“記錄修改”這個環節。
增量更新的目標: 是為了解決并發標記中“黑色對象引用了新的白色對象,但GC沒發現”的問題。它要求GC必須記錄下那些在并發標記期間被修改過的、可能指向新對象的“黑色對象”(或更簡單地說,記錄下發生過引用寫入的區域)。
如何記錄?
- 最精確但可能最慢的方式: 記錄下每一個發生這種“黑指向白”賦值操作的對象地址。這需要在寫屏障里做很多判斷和記錄,開銷可能很大。
- 卡表的方式(更優化的方式): 不記錄精確的對象,而是記錄一個粗粒度的區域(卡頁)。只要卡頁內的任何一個對象的引用字段被修改了(通常簡化為只要有引用寫入就標記),就把這個卡頁標記為“臟”。
卡表的優化體現在哪里? - 寫屏障開銷小: 標記一個字節(卡表項)非常快,比精確記錄對象和判斷顏色/代等復雜邏輯要高效得多,對應用程序的吞吐量影響更小。
- 空間效率高: 只需要 HeapSize / CardSize 的額外空間,比存儲大量對象指針要節省得多。
所以,不是說先有了一個“增量更新”的抽象算法,然后卡表來優化它。
而是:
為了實現“增量更新”這個策略(即在并發標記后重新檢查被修改過的區域),需要一種記錄修改的方法。
卡表提供了一種非常高效、低開銷的記錄方法。 它用空間換時間(可能標記了一些不需要的區域),但極大地降低了在應用程序運行時(寫屏障觸發時)的性能損耗。
什么是卡表?
卡表是一個 位圖(Bitmap) 或 字節數組,它將整個 堆內存(尤其是老年代)劃分成固定大小的 卡頁(Card Page)。卡頁的大小通常是 2 的冪次方,例如 512 字節。卡表中的每一個元素(一個比特位或一個字節)就對應堆內存中的一個卡頁。
卡表的作用?
卡表用來標記哪些卡頁可能包含了 指向其他區域(尤其是新生代指向老年代,或者在CMS并發標記中,老年代內部)的引用,或者更簡單地說,標記哪些卡頁 “變臟”(Dirty) 了。
寫屏障與卡表的聯動:
當寫屏障檢測到一次 跨代引用(新生代對象引用老年代對象,這在Young GC時很重要)或者在CMS并發標記中檢測到 老年代內部引用發生變化 時,它除了執行增量更新邏輯(如果需要),還會做一個非常快速的操作:將引用發生地所在的那個卡頁,在卡表中對應的標記位/字節,設置為“臟”狀態。
偽代碼示例(寫屏障更新卡表):
// 假設 Card Table 是一個字節數組
byte[] cardTable = ...;
final int CARD_SHIFT = 9; // 卡頁大小為 2^9 = 512 字節
final byte DIRTY_CARD = 0; // 臟標記// JVM 加入寫屏障后的偽代碼 (Post-Write Barrier with Card Table)
void setField(Object obj, Field field, Object newValue) {// ... 增量更新邏輯 ...postWriteBarrier(obj, field, newValue);// <--- 更新卡表 --->// 計算 obj 對象所在的卡頁索引long objAddress = getAddress(obj);int cardIndex = (int)(objAddress >>> CARD_SHIFT);// 將對應的卡表項標記為臟// 這里用字節數組示例,實際可能是位操作if (cardTable[cardIndex] != DIRTY_CARD) {cardTable[cardIndex] = DIRTY_CARD;}// <--- 卡表更新結束 --->// 執行原始的賦值操作obj.field = newValue;
}
重新標記階段如何利用卡表?
在 重新標記(Remark) STW階段,GC線程不再需要掃描整個老年代來查找可能存在的引用變化。它們只需要:
- 掃描卡表,找到所有被標記為“臟”的卡頁。
- 只 掃描那些“臟”卡頁內的對象,查找它們是否有指向白色對象的引用,并進行相應的標記處理(結合增量更新記錄的信息)。
這極大地縮小了重新標記階段需要掃描的范圍,從而顯著縮短了STW時間。
總結: 卡表通過空間換時間的方式,用一個額外的位圖/字節數組記錄了內存區域的“臟”狀態。寫屏障在修改引用時快速標記對應的卡頁,使得重新標記階段只需掃描臟頁,大大提高了效率。
CMS并發標記的完整保障機制:
三色標記(理論基礎)+ 寫屏障(監測變化)+ 增量更新(處理新增引用,保正確性)+ 卡表(記錄臟區,提效率)= CMS并發標記的組合拳。
7. CMS 的核心痛點詳解
我們在前面提到了CMS的幾個主要缺點,現在我們來更深入地探討它們,特別是并發失敗、內存碎片和浮動垃圾這三大痛點。
7.1. 并發失敗 (Concurrent Mode Failure)
這是使用CMS時最需要關注和盡量避免的問題,因為它會導致長時間的STW。
復習:為什么會發生?
CMS的并發回收(標記、清除)需要時間。如果在GC線程完成回收之前,用戶線程持續快速地分配內存(包括Young GC晉升的對象和直接在老年代分配的大對象),導致老年代空間不足以容納新的對象,就會觸發并發失敗。本質上是 回收速度跟不上分配速度。
導致并發失敗的具體場景:
- Young GC 晉升失敗: Young GC后,存活對象需要晉升到老年代,但此時老年代剩余空間(即使CMS正在并發回收)不足以容納這些對象。
- 并發分配大對象失敗: 用戶線程嘗試在老年代直接分配一個大對象,但由于內存碎片或者并發回收尚未釋放足夠連續空間,導致分配失敗。
- 預留空間不足:
-XX:CMSInitiatingOccupancyFraction
設置過高,或者應用內存增長模式突變,導致CMS啟動回收時,剩余空間不足以支撐到并發回收完成。
后果:
JVM會停止所有用戶線程(STW),然后調用 Serial Old 收集器(一個單線程、標記-整理算法的收集器)來對整個老年代進行垃圾回收,包括內存整理。這個過程非常緩慢,STW時間可能長達數秒甚至更久。
如何調優避免?
調優的核心思路是:讓CMS盡早開始回收,或者讓回收過程更快,或者減少內存分配壓力。
- 降低觸發閾值: 調低
-XX:CMSInitiatingOccupancyFraction=N
的值(N是百分比,例如60-80),讓CMS在老年代占用率達到N%時就提前開始回收,預留更多的時間和空間。這是 最常用 的調優手段。需要根據應用的內存增長速率和GC日志來找到一個合適的值。太低會增加GC頻率,太高則容易并發失敗。 - 增加并發回收線程數: 通過
-XX:ConcGCThreads=N
適當增加并發標記和并發清除的線程數(如果CPU資源允許),加快回收速度。但線程過多也會增加CPU開銷。 - 減少內存碎片:
- 開啟Full GC時的壓縮:
-XX:+UseCMSCompactAtFullCollection
(默認開啟)。 - 調整壓縮頻率:
-XX:CMSFullGCsBeforeCompaction=N
。如果并發失敗頻繁且主要是由碎片引起,可以考慮設置為0,讓每次后備的Full GC都進行壓縮,但這會增加Full GC的STW時間。更理想的是通過其他方式減少大對象的產生或優化對象生命周期。
- 開啟Full GC時的壓縮:
- 優化應用內存使用:
- 減少大對象的分配。
- 優化對象生命周期,避免大量對象集中晉升到老年代。
- 檢查是否存在內存泄漏。
- 增大老年代空間: 如果物理內存允許,直接增大老年代的總大小 (
-Xmx
,-Xms
配合調整新生代比例-XX:NewRatio
或大小-Xmn
),可以給CMS更多緩沖空間。 - 在Remark前觸發Young GC: 使用
-XX:+CMSScavengeBeforeRemark
。在重新標記(STW)之前,先進行一次Young GC。這樣做的好處是:- 減少老年代的對象數量(一些短期對象被回收)。
- 減少重新標記階段需要掃描的新生代對象(因為引用關系更少了)。
- 可以略微縮短Remark的STW時間,并可能減少并發階段的引用變化。
監控: 密切關注GC日志,查找 “Concurrent Mode Failure” 或 “promotion failed” 關鍵字,分析失敗前后的內存使用情況和GC活動。
7.2. 內存碎片 (Memory Fragmentation)
這是CMS采用標記-清除算法帶來的先天不足。
復習:為什么會產生?
標記-清除算法只回收死亡對象占用的空間,但不移動存活對象。回收后,內存中會留下許多不連續的小塊空閑區域。
影響:
- 大對象分配困難: 最直接的影響是,當需要分配一個較大的連續內存塊時(比如一個大數組或大對象),即使總的空閑內存很多,也可能找不到足夠大的連續空間,導致分配失敗。
- 提前觸發Full GC: 當碎片嚴重到無法滿足正常分配(尤其是大對象分配)時,即使老年代整體占用率不高,JVM也可能被迫觸發一次帶壓縮的Full GC(使用Serial Old或配置了壓縮的CMS Full GC),導致長時間STW。
CMS的應對措施:
CMS本身 不直接 解決并發清除階段的碎片問題。它依賴于:
- Full GC時的整理: 通過
-XX:+UseCMSCompactAtFullCollection
和-XX:CMSFullGCsBeforeCompaction
參數,在發生Full GC(包括并發失敗后的Full GC)時進行內存整理。但這本身就是一種“亡羊補牢”,且會帶來STW。 - 寄希望于分配策略: JVM的內存分配器(如TLAB - Thread Local Allocation Buffer)會盡量在現有的小塊碎片中進行分配,但這無法根本解決大對象分配問題。
根本性解決:
真正能較好解決碎片問題的GC算法是 標記-復制(Mark-Copy) 和 標記-整理(Mark-Compact)。這也是為什么后續的G1、ZGC等收集器都采用了不同的策略(如G1的分區復制、ZGC的指針染色與重定位)來避免或處理碎片問題。
7.3. 浮動垃圾 (Floating Garbage)
復習:為什么會產生?
在CMS并發標記階段之后、并發清除階段完成之前,如果用戶線程使得某個原本被標記為存活的對象變成了垃圾(斷開了所有引用),CMS在本輪GC中無法回收它。
影響:
- 內存利用率下降: 這部分垃圾對象繼續占用內存,直到下一次GC才能被回收。
- 需要預留空間: CMS需要預留一部分空間來容納這些潛在的浮動垃圾,進一步增加了
-XX:CMSInitiatingOccupancyFraction
提前觸發回收的必要性。 - 可能增加GC頻率: 如果浮動垃圾積累過多,可能導致老年代更快達到觸發閾值。
能否解決?
CMS的增量更新機制 無法 解決浮動垃圾問題(它主要解決漏標)。SATB策略(如G1使用)能更好地處理浮動垃圾(因為它基于快照,快照中存活的對象即使后來變垃圾了也會保留到本輪結束),但CMS沒有采用。
對于CMS來說,浮動垃圾是其并發設計所必須接受的一個副作用。只能通過合理配置 -XX:CMSInitiatingOccupancyFraction
來為其預留足夠的空間。
總結: CMS的這三大痛點——并發失敗的風險、內存碎片的積累、浮動垃圾的存在——是其設計上的固有局限。現代GC如G1、ZGC等都在嘗試用更先進的技術來克服這些問題。
8. CMS 適用場景與被取代的原因
8.1. 何時考慮使用 CMS?
在CMS還盛行的年代(大約在JDK 6、7、8時期),判斷是否使用CMS主要基于以下考量:
- 應用對延遲的敏感度極高: 這是選擇CMS的最主要原因。如果你的應用無法容忍幾十毫秒以上的STW停頓,例如:
- 需要快速響應用戶請求的Web服務器。
- 實時交易系統、金融報價系統。
- DNS服務器、電信網關。
- 對交互體驗要求高的桌面應用。
- 服務器CPU資源充足: CMS并發階段需要額外的CPU資源。如果服務器是多核(例如4核及以上),能夠承受GC線程帶來的額外開銷而不至于嚴重影響應用吞吐量。
- 內存分配速率不是極端快: 如果應用內存分配速率非常驚人,導致CMS回收速度跟不上,頻繁觸發Concurrent Mode Failure,那么CMS可能不是好的選擇。
- 對內存碎片有一定容忍度: 如果應用主要是分配中小對象,或者大對象分配不頻繁,或者能夠接受偶爾由碎片整理帶來的Full GC停頓,那么碎片問題可能不構成主要障礙。
- 堆內存大小適中: CMS在處理超大堆(幾十GB甚至上百GB)時,其并發標記和清除時間會相應變長,重新標記階段的STW也可能變得不可忽視。雖然可以通過調優緩解,但對于非常大的堆,G1通常表現更好。
簡單來說: 如果我們的首要目標是 低延遲,并且愿意犧牲一定的 吞吐量 和 內存空間,同時有足夠的 CPU資源,那么CMS在當時是一個不錯的選擇。
8.2. 為什么 CMS 被廢棄和移除?
隨著技術的發展和更優秀替代品的出現,CMS逐漸暴露出的缺點和維護成本使其最終被淘汰。主要原因包括:
- 標記-清除算法的固有缺陷(內存碎片): 內存碎片問題是CMS的硬傷,長期運行可能導致性能瓶頸或頻繁的Full GC,需要復雜的調優和碎片整理策略,而碎片整理本身又會帶來STW。
- 并發失敗問題難以根治: Concurrent Mode Failure的風險始終存在,一旦發生,其帶來的長STW懲罰非常嚴重,使得CMS的低延遲優勢變得不穩定。調優復雜且依賴經驗。
- 對CPU資源消耗較大: 在CPU資源本就緊張的場景下,CMS對吞吐量的影響比較明顯。
- 浮動垃圾導致內存利用率低: 需要預留較多內存,實際可用堆空間小于預期。
- 實現復雜,維護成本高: CMS內部涉及大量復雜的并發控制和同步機制,對于JVM開發團隊來說,維護和持續優化它的成本很高。
- G1等更優秀的替代品出現:
- G1 (Garbage-First) 收集器 的出現是CMS被取代的關鍵因素。G1的設計目標之一就是取代CMS,它:
- 采用了區域化(Region) 的堆內存布局,化整為零。
- 引入了可預測的停頓時間模型 (
-XX:MaxGCPauseMillis
),用戶可以設定期望的最大停頓時間。 - 使用了標記-復制(在Young GC和Mixed GC的部分階段)和標記-整理(在Full GC時)算法,從根本上解決了內存碎片問題。
- 通過優先回收價值最高(垃圾最多)的區域 (Garbage First) 來提高回收效率。
- 兼具并發和并行特性。
- 后續的 ZGC 和 Shenandoah 更是將低延遲做到了極致(目標停頓在毫秒甚至亞毫秒級),雖然它們的應用場景和成熟度還在發展中。
- G1 (Garbage-First) 收集器 的出現是CMS被取代的關鍵因素。G1的設計目標之一就是取代CMS,它:
結論: G1在解決了CMS核心痛點(碎片、并發失敗可控性、可預測停頓)的同時,提供了相當不錯的性能表現,并且配置相對更簡單,成為了JDK 9及以后版本的默認垃圾收集器。這使得CMS的歷史使命基本完成,被廢棄和移除也就順理成章了。
9. 總結:CMS 的歷史價值
CMS作為第一款真正意義上的 并發 垃圾收集器,在JVM發展史上具有里程碑式的意義。它首次將“低延遲”作為核心設計目標,并通過創新的并發標記和并發清除技術,極大地改善了對響應時間敏感的應用的用戶體驗。
雖然CMS因為其固有的設計缺陷(內存碎片、并發失敗風險、CPU消耗)以及更優秀的替代者(G1、ZGC等)的出現而被逐漸淘汰,但學習和理解CMS的工作原理仍然非常有價值:
- 理解GC的演進: CMS是理解G1、ZGC等現代并發GC設計思想的重要基礎。很多現代GC的技術,如三色標記、寫屏障、卡表等,都是在CMS或更早的GC探索中逐步發展和完善起來的。
- 深入JVM內存管理: 掌握CMS有助于更深入地理解JVM如何管理內存、如何平衡吞吐量與延遲、并發GC面臨的挑戰以及解決這些挑戰的技術手段。
- 遺留系統維護: 在一些尚未升級JDK版本的舊系統中,可能仍然在使用CMS。理解其原理有助于對這些系統進行問題排查和性能調優。
CMS就像一位開創了新道路但自身并非完美的先行人。它證明了并發垃圾收集的可行性,為后續更先進、更完善的垃圾收集器的誕生鋪平了道路。