ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

三色标记法详解:并发GC的核心原理与CMS/G1/ZGC实践

三色标记法详解:并发GC的核心原理与CMS/G1/ZGC实践 并发垃圾回收器是现代运行时JVM、Go、Rust侧的一些GC设计里里最绕的一块而三色标记法恰恰是理解它的钥匙。很多同学看源码资料时一看到“黑色对象”“写屏障”“漏标”就头晕觉得是一堆名词的排列组合。这篇我打算直接剥开讲三色标记法的实现原理再顺带聊聊CMS、G1、ZGC是怎么在工程上落地这个模型的。适合正在啃GC源码、做性能调优或者准备面试时被连环问“并发标记凭什么不错杀”的人。1. 垃圾回收器到底在解决什么问题1.1 从单线程到并发回收的核心矛盾垃圾回收的本质是回答一个问题哪块内存是垃圾哪块内存还能用早期最简单的做法是引用计数每个对象记一个被引用的次数次数归零就回收。听着很自然但循环引用直接让次数永远不为零内存泄漏得很安静。另一种经典做法是标记-清除从根引用出发沿着对象引用关系把所有还活着的对象都画出来剩下的就是垃圾。问题在于画图的过程必须扫完整个对象图这个过程中业务线程不能改引用关系否则画出来的图就是错的于是就有了Stop The WorldSTW暂停。单线程回收最直接的痛点是停顿时间不可控。堆越大扫描越久业务线程就得等越久。而在低延迟场景下比如交易系统、在线服务几十毫秒的停顿都可能引发超时雪崩。所以业界的主流方向是把标记、清除这些最耗时的环节拆开让回收工作跟业务线程并发执行而不是把业务冻住。但一旦并发业务线程一边改引用GC线程一边做可达性分析两边同时动同一张引用图很容易互相干扰这就逼着大家去设计一套“边改边看还不出错”的标记方案。三色标记法就是在这类并发场景下被反复验证的核心模型。1.2 三色标记法在设计中的地位先把三色标记放到整条技术链里看。可达性分析不是只有三色这一种抽象但三色标记用它极其简洁的状态定义解决了“标记进行到什么程度”这个问题。它把每个对象抽象成三种颜色白色表示还没扫到这个对象存活与否暂时未知灰色表示正在扫描它的引用但它本身已经被确认活着黑色表示这个对象以及它的直接引用关系都已经处理完了。核心思想就是靠维持“黑色对象不会直接引用白色对象”这一不变量来保证最终不会把活对象当垃圾清掉。这套抽象之所以被并发垃圾回收器广泛采用是因为它把“并发修改引用”这个难题拆分成了两个能落地的问题如果业务线程新加了一条从黑色到白色的引用怎么处理如果业务线程删除了一条从灰到白的引用又怎么处理。前者对应错标把垃圾对象标成存活的预防策略后者对应漏标把活对象标成垃圾的预防策略。理解了这两个分支就等于拿到了看懂CMS、G1、ZGC源码的通用地图。2. 三色标记法的核心实现原理2.1 三色状态的划分与转换把对象标成三色本质上是对对象引用关系做拓扑遍历时的一种进度标记。我先把三色状态的定义说清楚因为后面所有并发问题的讨论都基于这几个状态。白色对象是“尚未染指”的对象回收开始时整个堆里的对象默认都是白色当然实际实现里不会真的逐个刷白而是用位图做标记比如用一块bitmap记录“是否被标记过”bit位为0就等价于白色。灰色对象是“已经确认存活但它的出边还没遍历完”的对象这种对象一般会放在一个显式的队列或栈里作为“待扫描工作集”。黑色对象则是“出边已经处理完”的对象等价于bitmap里标记为1且不会再进工作集。转换关系是这样的从根集合出发先把根直接引用的白色对象标灰然后不断从灰色工作集弹出对象扫一遍它的引用字段每遇到一个白色对象就把对方标灰如果被弹出的对象所有引用都处理完了就把自己标黑。当灰色工作集为空时算法结束所有还没被标记的白色对象就是垃圾。这里有一个很多人混淆的点黑色对象在遍历过程中不是不能再碰到。实际上扫描灰色对象时如果它的引用指向一个黑色对象什么都不用做。而“黑色对象不能引用白色对象”是一个不变量不是自然成立的事实。串行标记里因为业务线程暂停了黑色对象永远不可能新增引用所以不变量天然成立。并发标记里如果有新的引用被写入就必须靠干预手段把这个不变量重新拽回来这就是后面说的写屏障要做的事。2.2 从灰色节点出发的扫描流程整个标记过程可以拆成几个阶段虽然不同回收器的名字不同但骨架是一样的。第一阶段是初始标记只扫描从根集合直接引用的对象这个阶段必须STW因为根集合本身要严格一致通常耗时很短。第二阶段是并发标记GC线程和业务线程同时运行GC线程持续从灰色工作集里取出对象扫描并扩展灰色集业务线程这期间可以继续改引用。第三阶段是重新标记再把业务线程短暂STW处理并发阶段产生的“账外对象”这个阶段在CMS里叫final remark在G1里叫remark。第四阶段是并发清除把确认死亡的白色对象回收掉。用伪代码表达并发标记阶段的核心循环是这样while (gray_worklist not empty) { obj gray_worklist.pop() for (field in obj.references) { child field.read() if (child.is_white()) { child.set_gray() gray_worklist.push(child) } } obj.set_black() }这段逻辑就是三色标记算法的主干朴素到只有几十行。但并发垃圾回收器为了“并发”两个字在这个朴素的循环外面包了写屏障、记忆集、并发队列一大堆机制本质上都是在修补一个假设这个循环执行期间引用图不能变。业务线程一动图循环里读到的引用可能就过时了于是大片白色对象可能永远没被扫到。2.3 三色标记的两大经典问题漏标与错标并发标记的难点可以精确归结为两件事漏标和错标。漏标的场景是这样的灰色对象A原本引用白色对象B业务线程把A指向B的引用删了改成A指向C。如果GC线程在删除发生之前已经扫完了A而B从来不在其他可达路径上那么B从灰色A的扫描视野里消失了又没有变成灰色最后被当垃圾回收。问题在于业务线程删除这个引用之前B可能是被一个“还轮不到扫描”的灰色对象引用的删除动作发生在“灰色对象还没被扫描”到“已经扫描完”之间就会逃过标记。一句话总结漏标条件一个白色对象被至少一个灰色对象引用同时该灰色对象在被扫描完之前丢失了对这个白色对象的引用。错标的场景正好反过来黑色对象C原本不引用白色对象D业务线程在C已经被标黑之后往C里新写入了指向D的引用。因为C已经是黑色不可能再被扫描D就不会被标灰但D此时其实是可达的也被当垃圾清掉。从定义看这是比漏标更严重的错误因为它会把活对象回收掉直接导致程序崩溃或数据损坏。所以并发回收器宁可制造浮垃圾、多保留一些不再可达的对象也绝不允许把活对象判定为死亡。用浮垃圾换正确性是这类回收器设计里决定性的价值取向。3. 并发垃圾回收器如何解决漏标写屏障与增量更新3.1 增量更新原理与实现细节先看错标问题的解法。错标的核心原因是黑色对象新增了指向白色对象的引用而黑色对象已经不会被重新扫描。那最简单的思路就是不允许黑色对象偷偷引用白色对象。一旦检测到“黑色对象要引用白色对象”就强行把白色对象改为灰色塞回灰色工作集保证它在后续标记中会被重新处理。这个思路叫增量更新CMS用的就是它。增量更新在实现时依赖写屏障。写屏障不是安全沙箱之类的机制而是编译器或运行时在赋值操作的适当位置插入的一段额外逻辑。比如Java里执行“obj.field ref”这种字段赋值时JIT编译出来的代码里会插入一个回调当赋值导致“老引用值发生变化”或“新引用值被写入一个已标黑的对象”时触发额外动作。在CMS里增量更新更关注的其实是“把引用从一个对象迁移到另一个对象”的场景。我举个例子黑色对象A本来引用白色对象B业务线程改成A引用白色对象C那么写屏障会把C标灰并放入标记栈。至于B是不是失联变成了垃圾CMS不会管反正B本来就可能是垃圾多留一轮也无妨。增量更新逻辑上不复杂但它有一个代价它需要在并发标记的末尾重新标记阶段把整个“被写入过新引用的黑色对象”的引用关系重新扫描一遍。CMS之所以需要一个看上去很啰嗦的final remark阶段就是为了把增量更新过程中被标灰的那批对象再遍历一次。这个阶段虽然STW但只涉及被污染的对象集合通常比全量扫描小得多所以可用性体现在这里。3.2 原始快照SATB原理与实现细节漏标问题的解法要更绕一些。再回顾漏标的具体条件灰色对象A原本引用白色对象B在A被扫描完之前引用被删除导致B永远不是灰色。增量更新的思路是管住“新增引用”假设所有新引用关系都记录那就不会错杀。但G1等回收器选择的是另一条路记录所有被删除的引用把旧引用关系保留在一种“原始快照”语义里。SATBSnapshot At The Beginning的核心思想是记录并发标记开始那一刻的堆快照所有“并发标记开始时是可达的”对象都必须在本轮标记里被视为可达哪怕它后来失联了也只是多保留一轮变成浮垃圾。实现方式是当业务线程把对象X的某个字段从引用old_ref改写成new_ref时写屏障会把old_ref对象记入一个SATB队列。GC线程在并发标记阶段定期处理这个队列把里面记录的old目标重新变为灰色。为什么能修复漏标因为那个被删掉的引用在旧快照里是存在的原对象B在“正在扫描的灰色对象A”的旧视角里是可达的记录下这个旧引用就相当于把B从历史中找到并标灰。SATB的特点是宁可多标不可漏标所以它天然留下大量浮垃圾好处是重新标记阶段非常轻不需要像CMS那样重新扫描大量引用关系。在G1的设计里SATB队列通常有一定长度限制业务线程写入屏障时会把对象引用压入一个本地缓冲区满了再发布到全局队列GC线程负责消费。这个机制在G1日志里对应的是“SATB buffer processing”等节点。3.3 写屏障的分类与选择聊完两种核心策略再摊开看工程实现上的取舍。写屏障一般按插入位置分为前置写屏障pre-write barrier和后置写屏障post-write barrier。前置写屏障在赋值之前记录旧值常见于G1也就是SATB场景。后置写屏障在赋值之后记录新值或者更新记忆集常用于CMS的增量更新和跨代引用记录。在HotSpot JVM里G1的写屏障往往会同时做两件事SATB队列的旧值记录和跨区引用的记忆集更新所以它的屏障开销比CMS更高但换来的是更强的并行处理能力以及更少的全局STW。选择写屏障策略本质上是两种错误的权衡。增量更新及时反映并发期间的新增引用标记结果更接近“最终状态”浮垃圾较少但需要较大的重标记集合SATB保留的是并发开始时的快照重标记压力小更适用于大堆但会让某些已死对象多存活一轮。实际选择时G1盯上的是低延迟CMS盯上的是旧时长停的规避它们都在各自的权衡里把单轮停顿控制得尽量低。4. 几种典型并发垃圾回收器的三色标记实践4.1 CMS增量更新CMSConcurrent Mark Sweep是传统价值很高的一个案例它的并发标记过程中同时用了三色标记和增量更新来解决错标。CMS的四个阶段分别叫initial mark、concurrent mark、final remark、concurrent sweep。并发标记阶段GC线程从灰色工作列表处理可达对象同时写屏障会把“黑色对象新增引用指向白色对象”的事件捕获把白色对象标灰预留。最终重新标记阶段系统会STW重新扫描所有在并发标记期间被修改过的引用路径用来纠正漏标的黑洞。CMS实际用起来有些难受的地方。老年代碎片化很严重因为它是清除式的没有压缩整理。并发失败时退化为Serial Old停顿直接拉满这是生产环境里最让人头疼的问题没有之一。三色标记解决不了内存碎片化它只管“谁是垃圾”不管“垃圾怎么排列”。所以CMS的调优经验里经常提到预留老年代空间、尽量别触发并发失败本质上就是用更大的堆空间换回收完整度。4.2 G1原始快照G1把堆划分成Region回收也不再是全堆扫一遍而是基于Region做分代和回收集合。但在标记模型上G1明确采用SATB使用前置写屏障记录被覆盖的旧引用。这套做法让G1在并发标记期间几乎不需要像CMS那样做全量重新遍历重新标记阶段只要处理SATB队列中的残留引用即可所以停顿预测更稳。G1里有个细节值得展开它为了处理跨Region引用使用了记忆集Remembered Set记忆集本身独立于三色标记。SATB只负责解决并发标记中的漏标问题而记忆集解决的是“找到老年代Region里被哪些年轻代对象引用”的问题。两者叠加才构成G1的完整可达性基础。事实上G1的并发标记线程数量由“ConcGCThreads”控制调大这个参数会让标记更快完成但也消耗更多CPU业务线程的执行时间会被压缩不是越大越好。4.3 ZGC染色指针与不同思路ZGC出现后很多人问它还用三色标记吗严格说它的基本逻辑依然是三色标记抽象只是实现上做了大幅改动。ZGC引入了染色指针Colored Pointer在64位指针里用额外几个位存状态信息标记颜色可以直接编码在引用里而不是靠独立的位图。这样GC线程可以通过原子地修改引用中的颜色信息把对象从一种状态切到另一种状态配合读屏障实现并发可达性分析和并发重定位。ZGC相较CMS/G1最大的变化是几乎没有STW阶段和全面压缩整个堆被重定位时通过读屏障纠正引用。虽然从抽象模型看它处理的仍然是“标记哪些对象存活”但它把复杂度大部分转移到了读屏障的路径上对吞吐量的损耗比写屏障更大。选型上如果团队要的是极低延迟ZGC通常是更合适的尝试方向只是它的堆占用和CPU损耗通常比G1高不能用“快”简单概括。5. 并发标记的工程挑战与性能分析5.1 标记过程中的线程协作与调度实际生产里三色标记不只是一套算法更是一套线程调度和工作划分系统。GC线程会在并发阶段抓取任务常见的做法是把堆分块用全局任务队列配合线程本地缓冲让多个标记线程各自处理分块避免真锁带来的竞争。JVM里“G1YoungGenSize”、“ConcGCThreads”、“ParallelGCThreads”这些参数决定了GC线程数量而业务线程与GC线程数量的比例会直接影响标记速度。标记线程太多会造成严重的缓存抖动因为多个线程同时扫描不同区域会挤占内存带宽。标记线程太少并发标记拖得久业务线程要长期承担写屏障的开销可能导致吞吐量暴跌。我实操调优时一般先把“ConcGCThreads”设为CPU核心数的五分之一到四分之一再通过GC日志里的“User”和“Sys”时间估算CPU消耗反复对比业务延迟曲线而不是一上来就拉满。5.2 如何测量与调优并发标记怎么判断三色标记有没有正常工作最直接的手段是观察GC日志。HotSpot JVM可以通过“-Xlog:gc*”打开日志重点关注“Concurrent Mark”各子阶段耗时比如“Root Scan”、“SATB buffer processing”、“Copying”等。需要警惕的指标是并发标记阶段的“Load”和“清理粒度”如果每次标记都拖到几百毫秒且日志里频繁出现“Remark”增长那多半是并发写屏障的队列积压了或者堆里存活对象太多。调优方向主要有三个调大堆空间以减少触发频率调整并发标记线程数减少写屏障与SATB队列的分配压力。在G1里常见的一种优化是调整“G1MixedGCLiveThresholdPercent”与“G1HeapWastePercent”间接影响并发标记后的区域回收选择。要注意的是三色标记本身不会解决“对象分配速率过高”的问题它只决定标记质量分配速率过高依然会频繁触发垃圾回收只是从算法层面不会错杀活对象而已。6. 常见问题与排查技巧实录6.1 常见误解与面试高频问题很多人把“三色标记”等同于“三色标记算法解决了所有并发GC问题”这是个明显的误解。三色标记只是一个状态机真正解决并发问题的是配套的写屏障、记忆集和并发队列。面试官问“为什么CMS用增量更新G1用SATB”时不要背结论而要讲清楚两种策略对错标和漏标的取舍以及它们对停顿和浮垃圾的各自影响。还有一个高频误解是认为黑色对象一旦标黑就不会再改变。并发场景下黑色对象可能通过新增引用触发了写屏障然后被重新变灰所以“黑”只是一个瞬态判断不是永久标签。此外很多人把三色标记当成一种垃圾回收器其实它是一种可达性分析的模型CMS、G1、ZGC等才是具体回收器。6.2 实操中的避坑经验工作中踩过几次坑之后我总结了几条经验。第一判断并发标记是否ok至少要连续观察三轮GC结果不要只盯一次日志。因为浮垃圾的存在某轮标记后的存活对象量可能是虚高的下一轮就会体现出来单看一次容易误判。第二如果发现“Remark”阶段时间异常长别马上怀疑三色标记本身。先查是不是应用里有超大的对象图、或者动态代理对象过多再查SATB队列消费线程是否被CPU饥饿。很多时候是线程绑定设置不当而不是GC算法不行。第三用了G1之后还要留意图里“Humongous”对象超过Region一半大小的对象会被放入巨型区域巨型区域本身扫描代价偏高这会直接拖慢并发标记速度。三色标记对连续大对象图的表现不如对小对象的遍历这也是为什么很多团队在存量常驻为大对象时宁愿选CMS或ZGC。第四生产环境里我习惯把“-XX:PrintGCDetails”和“-Xlog:gcphasesinfo”配合起来过滤冗余信息只关注并发标记阶段的耗时和队列长度。日志不是越多越好信息噪音反而让你抓不到核心。五把“字节码里不断创建对象”和“回收器标记算法”混在一起排查是最常见的方向错误。如果并发标记本身没有问题但GC频率很高应该优先检查对象分配速率和业务缓存设计而不是反复调GC参数。六很多教程强调“并发”就是“不暂停”实际并不准确。并发标记阶段还有短暂的初始标记和重新标记会STW。真正的低延迟只是把暂停时间压到很小不是消灭暂停。理解这一点才能正确设定SLA容忍值。我个人在实际操作中的体会是三色标记法最迷人的地方不是三色本身而是它迫使你站在“业务线程和GC线程并行修改同一张图”的角度思考问题。调试GC时不要只盯着性能指标先确认这个堆里的对象关系是不是被某个特别高频的写路径污染了再决定调参数。很多时候一次糟糕的GC体验并不是回收器的问题而是程序里对象引用被反复重写白白抬高了写屏障的成本。想明白这一点再看任何并发垃圾回收器的源码都会觉得名字只是一层壳下面那套靠状态转换和屏障维护不变量的大循环才是真正的核心。最后再分享一个小技巧学习三色标记时可以自己在纸上画几轮白色、灰色、黑色的转换再把业务线程插一脚改引用试试能否构造出漏标和错标场景。我当年就是靠手画了十几次状态转换图才彻底看懂了SATB为什么能救回漏标对象。纸上推演一遍比翻十篇源码分析都管用。
返回列表