☰
深入拆解JVM三色标记法:CMS与G1并发标记底层原理
2026/10/4 6:23:49 网站建设 项目流程

很多后端同学一谈JVM调优就头大,尤其是看到CMS、G1的GC日志里那些concurrent-mark、remarking阶段,完全不知道背后到底发生了什么。今天我想拿“并发垃圾回收器”话题里最核心的一块——“三色标记法”的底层实现原理,好好拆一拆。三色标记法不是什么高深的论文理论,它是CMS、G1这类并发回收器在并发标记阶段能保证对象不被错杀的关键算法,也是你排查“对象莫名被回收”“Concurrent Mode Failure”“长时间STW”这些问题时绕不开的知识点。这篇文章适合正在做JVM调优、准备面试,或者纯好奇“GC到底怎么并发干活”的朋友,我会把原理、实现、参数和踩坑经验全部放在一起讲,尽量做到既有深度又能直接照做。

1. 追溯根因:并发标记为何需要三色抽象

1.1 从可达性分析说起

要理解三色标记法,得先回到最基础的垃圾识别逻辑。JVM判断一个对象能不能回收,靠的是可达性分析:从GC Roots出发,沿着对象引用链路往下走,凡是能走到的对象都算“活的”,走不到的就是垃圾。GC Roots包括栈上的本地变量、静态字段引用、JNI引用、活动线程的栈帧等等。

在单线程、全线暂停的GC场景里,这个分析过程很简单。比如早期的Serial GC、Parallel GC,标记阶段会触发Stop-The-World,整个应用冻结,然后回收器从GC Roots开始一层一层遍历对象图。遍历期间没有任何新对象产生,也没有引用关系变化,所以标记结果天然是准确可靠的。代价就是停顿时间长,堆越大,停顿越久,这在多核大内存的服务器上完全不能接受。

于是并发回收器出现了,思路很直白:让标记动作和业务线程同时进行。CMS和G1就是典型代表。但问题也随之而来——业务线程在并发标记的同时还在跑,对象的引用关系时刻在变,甚至不断有新对象被创建。这时候如果还用“遍历一遍就定生死”的朴素思路,很容易出现两种事故:一种是活对象被当成垃圾回收掉,这是灾难级的;另一种是垃圾对象没被标记到,留到下一次GC再处理,这叫浮动垃圾,后果轻一些。三色标记法正是为了解决“并发环境下标记结果依然正确”这个问题而被引入的。

1.2 并发标记到底难在哪

我举个生活中的例子。你在一个仓库里盘点货物,规则是:从入口开始,沿着货架上的线索标签一路查下去,凡是查到关联线索的箱子都算有效库存。如果仓库完全锁门,你慢慢查,结果一定准确。但现在是边营业边盘点,营业员还在不断往货架上放新箱子、撕掉旧标签、把某些箱子搬到别处。

你查过的箱子可能被搬走,还没查的箱子可能新增了线索,甚至你手里的线索本身就被营业员改了。这时候你再按老方法盘点,要么错过一些箱子导致误判,要么重复劳动。三色标记法相当于给每个箱子贴了一个状态标签:“没看过”“正在看”“看完了”。有了这个状态机,GC才能知道哪些对象的引用需要重新确认,哪些可以放心不管。

关键难点其实只有一个:并发环境下,标记线程看到的对象图不是静止的,而是一张不断变化的动态图。对象引用被改写的那一瞬间,如果标记线程已经扫过这个对象,而新引用指向的对象还没被扫到,那么这个“新指向的对象”就可能被漏掉,进而被误回收。三色标记法本身不解决这个漏标问题,它只是提供了一套严谨的描述框架,真正解决问题的是建立在它之上的写屏障机制。后面我会详细讲。

1.3 三色标记法的基本约定

三色标记法把所有对象分成三种颜色状态,约定非常清晰:

  • 白色:尚未被回收器访问过的对象。在标记刚开始时,所有对象都是白色。标记结束后,仍然是白色的对象说明不可达,会被回收。
  • 灰色:对象本身已经被访问到,但它引用的其他对象还没全部被扫描完。灰色对象是标记工作的“当前 frontier”,是待扫描队列里的成员。
  • 黑色:对象本身和它引用的所有对象都已经被扫描完毕。黑色对象不会再被遍历,它直接指向的对象也不可能是白色——在正确的并发标记流程下。

标记的过程就是一个颜色流转过程:初始时从GC Roots出发,把根引用的对象置为灰色;然后不断从灰色集合中取出对象,把它引用的白色对象置为灰色,等这个对象的所有引用都处理完,就把自己置为黑色。如此循环,直到灰色集合为空,说明所有可达对象都变成了黑色,标记阶段结束。

