ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ZGC核心原理与低停顿实现:染色指针、并发转移与重映射

ZGC核心原理与低停顿实现:染色指针、并发转移与重映射 第一次接触ZGC是在一个搜索推荐服务的真实现场。当时堆内存已经给到64GB用的还是G1压测时GC停顿动不动就上百毫秒P99曲线像过山车。换成ZGC之后最直观的感受是GC日志里的Pause Mark Start、Pause Mark End这类STW阶段基本稳定在1ms左右和堆大小几乎解耦。这篇文章不是ZGC的配置速查而是把ZGC垃圾回收器的实现原理拆开讲清楚为什么它能做到这么低停顿、染色指针和多重映射到底解决了什么问题、并发转移和重映射的全流程是怎么串起来的。适合两类人一类是刚接触ZGC、想把“为什么这样设计”搞明白的Java开发者另一类是已经在线上用过ZGC、想理解GC日志背后逻辑的人。原理清楚了后面遇到“为什么这里会停顿”“为什么内存不释放”这类问题基本都能自己推断出来。1. ZGC到底在解决什么问题1.1 传统GC的停顿根源先看一个所有搬过家的人都能理解的场景你要把客厅的家具重新摆放但屋里还有老人小孩在走动。你挪沙发的时候别人可能正坐在沙发上你搬书架的时候别人可能正从书架上拿书。为了避免撞车最稳妥的办法就是让所有人停下你一个人快速搬完再让大家恢复活动。这个“所有人停下”就是Stop-The-World。传统垃圾回收里标记-复制、标记-整理这类算法本质都逃不开移动对象。对象一旦移动所有引用这个对象的地址就要跟着改。应用线程如果还在跑随时可能去读旧地址读出来就是坏数据。所以回收器必须选择一个安全点把所有线程暂停下来完成一段一致性非常强的工作。G1比CMS聪明的地方在于引入了Region把堆拆成一个个小块可以只回收部分Region。但G1的转移阶段仍然需要STW——它需要在暂停状态下把选中的Region里活对象复制到别的Region并且更新所有指向旧地址的引用。堆越大Region越多需要处理的引用关系越复杂STW时间往往就跟着涨。还有一个隐性问题传统回收器在标记阶段通常要修改对象头或使用额外的记忆集合Remember Set来记录跨Region引用。这些东西都要占用额外内存并且在并发访问时有很高的缓存一致性开销。CMS就是因为标记-清理不搬运对象堆碎片化严重G1为了整理碎片收入和成本很难平衡。这些矛盾的根源还是在于“对象的状态信息”和“对象的引用关系”被分散放在对象本身和元数据区里访问时总要去读、去写很难做到大规模并发。1.2 ZGC的设计目标停顿时间与堆大小无关ZGC最早在JDK 11作为实验特性出现核心目标就一句话在任意大小的堆上把GC停顿时间控制在10ms以内。更准确地说是让STW时间不随着堆大小线性增长。ZGC团队在设计时留了个前提不追求绝对最高的吞吐量可以为了低延迟牺牲一部分吞吐。这和G1、Parallel Scavenge的定位完全不同。很多人第一次看到“停顿与堆大小无关”会觉得很玄。实际上ZGC把完整的GC周期拆成了多个阶段其中真正需要暂停应用线程的阶段只做两件事一是收集一小部分跟线程栈、静态变量直接相关的GC Roots二是做一次非常短的全局收敛。而最耗时的“遍历整个活对象图”“复制对象”“修正引用”这些操作全部放到了并发阶段由GC线程和应用线程同时跑。所以堆从4GB涨到64GBZGC的STW时间基本能稳住涨的是并发阶段的耗时这部分恰恰不需要暂停应用线程。当然ZGC也不是银弹。它适合大堆、低延迟、P99敏感的服务如果堆只有几百MB或者应用本身对吞吐量极度敏感、CPU核数又少ZGC的读屏障开销和并发线程调度成本可能反而拖后腿。这些场景差异我在第4节会结合调参经验详细聊。2. 理解三个底层设计Region、染色指针、多重映射2.1 Region把大堆切成格子按格回收ZGC和G1一样不搞整堆的连续空间管理而是把堆切分成固定大小的Region。每个Region可以独立分配、独立回收这样GC时只需要挑选出“值得回收”的Region集合而不是每次都要处理全堆。ZGC的Region分成三类小Region默认2MB存放小对象中Region默认32MB存放中等大小对象大Region则用于分配超大对象大小按需伸展。对象越大放进大Region的比例越高这是为了避免对象在多个Region之间横跨横跨对象在复制和引用追踪时都很难处理。这种分级设计还能降低复制成本如果一个Region里活对象特别少回收时只需搬走少量对象剩下的空间直接释放如果活对象密度很高那这个Region暂时就不值得回收留着继续用。这里有一个容易被忽略的细节Region只是逻辑上的“格子”ZGC并不假定对象必须连续存放在某个固定Region里。真正支撑“不暂停也能搬对象”的是下面要说的染色指针和多重映射。没有这两个机制Region做得再细搬运时还是得STW。2.2 染色指针状态写在指针上而不是对象头里这是ZGC最核心、也最反常识的一个设计。传统垃圾回收器标记对象大部分是往对象头里写状态标记位、分代年龄、偏向锁信息都在对象头里。标记阶段修改对象头应用线程访问对象时也要读对象头这就带来两个问题一是并发访问同一对象时缓存行竞争严重二是对象头里能放的状态位有限很难承载GC状态机。ZGC换了个思路不把状态写进对象内部而是把状态直接编进引用指针本身。它借用了64位虚拟地址空间的高位空出4个颜色标注位Marked0、Marked1、Remapped、Finalizable。这样一个引用指针除了“对象在哪个地址”还包含了“这个对象当前处于什么GC状态”。常见的x86_64/Linux实现里低42位用于编码堆内地址最大支持4TB堆不同平台地址位宽会有差异但原理一致。你可以把染色指针想象成给快递包裹贴了不同颜色的标签同样是这个包裹贴红标签代表“正在标记中”贴蓝标签代表“已经搬迁完成”贴黄标签代表“需要特殊处理”。搬运工人看到标签颜色就知道该走哪条流程不需要拆开包裹看内容。GC同理一个对象物理内存没变但引用指针的颜色变了语义就变了。GC线程在并发标记时只要看到某个引用处在不正确的颜色状态就知道这个引用还没处理过可以立刻处理。这个设计带来的直接好处状态切换是原子地改写一个指针值不需要去碰对象本体也不需要加锁。两个Marked位专门用来交替使用本次GC用Marked0做标记位图下次GC用Marked1这样就可以避免每次GC都要清空整张标记位图。ZGC把非常昂贵的内存扫描工作变成了几下指针运算。2.3 多重映射三个地址一份物理内存染色指针解决了“状态放哪里”但还有一个问题同一块物理内存如果同时被多个视图引用GC线程怎么看对象的真实数据应用线程访问时又怎么知道该用哪个视图ZGC的操作系统层面解决方案是多重映射在JVM启动时通过mmap把同一块物理内存映射到三个不同的虚拟地址区间分别对应Marked0、Marked1、Remapped三个颜色视图。对于应用线程和GC线程来说这三个地址看着不同但它们最终指向的是同一份物理页数据没有多份拷贝。GC线程做标记时在当前颜色的视图上处理应用线程访问时通过指针的颜色位选到对应的视图。打个比方同一个房间在地图App上有三个入口地址门牌号不同但推开门进去家具完全一样。你手里拿的是旧门牌号走到门口发现新门牌是另一个导航员读屏障会告诉你新门牌在哪儿并顺手帮你把门牌号换成新的。这个“顺手换门牌”的动作就是ZGC里的自愈机制。多重映射的好处很明显GC调整对象状态时不需要改写对象数据也不需要加内存屏障保证可见性只需要切换指针颜色的视图。缺点的代价是地址空间占用。ZGC在JVM启动时会预留大量虚拟地址空间但这只是虚拟地址不占物理内存所以对实际内存没有影响。这也是为什么换ZGC后很多人看/proc/pid/maps会觉得“这个进程虚拟内存怎么这么大”其实不用担心。3. ZGC并发回收全流程拆解3.1 并发标记把活对象点亮ZGC一次完整的GC循环大致分为并发标记、并发转移、并发重映射三个大阶段。标记阶段的目标是找出所有从GC Roots可达的活对象。流程是这样的先有一个极短的STW“初始标记”应用线程全体停一下ZGC把每个线程栈、JNI引用、静态变量这些根引用记录下来。这一步之所以必须停是因为线程栈里的引用只能在线程暂停的时候才能稳定枚举。但停顿时间不取决于堆大小只取决于线程数量和根引用数量所以通常只有零点几毫秒。接下来是并发标记。GC线程从GC Roots出发沿着对象引用遍历整个对象图。真正干活的是GC线程和应用线程同时进行。应用线程每访问一个引用都会经过读屏障读屏障会把当前对象染色到正确的标记位如果发现有引用指向的对象还没有标记就立即补上标记。GC线程同样通过读屏障扫描对象图。因为所有对引用的访问都会经过读屏障所以漏标的情况能及时补上。并发标记阶段结束前还有一个很短的STW“再标记”主要用来处理弱引用、虚引用、终结器引用这一类需要特殊语义的引用。之后ZGC会统计每个Region的活对象密度和回收收益决定哪些Region要被转移。整个标记阶段STW只占两个极小的窗口最耗时的对象图遍历完全并发这是停顿不随堆大小增长的第一个关键点。3.2 并发转移只搬有价值的Region标记完成后ZGC会得到一份“回收候选Region”的清单通常只挑选那些活对象少、碎片多的Region复制成本低收益高。这一步叫作并发转移。转移开始前会有一个非常短的STW用来初始化转移集合和准备GC Roots然后应用线程恢复。接下来GC线程把选中Region里的活对象逐个复制到新的Region复制完成后在旧对象的内存位置写入一个前向指针指向新对象的地址。这样如果应用线程访问到了尚未更新的旧引用就能顺着前向指针找到新对象。这里最精彩的就是读屏障的自愈机制。假设一个应用线程正在执行一段业务代码读了一个对象字段拿到一个旧引用指向已经被转移走的旧Region。如果没有读屏障这条引用就是悬空的访问会出错。有了读屏障它在拿到这个引用的瞬间检查颜色发现对象处于“已转移但引用还没重映射”的状态就会立刻顺着前向指针跳到新地址并且把当前引用字段的值改写为新地址。这个过程对外部线程来说是原子的应用线程感觉不到任何异常。优雅的地方在于ZGC不需要专门派一个线程去全堆修正引用而是让业务线程在访问时自然完成修正谁碰谁改。并发转移阶段虽然不需要STW但非常占用CPU。GC线程要把活对象拷到新Region业务线程访问旧引用时还要做前向跳转。如果业务线程太多了拷贝速度跟不上分配速度ZGC就会进入白热化状态出现分配停顿——Application线程在尝试分配新对象时会等待GC完成一部分转移。这一般不是算法问题而是“堆太小分配速率太高GC线程太少”的综合结果。3.3 并发重映射拖到下一次GC再做转移阶段结束之后理论上是需要把堆内所有指向旧地址的引用都修正到新地址的。对很多回收器来说这步就是STW重灾现场。但ZGC并没有单独抽出一个阶段来做全堆重映射。它的做法是把重映射和下一次GC的并发标记合并。下一次GC启动时同样从GC Roots出发遍历对象引用遇到处于Remapped状态的引用时读屏障会重新做一次自愈修正。也就是说重映射工作被分摊到了下一次完整的GC循环里大多数引用会被“顺便”修正完。那些没有被下一次GC遍历到的对象可能仍然持有旧引用但旧Region已经被回收不会再分配新对象所以这些旧引用虽然存在却不会造成错误访问等下一次GC再次扫描到它们时自然会更新。怎么实现这种“两次GC共用一套机制”靠的就是那4个颜色位。本次标记用Marked0下次用Marked1两个标记位图交替使用避免了清空位图和重算状态的高成本。这也解释了为什么ZGC的GC日志里经常能看到一个GC周期结束后紧接着的下一个GC周期的“Concurrent Mark”阶段就承担了上一次的重映射工作。3.4 为什么STW时间不随堆大小增长把三个阶段串起来看真正的STW只有三处初始标记、再标记、转移准备。这三处做的工作量都不和堆大小成正比初始标记枚举GC Roots只和线程数、根引用数有关。再标记处理弱引用和少量收敛工作。转移准备选择Region集合不扫描全堆。最耗时的对象图标记、对象复制、引用自愈更新全部并发完成。应用线程在并发阶段只需要付出一些读屏障的开销不需要长时间停下来等堆。所以堆从16GB涨到64GBZGC的停顿依然可以做到几毫秒以内代价是并发阶段的时间变长GC线程占用更多CPU。这就是为什么ZGC官方文档特别强调它适合CPU核数比较多的机器因为它在拿“并发CPU时间”换“停顿时间”。3.5 分代ZGC的演进经典ZGC有一个被人诟病的点每次GC都是全堆标记。虽然停顿低但并发标记和转移的工作量在对象很多时依然可观。为了让年轻代对象能更频繁地被回收JDK 16以后引入了分代ZGC的实验特性JDK 21正式转正。分代ZGC在染色指针上做了进一步扩展把堆分成年轻代和老年代年轻代采用复制回收老年代沿用并发整理。核心的染色指针、读屏障、多重映射机制没有变只是新增了用于跟踪跨代引用的写屏障。对使用者来说JDK 21上可以用-XX:ZGenerational开启分代模式并结合-XX:UseZGC使用JDK 17还是只有非分代版本。我建议新项目直接考虑分代ZGC尤其是对象分配速率高、服务对延迟敏感的场景收益比非分代更明显。4. 日志、参数与线上调优4.1 先学会看ZGC日志想用好ZGC第一件事不是调参而是打开GC日志。JDK 9之后统一用-XlogZGC常用的日志参数组合是这样的-Xlog:gc*,gcheapdebug,gcstatsdebug在我的线上经验里日志能看到的信息比想象中多。先看一段简化后的ZGC GC日志不同JDK版本的阶段命名略有差异但核心结构一致[0.338s][info][gc,start] GC(1) Garbage Collection (Allocation Rate) [0.338s][info][gc,phases] GC(1) Phase: Pause Mark Start 0.048ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Mark 74.2ms [0.338s][info][gc,phases] GC(1) Phase: Pause Mark End 0.022ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Process Non-Strong References 0.013ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Reset Relocation Set 0.041ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Destroy Detached Pages 0.003ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Prepare Relocation 0.021ms [0.338s][info][gc,phases] GC(1) Phase: Concurrent Relocate 30.1ms [0.338s][info][gc,phases] GC(1) Phase: Pause Relocate Start 0.031ms先说结论看ZGC日志最需要盯住的是所有带Pause的阶段。Pause Mark Start和Pause Mark End通常在1ms以内如果它们突然超过5ms甚至10ms说明GC Roots数量异常或业务线程太多导致收敛变慢要关注线程栈大小和JNI引用。Concurrent Mark和Concurrent Relocate虽然耗时长但那是GC线程并发执行的不会阻塞业务线程不能把这些数值当成服务停顿时间。日志开头GC(1) Garbage Collection (Allocation Rate)里的Allocation Rate是GC触发原因常见几种Allocation Rate预测到当前分配速率会耗尽可用内存主动提前GC。Allocation Stall应用线程分配对象时无法获取内存被迫等待GC腾空间。这是最危险的信号。High Usage堆使用量达到软限制。Proactive距上次GC已经过了ZCollectionInterval主动触发。看到Allocation Stall频繁出现基本就是两个方向堆开小了或者分配速率涨得太快。先加堆再看并发GC线程数和ZAllocationSpikeTolerance。4.2 常用参数与实际效果我整理几个实际会用到的参数没有生僻的都是调优时真正动过的参数作用我的使用建议-XX:UseZGC启用ZGCJDK 15之前需要加-XX:UnlockExperimentalVMOptionsJDK 15起直接可用-Xlog:gc*,gcheapdebug,gcstatsdebug输出GC阶段日志建议至少观察1-2周后再做参数调整-XX:ConcGCThreadsN设置并发GC线程数默认值偏保守CPU核多时可以适当调大但别超过核数的一半-XX:SoftMaxHeapSizesize设置堆“软”上限区别于-XmxZGC可以临时超过软上限主要用于控制GC频率-XX:ZAllocationSpikeTolerance2.0控制分配速率突增容忍度线上很少动如果经常Allocation Stall可以适当调小让ZGC更早触发-XX:ZCollectionIntervalseconds控制主动GC最大间隔配合ProactiveGC避免空闲堆长期不回收-XX:ZUncommitDelayseconds控制内存归还OS的延迟默认300秒容器监控如果报RSS不降可以调小特别提醒一个坑ZGC没有-XX:MaxGCPauseMillis参数。很多人从G1切过来习惯性带上MaxGCPauseMillis50结果启动后发现参数不生效甚至报错。ZGC的停顿时间不是靠目标参数调节的它靠算法保证。你真正能影响的是GC触发的频率和并发线程数不是某个固定的停顿目标。4.3 一套可复制的切换步骤我每次给服务切换ZGC基本都按这个流程走确认JDK版本。至少JDK 15以上强烈建议JDK 17或JDK 21。JDK 11虽然有ZGC但很多调优参数和分代能力都没有不值得折腾。在测试环境把堆大小、线程数、请求模型都压到接近生产水平先用默认参数跑3天收集GC日志。打开日志重点看两个指标Pause阶段耗时和Allocation Rate触发频率。如果并发标记经常超过300ms说明对象图很大可以考虑加CPU线程如果频繁Allocation Stall说明堆太小优先加-Xmx。灰度发布。建议先在单台实例上用-XX:UseZGC切过去对比P99和CPU。ZGC不是没有代价的它的读屏障会带来一些吞吐损失如果服务CPU长期跑满就要重新评估值不值得。稳定运行后配合监控告警当GC日志出现Allocation Stall或Pause超过预期阈值时自动触发报警。4.4 我踩过的几个坑第一个坑堆小就别硬上ZGC。我在一个2GB堆的定时任务服务上试过ZGC结果是P99没改善多少CPU反而多吃了不少。ZGC的并发标记和屏障开销在堆规模太小时体现不出优势。这种场景老老实实用G1或者Parallel都比ZGC划算。第二个坑分代参数不要凭记忆乱写。JDK 21的分代ZGC参数是-XX:ZGenerational但不同版本默认行为可能不同有些版本非分代仍是默认有些版本分代是默认。上线前一定用java -XX:PrintFlagsFinal -version | grep ZGC确认一下。第三个坑看GC日志时被Concurrent Relocate的时间吓到。有次我线上看到Concurrent Relocate吃了400ms差点以为是服务卡了400ms后来看了线程拓扑才明白那是GC线程并发执行的业务线程没有等它。判断停顿的唯一标准还是那几个Pause阶段。5. 常见问题与避坑速查我把线上被问得最多的问题整理成了一张速查表排查时照着看能省不少时间现象可能原因处理建议开启ZGC后P99反而变高堆太小、CPU核数不足、并发GC线程不够先加堆再调ConcGCThreads小堆场景建议退回G1频繁出现Allocation Stall对象分配速率超过并发转运能力增加-Xmx启用分代ZGC调整ZAllocationSpikeTolerance日志显示Concurrent Mark特别久活对象数量多、对象图太大堆足够大时不用干预关注是否频繁触发必要时加CPU核容器内存监控显示RSS不下降未Commit内存归还OS有延迟检查ZUncommitDelay默认300秒要用SoftMaxHeapSize控制软上限GC日志经常出现Proactive距上次GC时间太长按时间主动触发如果不想太频繁调大ZCollectionInterval正常现象不用慌大对象频繁分配导致GC频繁大Region复制成本高触发回收快业务上拆分大对象或使用对象池复用避免大量一次性大数组JDK 21想用分代版版本理解不一致确认JDK版本和默认模式用-XX:ZGenerational显式开启/关闭还有一条排查经验想确认ZGC到底在干什么可以用jcmd pid GC.heap_info查看Region使用情况输出里会列出小Region、中Region、大Region的存活率和回收候选集合。高频触发GC时我通常先看这个再回GC日志找Allocation Rate基本能定位问题。关于ZGC还有一个额外提醒它是并发GCGC线程和应用线程会抢CPU。如果应用本身已经把CPU用满ZGC并发标记时会进一步加剧CPU竞争造成业务线程吞吐下降。这种情况下与其调GC参数不如加核或者减少GC频率。这是我在一个CPU已经很紧张的推荐服务上得出的教训强行上ZGC最后反而把P99压得更糟换成分代ZGC并限制了并发线程数之后才恢复稳定。我个人在实际操作中的体会是ZGC最值得研究的不是那几行启动参数而是它“把状态编码进指针”的思维方式。染色指针、多重映射和读屏障三者配合把GC的关键操作全部打散到并发流程里。理解了这个状态机下次看到日志里Pause阶段稳定在1ms以内就不会只觉得“哇好快”而是能说清楚它为什么快。如果你正准备在线上尝试ZGC建议从“打开GC日志观察两周”开始先建立基线再动手调参比一上来就抄一整套参数靠谱得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进