拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ZGC低延迟GC原理剖析:指针着色、读屏障与实战调优

1. ZGC能干什么低延迟GC的定位与设计起点1.1 打破停顿神话从STW到目标停顿时间小于1ms直接说结论ZGCZ Garbage Collector是JVM上一款以极低停顿时间为核心目标的垃圾收集器它从设计之初就把“GC暂停时间”当成头号指标而不是吞吐量。传统GC比如Parallel GC、CMS、G1都在努力让STWStop-The-World时间变短但无论怎么优化总存在一个“必须停下来才能做”的临界点。ZGC走了一条更激进的路它把几乎所有GC阶段都做成了并发目标是把单次GC停顿压到1毫秒以内并且这个指标不随堆大小线性增长。这里要特别强调一点ZGC的停顿时间与堆大小无关这一点和G1有本质区别。G1在Region数量增加、存活对象增多后并发标记和转移的开销会肉眼可见地上升STW时间也会跟着拉长。但ZGC因为有三色标记、指针着色、读屏障这一整套设计打底理论上堆从2GB扩到200GB单次停顿依然能维持在1ms级别。当然这是理论上限实际部署中操作系统层面的抖动、大页配置、NUMA拓扑都会影响最终表现但至少架构上它做到了“停顿时间不随堆增长”这件事。适合用ZGC的场景也非常明确在线交易、实时推荐、游戏服务端、高频交易系统等对延迟极度敏感的Java服务。用大白话说你的业务不能让用户等太久哪怕是几十毫秒的GC停顿都会带来超时、卡顿、体验劣化时ZGC就是值得认真考虑的方向。1.2 ZGC的“全景图”核心架构与三件套ZGC能实现低延迟靠的是一整套环环相扣的设计不是某一个单一技术点的功劳。业内讨论ZGC时经常把它拆成三件套三色标记这是GC算法的理论基础解决“哪些对象存活、哪些对象可回收”的问题。指针着色Colored Pointers这是ZGC独有的核心创新把GC状态信息直接写在指针里省掉了传统GC维护额外元数据的开销。读屏障Load Barrier配合指针着色在应用程序“读对象引用”的时候插入检查逻辑保证并发正确性。这三者的关系可以这样理解三色标记是算法骨架指针着色是数据结构层面的革命读屏障是保证并发安全的执行机制。三者缺一不可。我第一次接触ZGC时最强烈的感受是它不像是在优化GC更像是在重新设计GC。传统GC的思路是“如何把STW时间压缩到更短”而ZGC的思路是“能不能干脆没有STW”。这背后是一整套对JVM内存模型、硬件地址空间、编译器插入逻辑的重新思考。下面我从最基础的三色标记讲起再一步步推进到指针着色和读屏障最后把完整的GC流程串起来。2. 三色标记ZGC算法的基础底座2.1 三色标记算法的基本过程三色标记不是ZGC发明的它是GC领域非常经典的可达性分析算法很多垃圾收集器都在用。它的核心思想是把对象分为三种颜色白色尚未被扫描到或者最终仍未被引用的对象代表“可回收”。灰色对象本身已被标记为“存活”但它引用的其他对象还没有被全部扫描完代表“正在处理中”。黑色对象及其所有引用都已扫描完毕代表“本次GC确认存活”。算法从GC Roots比如线程栈里的局部变量、静态变量、JNI引用出发先把直接引用的对象涂成灰色然后不断从灰色对象出发扫描它的字段。每扫描完一个对象的所有引用就把这个对象从灰色变成黑色同时把扫描过程中新发现的白色对象变成灰色。整个过程循环往复直到没有任何灰色对象为止。最后剩下的白色对象就是不可达的垃圾对象。听起来很直观对吧你可以把三色标记想象成在一个仓库里盘点货物黑色是已经盘点完且确认有用的货架灰色是正在盘点的货架白色是还没碰过的货架。最终白色区域就是要清理掉的货物。2.2 并发标记最大的“坑”漏标问题三色标记本身不难难点在于“并发”。GC线程在标记对象的同时业务线程还在不停地改写对象引用。如果业务线程把一个黑色对象重新指向了一个白色对象而这个黑色对象已经不会被再次扫描那么这个白色对象就会被错误地当成垃圾回收掉。这就是经典的漏标问题。举个例子对象A已经被标记为黑色扫描完了所有引用。此时业务线程执行了A.field objB而objB还是白色。如果GC线程不再回头扫描A那么objB就会漏标最终被错误回收。程序后续再访问objB就会拿到一个悬空引用。为了解决漏标业界有两套经典方案增量更新Incremental Update当黑色对象被写入新的白色引用时把这个黑色对象重新变回灰色等待再次扫描。CMS就用了这个思路。原始快照SATBSnapshot At The Beginning记录并发标记开始时所有对象的引用关系快照只要某个对象的引用在标记期间发生变化就把变化前的引用记录下来确保旧的引用关系不会被漏掉。G1用的是这个思路。这两种方案都能解决问题但都有一个隐藏成本需要在写引用时插入额外的逻辑也就是写屏障Write Barrier而且SATB还要求记录历史快照内存占用和扫描成本都不低。2.3 为什么ZGC没有直接使用SATB或增量更新ZGC没有走CMS和G1的老路而是选择了另一条完全不同的路径它用三色标记但不对“对象头”做标记而是对“指针”做标记。这就是指针着色的由来。为什么这么设计因为ZGC希望避免SATB快照带来的内存开销也不想依赖写屏障。它希望把标记信息直接编码到指针里这样业务线程在读取引用时通过一条指令就能判断当前对象是否处于正确状态。这个思路一旦落地很多传统GC需要“记录”和“回调”的动作都可以省略。顺带说一句ZGC早期版本JDK 11初步实验时在并发标记上也参考了三色标记但实现方式极其特别它不修改对象头也不维护额外的标记位表而是通过指针上的颜色位来表示对象的颜色状态。这种“把状态放在指针上”的做法是ZGC整个设计的分水岭也是理解ZGC的关键。3. 指针着色ZGC最有辨识度的一步棋3.1 指针着色的基本思想普通JVM里一个引用就是一个指向对象地址的指针信息量非常单一。ZGC的指针着色则把指针本身变成了一台“信息载体”除了地址信息还能携带GC状态信息。具体做法是这样的64位系统上指针理论上有64位可用但实际物理内存和虚拟内存都远用不到这么多。ZGC把64位指针里的高几位拿来存放GC状态标志位剩下的位数才用来表示对象地址。因为这些标志位本质上是给指针“染色”所以叫指针着色。从使用者的角度来看指针着色带来的最大好处是GC状态信息不再需要单独的内存区域来存储不再需要每次GC时去更新对象头里的标记字段也不需要在GC前后频繁地修改对象元数据。所有标记、转移过程中的状态变化都通过对指针本身的位操作来完成。3.2 支持多少位地址空间为什么是46位很多人第一次看到ZGC的指针布局都会疑惑好好的64位指针为什么只支持46位地址空间这不是浪费吗这背后是严格的数学计算。64位指针中ZGC预留了4位用于标记状态这里说的4位是完整设计中的分配不同JDK版本会有微调剩余地址空间用于对堆中对象的定位。粗略地说46位可以寻址高达64TB的堆空间2的46次方字节等于64TB。在绝大多数业务场景下64TB的堆完全够用。即便未来堆继续扩大JDK也在持续演进比如JDK 21以后对更大堆空间的支持也在逐步完善。ZGC 4位标志位的典型分配大致是这样的一个bit用于终结标记Finalizable一个bit用于重映射标记Remapped两个bit用于标记状态Marked0和Marked1用于区分不同GC周期的标记结果这4个标志位的存在让ZGC在并发标记和并发转移阶段能够以极低的开销判断一个对象当前处于什么状态。举个例子如果一个指针的Marked0位是1说明该对象在上一个GC周期中应该被视为“已标记存活”业务线程可以直接使用而无需额外处理。3.3 多重映射让源码零修改的关键设计指针着色有一个很现实的问题业务代码通过指针访问对象时如果发现指针的状态不对比如对象需要转移那该怎么办最简单的方案是让代码在访问对象前做一系列位运算、判断、条件跳转。但这就意味着每次读取引用都要插入成百上千条指令对性能是毁灭性打击。ZGC的方案非常巧妙它没有让应用程序代码手动处理这些位运算而是利用了操作系统的虚拟内存映射。ZGC在JVM启动时会将同一段物理内存映射到多个虚拟地址上不同虚拟地址对应不同的“颜色视图”。应用程序指针上那些颜色位实际是通过访问不同视图地址来体现的。用大白话说同一个对象物理内存只有一份但你有好几个虚拟地址可以访问它。当GC状态改变时ZGC通过切换映射关系让原本指向旧地址的指针在新映射下自动对应到正确的内存视图。应用程序只知道“我读了一个地址、拿到了对象”完全不知道底层发生了什么。这套机制叫做多重映射Multi-Mapping。这个设计让我印象极深。它把原本需要软件处理的复杂逻辑下推到了操作系统的内存管理单元MMU层面。应用程序的每条对象引用访问几乎不会增加额外的指令开销除非读屏障检查到确实需要特殊处理。4. 读屏障着色指针的“监视器”4.1 什么是读屏障它拦截了谁你可能听过G1有写屏障Write BarrierCMS也有写屏障但ZGC用的是读屏障。读屏障是指在“读取对象引用”这个动作发生时插入的一段检查逻辑。它拦截的不是普通的对象字段赋值而是GC需要关注的“读取引用”操作。只要业务线程执行了类似Object obj someField;这样的操作编译器就会在真正读取引用之后、使用这个引用之前插入一段屏障代码。读屏障做什么事呢以ZGC为例当读取到一个指针后读屏障会检查这个指针的颜色状态。如果状态是“正常”那太好了直接使用即可。如果状态是“需要重映射”或“需要转移”那么读屏障会做两件事第一根据情况把当前引用修正到新的地址第二触发ZGC去处理对象转移同时可能把这个对象的指针更新为最新的地址。这就是ZGC“自愈”特性的来源。一个对象被转移后如果还有旧的引用残留在某些线程栈或堆字段中第一次有人通过旧引用访问它时读屏障会负责把旧引用更新成新地址。下次再访问时就不需要再做任何修正了。4.2 为什么宁可读屏障也不用写屏障传统CMS和G1选择写屏障是因为它们需要捕获“引用被写入”的瞬间以便做增量更新或SATB记录。但写屏障有一个实际痛点写操作往往会伴随着指令流水线的冲刷而且很多写屏障需要额外的存储操作来记录历史信息开销不小。ZGC走读屏障的一个原因是它不关心引用什么时候被写入只关心引用什么时候被读取。因为ZGC的指针着色已经把大部分状态编码在指针本身了当业务线程读取引用时ZGC只需要检查指针上的颜色位就能确定下一步动作。相比写屏障读屏障检查的成本有时更低因为它在热点代码里通常只是一两个位运算加一次条件跳转。当然读屏障并不是零成本。在JDK 12、13时期ZGC的读屏障在部分基准测试中有明显开销尤其是那些高频读取引用的场景。后来JIT编译器不断优化加上JEP增强开销已经逐步降低。但从架构设计来说它比“每次写引用都去触发复杂回调”要干净很多。这也是ZGC宁可选择读屏障的原因——它的整个算法结构已经决定读路径才是需要介入的地方。4.3 常见误区读屏障开销真的不可接受吗网络上经常看到一种观点“ZGC的读屏障太伤性能不适合CPU密集型应用。”这句话有道理但不能全信。ZGC读屏障确实会在每次引用加载时引入额外指令但它并非没有优化空间。现代处理器对条件分支有很好的预测机制绝大多数情况下读屏障的路径是同一的、无需修正的所以分支预测器的表现往往不错。真正影响ZGC性能的往往不是读屏障本身而是另外两点第一堆内存低于一定阈值时ZGC会退化为类保守式回收性能会变差第二并发转移时如果存活对象非常多转移线程和业务线程争夺内存带宽会产生较大开销。这些和“读屏障无脑慢”完全是两回事。我的建议是如果你正在选型GC不要只看网上的基准测试更不要只看“读屏障”三个字就劝退。要看你的业务负载模型是对象分配频繁但存活率低还是存活率高且对象巨大不同的负载ZGC表现差异很大。最容易出效果的场景是“大堆、存活对象不多、对停顿敏感”的服务。5. 完整GC循环标记、转移、重映射怎么串起来5.1 并发标记阶段做了什么ZGC的一次完整GC周期大致可以拆成标记、转移、重映射三个阶段。先看标记阶段。标记阶段从GC Roots出发采用三色标记算法遍历所有存活对象。这里最重要的细节是整个标记过程是并发的业务线程可以继续运行。当业务线程读取一个指向“未标记对象”的引用时读屏障会把该对象置为已标记状态同时让遍历继续。由于指针着色ZGC可以在读屏障里通过简单的位运算更新颜色位不需要像传统GC那样去修改对象头。整个并发标记阶段结束时ZGC能确认一批“本次GC周期内存活”的对象。这些对象的指针上带有对应的颜色标记而没有被标记到的对象将被视为可回收对象。标记阶段往往是大规模GC中开销最高的部分因为它要扫描整个对象图。但ZGC设计了一套很聪明的做法“只标记不完全遍历”。什么意思呢ZGC不会在标记阶段把所有引用关系都扫描完而是通过指针着色的机制让业务线程在后续访问中做“补充标记”。这样可以尽量把工作打散到业务线程的运行过程中减少集中扫描的峰值。5.2 并发转移阶段怎么搬对象标记结束后ZGC会开始转移阶段。转移是ZGC实现低延迟的关键一步它要把存活对象搬运到新的内存区域以便压缩堆空间、减少碎片。传统GC在转移阶段通常要STW因为如果不停止业务线程当业务线程访问一个已经被搬走的对象时如何找到它的新地址就成了难题。ZGC解决这个难题靠的正是指针着色和读屏障。转移开始前ZGC会把旧对象的指针标记为“需要重映射”。当业务线程读取到带有这个标记的指针时读屏障会根据转发表forwarding table找到新地址然后返回新地址给业务线程。同时读屏障还会更新当前这个引用字段让下一次访问直接走新地址。这个过程叫做“自愈”。因为读屏障承担了“找新地址”的工作ZGC转移阶段不需要停止所有线程。JIT编译器配合读屏障把查找新地址的逻辑内联到生成的代码里业务线程在毫秒级的窗口期内就能完成旧地址到新地址的切换。整个堆的存活对象越多并发转移的耗时越长但业务线程不会被长时间阻塞。5.3 重映射阶段与“自愈”为什么第二次访问变快了ZGC最后一个阶段是重映射。重映射的目标是让整个堆中所有指针都指向最新地址消除那些“还需要读屏障修正”的旧指针。这个过程也是并发的。如果你仔细想过会发现“自愈”机制很漂亮当一个对象在转移阶段被访问到时旧引用会被更新成新引用。这个更新动作发生在业务线程的读取路径上非常自然。随着业务继续运行越来越多的旧引用会被修正等到重映射阶段正式开始时需要修复的引用数量可能已经大大减少。这也是为什么ZGC在多次运行后后一阶段的开销会显得更轻的原因。实际测试中你会发现ZGC在连续多次GC周期后单次周期内的STW片段非常短通常只有几个安全点Safepoint同步的时间。这些安全点的时间也不是ZGC自身算法导致的而是JVM其他机制比如偏向锁撤销、线程同步等带来的必要开销。如果你看到ZGC日志里停顿时间突然变高可以优先去查是不是安全点时间异常而不是把锅甩给ZGC。6. 配置建议与实战排查6.1 启用ZGC的参数与调优要点在JDK 15及以上版本ZGC已经正式支持生产环境不再标记为实验特性。JVM参数启用方式很简单java -XX:UseZGC -Xms16g -Xmx16g -jar your-app.jar如果是JDK 11到14之间的版本需要额外加一个实验参数开关java -XX:UnlockExperimentalVMOptions -XX:UseZGC -Xms16g -Xmx16g -jar your-app.jar从实践角度我有几条调优建议建议把-Xms和-Xmx设置为相同值避免堆自动伸缩带来的额外GC开销和地址重映射成本。ZGC本身是分代大内存友好型收集器固定堆大小能让它更稳定。关注大页配置。ZGC在大页Huge Pages场景下表现明显更好。Linux可以使用Transparent Huge PagesTHP或显式配置Huge Pages但要注意THP有时会带来内存分配延迟抖动建议先在测试环境压测验证。如果有多个CPU Socket建议开启NUMA感知。ZGC支持NUMA会让不同线程尽量访问本地内存减少跨CPU访问的延迟。通过-XX:UseNUMA开启。观察GC日志时建议开启详细日志java -XX:UseZGC -Xlog:gc*:filegc.log -jar your-app.jar日志里要重点看两个指标STW时间Pause Pause和进度上升情况。如果STW时间突然明显超过1ms通常不是ZGC算法本身的问题而是安全点、系统负载或内存分配失败导致的退化。6.2 常见异常与排查思路在实际使用ZGC时我踩过几个典型的“坑”分享出来供大家参考。第一个是“内存分配失败导致退化”。当堆内存严重不足ZGC来不及完成并发转移就会退化为STW的FULL GC这时候停顿时间会飙升。排查思路查看GC日志中是否有Allocation Stall或退化为GCLocker/FULL GC的记录。如果频繁出现说明堆大小或者对象分配速率与业务不匹配要么调大堆要么优化业务代码减少分配。第二个是“安全点时间异常”。ZGC本身停顿极低但JVM的全局安全点Safepoint如果被某些线程拖住一样会导致明显的停顿。可以通过-XX:PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount查看安全点日志。如果发现一个线程长时间占用安全点往往是因为偏向锁未撤销、JIT编译任务、或者某段JNI调用没有及时返回。第三个是“大对象分配压力”。ZGC对超大对象的分配和转移尤其是超过Region容量的大对象处理逻辑和普通对象不一样。实际场景中如果业务大量创建大数组、大缓冲ZGC的转移压力会显著增加。建议在代码层面拆细对象粒度或者检查是否有短生命周期的大对象频繁分配。6.3 什么场景适合ZGC我的一些选型判断标准最后聊聊选型判断。很多人一上来就问“ZGC是不是比G1好我要不要全部切到ZGC”我的回答通常是看你的核心诉求。如果业务对延迟极其敏感比如支付扣款、秒杀、行情推送、实时互动而堆内存又比较大比如8GB以上ZGC是非常好的选择。尤其是在G1已经出现“猪一般的长停顿”的Case里切换ZGC往往能收获立竿见影的效果。但如果你的业务场景是批处理、离线计算、吞吐优先的后台任务每条任务跑几分钟、对单次GC停顿不敏感Parallel GC或者G1在吞吐量上依然有优势。GC选型没有银弹只有是否匹配业务特征。用我个人的判断标准来说我会先问三个问题第一最长可接受的GC停顿是多少第二堆内存活对象占比大概多少第三CPU资源是否富余如果停顿需要打进10ms以内、堆比较大、CPU有多余资源ZGC几乎是最优选择。如果这几个前提不满足再仔细权衡一下G1、Shenandoah甚至Parallel GC都不迟。另外说句实在话ZGC的推广度在JDK 17以后已经是“生产可用”的状态。很多中等规模互联网公司已经在核心链路上跑ZGC而且日志里的停顿指标就是比G1稳定。我个人在长期运行的项目中已经用ZGC替换掉了部分G1集群最关键的变化是再也不需要因为GC停顿去预埋超时重试逻辑了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门