这个状态机的妙处在于,它让“标记到哪一步了”变得可观测、可断言。当你发现一个黑色对象新增了一条指向白色对象的引用时,你立刻能判断出:这里出问题了,因为按照规则黑色对象是不允许再指向白色的。这种“规则可描述、异常可检测”的特性,为后面写屏障的设计提供了理论依据。

2. 三色标记的核心细节与写屏障机制

2.1 颜色流转的完整过程

我拿一个实际对象关系来走一遍流程。假设堆里有这几个对象:A、B、C、D,引用关系是A→B,A→C,B→D,GC Roots直接引用A。

初始状态:所有对象都是白色,灰色集合为空。第一步,从GC Roots出发,发现A可达,把A放入灰色集合。此时A是灰色,B、C、D都是白色。第二步,从灰色集合取出A,扫描A的引用,发现A→B和A→C,于是把B和C都变成灰色,同时A的所有引用都扫描完了,A变成黑色。灰色集合里现在有B和C。第三步,取出B,扫描B的引用,发现B→D,把D变成灰色,B变成黑色。最后取出C,C没有任何引用,直接变黑。此时灰色集合空了,D是灰色?不对——D在第三步被置为灰色后,还没被取出扫描,所以灰色集合里还有D。我说得太快了,重新理一下:第二步结束时灰色集合是{B, C};第三步取出B,把D置灰,B变黑,灰色集合变成{C, D};第四步取出C,C无引用,C变黑;第五步取出D,D无引用,D变黑。至此灰色集合为空,标记结束。A、B、C、D全是黑色,没有白色对象,说明没有需要回收的垃圾。

如果某个对象E从始至终没有被任何引用指向,它就会一直保持白色,标记结束后被判定为垃圾。这个流程本身很简单,面试时能画出来、说清楚,就算过了基础关。

但并发场景下这个流程会被彻底打乱。如果标记线程刚把A变成黑色,业务线程立刻执行了一句代码:A.field = E,此时E还是白色。按照三色不变式,黑色对象不允许引用白色对象,但业务线程可不管你这套,它直接就改了。如果此刻没有任何补救措施,E就永远停留在白色,标记结束后被GC回收,可E明明是被A引用的活对象。这就是最经典的“漏标”事故。

2.2 增量更新:CMS的选择

为了阻止上述漏标,CMS采用的是增量更新(Incremental Update)方案。核心思想是:破坏“黑色对象不允许引用白色对象”这个不变式——既然你已经引用了,那我把这个黑色对象“打回灰色”重新扫描一遍。

实现上依赖写屏障。在HotSpot虚拟机里,所有的引用字段赋值操作都会被编译器插入一段额外的检查逻辑,这段逻辑就是写屏障。当业务线程执行类似A.field = E的赋值时,写屏障会拦截这个动作,判断赋值发生后,被修改引用的对象A是不是黑色。如果是,就把A重新放回灰色集合,等后续标记线程再扫描一次A的全部引用,这样E就会被发现并标记成灰色,最终保住性命。

增量更新的好处是实现直接、逻辑简单,而且只需要记录“新增的引用”所属的对象。代价是可能会重复扫描很多已经标记过的对象。如果一个黑色对象频繁被写入新引用,它就会被反复打回灰色,增加标记线程的工作量。CMS选择这个方案,是看重它实现简单、停顿可控,因为CMS的并发标记阶段本身就是和业务线程并行的,多一点重复扫描的影响可以接受,重要的是不漏标。后来G1给出的评价是:增量更新虽然能避免漏标,但会引入“浮对象”问题,导致一些本可回收的对象被多保留一个周期。G1的这套批评有一定道理,但CMS用增量更新跑了这么多年,稳定性是被大规模生产环境验证过的。

2.3 SATB:G1的选择

G1没有沿用CMS的增量更新,而是采用了另一套思路:SATB(Snapshot At The Beginning,起始快照)。名字翻译过来就是“记录开始时的一个对象图快照”,但G1并不是真的去复制一份对象图,而是通过写屏障记录“引用被删除”这个事件。

具体来说,G1的写屏障关注的是赋值操作中的旧值。比如业务线程执行A.field = E,把A原来指向的B改成指向E,SATB的写屏障会把旧值B记录下来。记录下来以后,即使B到某个白色对象C的引用随后被删除,只要B曾经被记录在案,标记线程在后续处理时仍会沿着B再扫描一遍,把C标记为灰色,C就不会被漏掉。

