
很多后端同学一谈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→BA→CB→DGC 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变成黑色。最后取出CC没有任何引用直接变黑。此时灰色集合空了D是灰色不对——D在第三步被置为灰色后还没被取出扫描所以灰色集合里还有D。我说得太快了重新理一下第二步结束时灰色集合是{B, C}第三步取出B把D置灰B变黑灰色集合变成{C, D}第四步取出CC无引用C变黑第五步取出DD无引用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 SATBG1的选择G1没有沿用CMS的增量更新而是采用了另一套思路SATBSnapshot At The Beginning起始快照。名字翻译过来就是“记录开始时的一个对象图快照”但G1并不是真的去复制一份对象图而是通过写屏障记录“引用被删除”这个事件。具体来说G1的写屏障关注的是赋值操作中的旧值。比如业务线程执行A.field E把A原来指向的B改成指向ESATB的写屏障会把旧值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:CMSInitiatingOccupancyFractionN老年代占用率达到N%时触发并发标记。默认值在JDK 8里大约是92%但这个值调低一点通常更稳妥建议70~80之间。原因后面在浮动垃圾部分细说。-XX:UseCMSInitiatingOccupancyOnly这个参数表示只使用上面这个百分比作为触发条件不要用JVM自动计算的值。很多团队漏了这个参数导致配置的百分比没生效CMS触发时机跟预期完全不符。再看G1。JDK 8里启用G1用-XX:UseG1GCJDK 9之后默认就是G1。和三色标记相关的参数有-XX:G1MixedGCCountTargetN混合回收阶段期望的混合GC次数影响并发标记后的回收节奏默认值8。-XX:G1HeapWastePercentN可回收空间占比低于N%时G1会停止混合回收默认5。-XX:G1MixedGCLiveThresholdPercentNRegion存活对象占比超过N%就不回收这个Region默认85。-XX:ConcGCThreadsN并发标记线程数默认是并行GC线程数的一定比例可以手动调。还有一组两个回收器通用的参数要提-XX:CMSParallelRemarkEnabledCMS的重新标记阶段并行化和-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: user0.02 sys0.00, real0.03 secs] [CMS-concurrent-mark-start] [CMS-concurrent-mark: 0.408/0.433 secs] [Times: user1.03 sys0.07, real0.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: user0.28 sys0.00, real0.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: user0.02 sys0.00, real0.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:PrintHeapAtGCJDK 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拿到输出后重点看FGCTFull GC累计时间和GCT总GC时间。如果FGCT持续增长说明有Full GC在频繁发生结合GC日志里的Concurrent Mode Failure或者Humongous Allocation确定根因。最后用jmap -dump:live,formatb,fileheap.bin pid拉一份堆用MAT或者VisualVM看对象分布确认是不是有对象在并发标记期间被反复引用。这套流程走下来大部分和三色标记法相关的实际问题都能定位到具体环节是增量更新的重扫太多还是SATB的浮动垃圾积压或者是并发标记期间CPU资源不够。定位到环节之后再按照前面讲的参数调整思路去改就不会瞎忙活。个人在实际操作中的体会是三色标记法这门技术理解原理花半天调优踩坑花半年。原理层面只要抓住“白色待定、灰色在扫、黑色完成”这个状态机再加上“黑色不能引用白色”这条不变式大部分问题都能推导出来。真正难的是把日志里的阶段耗时和业务场景对应上这需要积累。建议手头有生产环境权限的朋友先打开GC日志观察一个周期把CMS或G1的标记阶段耗时都记录下来再对照本文的排查思路走一遍收获会比单独看书大得多。最后再分享一个小技巧当你准备在JDK 8和更高版本之间做迁移时不妨先在同一套业务流量下分别用CMS和G1跑一周专门对比两者的并发标记耗时和remark STW时间。这个数据不会骗人比任何参数模板都可靠。三色标记法虽然只是GC内部的一个算法细节但理解了它你再去看GC日志、调停顿时间思路会清晰很多。