为什么G1要这样做?核心原因是G1把堆划分成了很多Region,标记过程中会连同Region的回收统计一起做,它更在意标记效率和并发阶段的稳定性。SATB有一个非常突出的优点:它能保证并发标记期间,“曾经存活过”的对象都不会被误回收,这相当于给业务线程一个更宽松的并发窗口。代价是会产生更多浮动垃圾——一些已经在运行中变成垃圾的对象,因为“快照”里它还是活的,只能等到下一轮GC再回收。在G1看来,多活一轮无所谓,漏标才是无法接受的。

增量更新和SATB的取舍,本质上是在“多扫描一些对象”和“多留一些浮动垃圾”之间做选择。CMS选择了前者,G1选择了后者。两种方案没有绝对优劣,只有适应场景的不同。我在实际调优中见过CMS因为增量更新反复扫描导致并发标记时间变长的,也见过G1因为SATB导致Region回收效率下降的,说到底还是要结合应用的对象分配率和存活率来看。

3. 实操:CMS与G1中的三色标记落地

3.1 参数配置与调优要点

理论说完了,进入实操环节。JVM不会让你直接配置“三色标记算法”本身,它是内置在回收器实现里的。你能做的是通过参数影响并发标记的触发时机、线程数、以及配套的清理行为。

先看CMS。启用CMS需要-XX:+UseConcMarkedSweepGC,在JDK 8里这行参数很常见。和三色标记直接相关的关键参数有两个:

  • -XX:CMSInitiatingOccupancyFraction=N:老年代占用率达到N%时触发并发标记。默认值在JDK 8里大约是92%,但这个值调低一点通常更稳妥,建议70~80之间。原因后面在浮动垃圾部分细说。
  • -XX:+UseCMSInitiatingOccupancyOnly:这个参数表示只使用上面这个百分比作为触发条件,不要用JVM自动计算的值。很多团队漏了这个参数,导致配置的百分比没生效,CMS触发时机跟预期完全不符。

再看G1。JDK 8里启用G1用-XX:+UseG1GC,JDK 9之后默认就是G1。和三色标记相关的参数有:

  • -XX:G1MixedGCCountTarget=N:混合回收阶段期望的混合GC次数,影响并发标记后的回收节奏,默认值8。
  • -XX:G1HeapWastePercent=N:可回收空间占比低于N%时,G1会停止混合回收,默认5。
  • -XX:G1MixedGCLiveThresholdPercent=N:Region存活对象占比超过N%就不回收这个Region,默认85。
  • -XX:ConcGCThreads=N:并发标记线程数,默认是并行GC线程数的一定比例,可以手动调。

还有一组两个回收器通用的参数要提:-XX:+CMSParallelRemarkEnabled(CMS的重新标记阶段并行化)和-XX:+ParallelRefProcEnabled(并行处理Reference对象)。这两个参数在很多团队里没有被开启,实际上它们能把重新标记阶段的STW时间压下去不少,性价比很高。尤其是Reference对象多的应用(比如大量使用WeakReference做缓存),不开启ParallelRefProcEnabled的话,remarking阶段会非常痛苦。

3.2 从GC日志看标记流程

配置再漂亮,最终还是要看日志确认。先看CMS的日志片段,这是我以前在一台8核16G的Java 8服务上截取的真实输出:

[GC (CMS Initial Mark) [1 CMS-initial-mark: 4822M(8192M)] 4856M(16G), 0.0317481 secs] [Times: user=0.02 sys=0.00, real=0.03 secs] [CMS-concurrent-mark-start] [CMS-concurrent-mark: 0.408/0.433 secs] [Times: user=1.03 sys=0.07, real=0.43 secs] [CMS-concurrent-preclean-start] [CMS-concurrent-preclean: 0.049/0.052 secs] [GC (CMS Final Remark) [YG occupancy: 1234K (152M)] [Rescan (parallel) , 0.008 secs] [weak refs processing, 0.000 secs] [class unloading, 0.008 secs] 4829M(8192M), 0.0371382 secs] [Times: user=0.28 sys=0.00, real=0.04 secs] [GC (CMS Concurrent Sweep) [CMS-concurrent-sweep-start] [CMS-concurrent-sweep: 0.334/0.335 secs]

我来逐行解读这里面的三色标记影子。CMS Initial Mark阶段是最短的一次STW,它的任务是标记GC Roots直接引用的对象,把这些对象置为灰色,相当于三色标记的起点。CMS-concurrent-mark阶段是主体,标记线程和业务线程并行,灰色集合从这个起点逐步扩展,对象不断从灰变黑。CMS Final Remark是第二次STW,负责处理并发阶段产生的漏标风险对象,也就是增量更新写屏障记录下来的那些黑色对象,把它们重新扫描一遍。

G1的日志长这样:

[GC pause (G1 Humongous Allocation) (young) (initial-mark) 0.0194141 secs] [GC concurrent-root-region-scan-start] [GC concurrent-root-region-scan-end, 0.0001151 secs] [GC concurrent-mark-start] [GC concurrent-mark-end, 0.4925833 secs] [GC remark, 0.0127069 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] [GC cleanup, 0.0019072 secs]

G1的initial-mark是借young GC的暂停完成的,不会额外增加一次STW。concurrent-mark是并发标记主体,remark是STW重新标记,cleanup阶段会统计存活信息并决定哪些Region值得回收。注意remark阶段在G1里会处理SATB队列里记录的那些旧引用对象,这就是SATB和三色标记法结合的关键动作。

看日志的时候,我最关注两个指标:concurrent-mark的耗时,以及remark/Initial Mark的STW时间。如果concurrent-mark耗时持续超过1秒,说明并发标记跟不上对象分配速度,或者写屏障记录太多,需要调大ConcGCThreads或者降低触发阈值。如果remark时间波动很大,多半是弱引用处理拖了后腿,优先检查有没有开启ParallelRefProcEnabled。

3.3 两张回收器的对比总结

我把CMS和G1在三色标记实现上的差异整理成一个表格,方便大家直接对照参考:

对比项CMSG1
并发标记主体CMS-concurrent-markG1 concurrent-mark
初始标记STW独立STW,遍历GC Roots直接引用借用young GC,由initial-mark完成
重新标记STWCMS Final Remark,处理增量更新记录G1 remark,处理SATB队列
写屏障方案增量更新(记录新引用,黑色对象变灰重扫)SATB(记录旧引用,按快照保留对象)
浮动垃圾倾向较少,但可能重扫大量对象较多,下一轮Mixed GC处理
并发失败风险Concurrent Mode Failure晋升失败(类似Full GC)
适用堆大小中小堆(4~8G)较稳大堆(8G以上)有优势

这张表不是要劝你无脑选G1。JDK 8下CG1已经足够成熟,但CMS在低延迟场景里依然能打。选择的关键在于:你的堆有多大、对象分配率有多高、能接受多长的STW。三色标记法本身只是算法框架,真正影响体验的是它对应的写屏障方案和触发策略。

4. 常见问题与排查技巧实录

4.1 对象被错误回收的经典场景

先说一个最容易被误解的问题:三色标记法到底会不会漏标?我的答案是:在写屏障正常工作的情况下不会,但如果你关闭了写屏障或者用了不支持的JVM选项,那就会。

写屏障不是可选项,是HotSpot在编译期自动插入的。但有一种情况需要注意:JIT编译后的代码和解释执行路径上的写屏障行为要一致。我在实际项目中遇到过一个问题:某个服务开启了对某个方法的疯狂内联优化,结果GC日志里频繁出现“unexpected oop”之类的断言错误,排查了很久,最后发现是安装了某个非官方JVM补丁包导致写屏障被优化掉了。这类问题极其隐蔽,所以我的建议是:生产环境一定要用官方发行的JDK版本,不要随便打来路不明的补丁。

还有一种经典误回收场景发生在并发标记阶段,业务线程执行了这样的代码:

// 场景:黑色对象引用白色对象 if (objA != null) { objA.ref = objB; // objB之前是白色,现在被黑色对象引用 }

在没有增量更新或者SATB的情况下,objB会被漏标。CMS的写屏障会在objA.ref = objB这行插入逻辑,发现objA是黑色就把它重新置灰。G1的写屏障则会记录旧引用。如果你在日志里看到某类对象频繁被重新标记,多半就是这个场景在实际发生。

排查这类问题的通用思路是:先确认GC日志里remark阶段的重扫对象数量,如果数值异常高,说明业务代码里存在大量“黑色对象被改写引用”的情况,可以考虑调整并发标记的触发阈值,让标记更早开始、更早结束,减少并发窗口期内的竞争。

4.2 浮动垃圾与Concurrent Mode Failure

浮动垃圾是三色标记法在并发场景下的必然产物,尤其是G1的SATB方案,浮动垃圾会更明显。所谓浮动垃圾,就是在并发标记期间从“存活”变为“死亡”的对象。它们已经被标记过了,不会被回收,只能等到下一轮GC。

CMS对浮动垃圾尤其敏感,因为CMS没有压缩整理阶段,老年代空间是靠空闲列表管理的。如果浮动垃圾太多,CMS在并发清理阶段结束后发现老年代可用空间持续减少,最终触发Concurrent Mode Failure。这个错误的意思是:并发标记还没跑完,业务线程就发现老年代满了,需要分配对象但没空间,被迫转入Serial Old GC做全线暂停的Full GC。一旦走到这一步,停顿时间通常是秒级起步,甚至十几秒。

规避方案很明确:把CMSInitiatingOccupancyFraction调低,给浮动垃圾留足空间余量。我在一个支付系统里把阈值从92%调到75%,Concurrent Mode Failure从每周好几次降到零,代价是CMS触发更频繁,但每次STW都短,整体更平稳。同时一定要加UseCMSInitiatingOccupancyOnly,否则你配的百分比会被JVM忽略。

G1这边的对应问题是“Humongous Allocation Failure”。当一个大对象(超过Region大小的一半)无法分配时,G1会触发一次Full GC,停顿一样很吓人。这种场景和三色标记法没直接关系,但常常出现在并发标记期间,因为G1的并发标记会占用一些CPU和内存资源,间接影响了业务线程的分配能力。排查时先看是不是大对象太多,再决定是调大Region(通过-XX:G1HeapRegionSize)还是从业务侧避免大对象。

4.3 我踩过的三个调优坑

第一个坑是盲目调大ConcGCThreads。看起来并发标记线程越多越快,但实际上线程多了会和业务线程争抢CPU。有一次我把ConcGCThreads从默认的2调到6,结果并发标记耗时确实短了,但业务RT反而上涨了15%。后来用-XX:+PrintGCDetails结合vmstat观察,发现并发标记期间CPU用户态占用被顶满,业务线程不得不排队。正确的做法是逐步调,每次加一,观察业务延迟指标。

第二个坑是忽略CMS的碎片化问题。三色标记法能帮你把活对象找出来,但CMS清理后的老年代是碎片化的,即使总空间还有剩余,大对象也可能分配失败。我处理过一个案例,老年代占用率只有60%,但系统频繁Full GC,最后用-XX:+PrintTenuringDistribution一看,是幸存区晋升过快加上老年代碎片导致的。根本解法还是迁移到G1或者调大堆内存,CMS在碎片化面前很无力。

第三个坑是不看GC日志就瞎调参数。很多团队一上线就直接抄网上推荐的“神参数”,却不加-Xloggc输出日志。等你发现GC问题时,没有任何历史数据可查。我的习惯是:任何一次GC调优,第一步永远是先加日志参数:

-Xloggc:/path/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC

JDK 11以上用新的统一日志语法:

-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags

日志拉出来,先看三色标记的几个阶段耗时,再看STW分布,先有数据再动手改参数。没有数据支撑的调优,跟抛硬币没区别。

4.4 一套顺手的排查命令

分享一套我常用的排查组合。先看当前用了哪个回收器以及关键参数生效情况:

jcmd <pid> VM.flags | grep -E 'UseG1GC|UseConcMarkedSweepGC|ConcGCThreads|CMSInitiatingOccupancy'

再看GC历史趋势:

jstat -gcutil <pid> 1000 60

拿到输出后重点看FGCT(Full GC累计时间)和GCT(总GC时间)。如果FGCT持续增长,说明有Full GC在频繁发生,结合GC日志里的Concurrent Mode Failure或者Humongous Allocation确定根因。最后用jmap -dump:live,format=b,file=heap.bin <pid>拉一份堆,用MAT或者VisualVM看对象分布,确认是不是有对象在并发标记期间被反复引用。

这套流程走下来,大部分和三色标记法相关的实际问题都能定位到具体环节:是增量更新的重扫太多,还是SATB的浮动垃圾积压,或者是并发标记期间CPU资源不够。定位到环节之后,再按照前面讲的参数调整思路去改,就不会瞎忙活。

个人在实际操作中的体会是:三色标记法这门技术,理解原理花半天,调优踩坑花半年。原理层面只要抓住“白色待定、灰色在扫、黑色完成”这个状态机,再加上“黑色不能引用白色”这条不变式,大部分问题都能推导出来。真正难的是把日志里的阶段耗时和业务场景对应上,这需要积累。建议手头有生产环境权限的朋友,先打开GC日志观察一个周期,把CMS或G1的标记阶段耗时都记录下来,再对照本文的排查思路走一遍,收获会比单独看书大得多。

最后再分享一个小技巧:当你准备在JDK 8和更高版本之间做迁移时,不妨先在同一套业务流量下,分别用CMS和G1跑一周,专门对比两者的并发标记耗时和remark STW时间。这个数据不会骗人,比任何参数模板都可靠。三色标记法虽然只是GC内部的一个算法细节,但理解了它,你再去看GC日志、调停顿时间,思路会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询