1. ZGC不是玄学:先聊它解决了什么问题
ZGC这个名字,这几年几乎已经成了低延迟 Java 服务的标配话题。每次一聊到 ZGC 实现原理,大家最先想到的往往是染色指针、读屏障这些名词,但我更想先聊聊它到底为了解决什么问题而来——因为不理解问题,你根本记不住那一堆机制背后的逻辑。ZGC 的全称是 Z Garbage Collector,由 Oracle 在 JDK 11 中以实验特性引入,JDK 15 正式转正。它的核心目标只有一个:让 GC 停顿时间尽量稳定在 1ms 以内,并且这个停顿时间不随堆大小线性增长。换句话讲,在 2GB 堆和 200GB 堆上,ZGC 的 STW 时间都应该维持在同一个量级,这才是它和传统回收器最本质的区别。
如果你是刚接触 ZGC,我建议先把“为什么需要新的垃圾回收器”这个问题想透。G1 在 JDK 9 之后成为默认回收器,已经比 Parallel Scavenge 的“全停顿”思路先进很多,但 G1 依然做不到亚毫秒级停顿,尤其是在大堆、高分配率的场景下,老年代混合回收带来的暂停经常是几十毫秒甚至上百毫秒。互联网在线服务的响应时间目标越来越苛刻,99 分位延迟要求 10ms 以内的系统越来越多,这就逼着 JVM 团队换一条技术路线。ZGC 就是这条新路线的代表。
1.1 为什么“低延迟”会成为 Java GC 的头号难题
想理解 ZGC,先要理解旧方案难在哪。最早的 Serial、Parallel 回收器都是“Stop The World”式清理,堆里所有线程先停下来,GC 线程把存活对象标记完、把垃圾清掉,再恢复业务线程。堆越大,标记和清理的对象越多,停顿就越长。CMS 试图用并发标记解决一部分问题,但它仍有初始标记、并发标记失败后的 Full GC,而且 CMS 的内存碎片问题在长期运行后非常头疼。G1 做了更细的 Region 划分,理论上可以只回收垃圾比例高的 Region,把停顿控制在一个相对可预测的范围,但 G1 要维护记忆集和卡表来追踪跨代引用,并发标记结束后仍然需要暂停做筛选回收、对象拷贝、引用修正。
你可以把 GC 想象成办公楼的大扫除。Parallel 的做法是所有人离开大楼,保洁一次性彻底清扫,干净是真干净,但整栋楼停摆。CMS 的做法是保洁先进场清扫,但扫完垃圾还得有人把家具归位,而且垃圾碎屑扫不干净。G1 的做法是把楼分成很多小房间,保洁只挑垃圾最多的房间清理,这样单次影响小很多,但为了让每个房间都知道“哪些东西被别人借用过”,它要维护一张超大的借还登记表,每次清理都先查表,查表本身也要花不少时间。ZGC 的思路更激进:干脆让保洁在大家工作的时候进场,谁在工作时碰到旧家具,谁就顺手按新位置碰一下,全程几乎不用让整栋楼停下来。
1.2 ZGC 的设计目标:停顿时间与堆大小无关
ZGC 的设计文档里写了非常明确的指标:停顿时间不超过 10ms,目标甚至做到 1ms 以内,并且不随堆大小、存活对象大小、堆线程数线性增长。它没有强调吞吐量要超过 G1,因为这是一个“以低延迟为首要目标”的回收器。它不是来取代 G1 的万金油,而是专门面向大堆、高并发、延迟敏感场景的专用武器。
这个目标决定了它的技术选择。标记要并发、对象移动要并发、引用修正也要尽量并发,所有真正耗时的工作都必须在业务线程运行的同时完成。但“引用修正”在传统 GC 里是最难并发做的事,因为业务线程可能在 GC 已经标记完某个对象之后,又突然访问到一个旧地址。ZGC 的解决办法是:不要把“这个指针已经过期”的信息单独存到某个全局表里,而是直接把状态编码到指针本身。于是就有了染色指针、读屏障、多重映射这一整套组合拳。接下来的第二部分,我把这三个基石逐个拆开讲,它们才是 ZGC 实现原理里真正硬核的部分。
2. ZGC 的三大技术基石:Region、染色指针、多重映射
很多人看完 ZGC 的论文后只记住了“Colored Pointer”这个英文词,但要真正理解它,你得把 Region、染色指针、多重映射放在一起看。这三者是一个整体:Region 解决“物理内存怎么管理”、染色指针解决“GC 信息放哪里”、多重映射解决“引用的虚拟地址怎么切换”。单拎任何一个出来讲都是片面的。
2.1 Region 布局:ZGC 的内存不再是一块大平板
ZGC 的堆和 G1 类似,也是基于 Region 的,但它不像 G1 把所有 Region 都分成差不多的“小块”,而是把 Region 分成 Small、Medium、Large 三种类型。Small 一般是 2MB,Medium 一般是 32MB,Large 则专门给超大对象使用,大小按照 2MB 对齐动态决定。这个设计的核心意义在于:大对象不会横跨多个 Region 导致回收时的扫描和拷贝成本暴涨,而小对象也能用足够小的粒度做回收筛选。
ZGC 的 Region 在启动时会根据堆大小动态决定大小。比如堆越大,Small/Medium 的标准也可以随之调整,目的就是让 Region 数量不会过多,也不会让每个 Region 的空闲浪费太多。和 G1 还有一个关键差别:ZGC 最初版本不分代,也就没有年轻代、老年代这些概念。所有 Region 一视同仁,GC 周期不再区分 Young GC 和 Mixed GC,而是统一做“并发转移”。不分代换来的是不需要为跨代引用维护卡表,这也是 ZGC 能去掉写屏障的重要原因。
2.2 染色指针:把标记信息直接塞进 64 位指针里
这是 ZGC 最反直觉、也最核心的设计。传统 GC 里,判断一个对象是否存活,通常要看对象头里的标记位。ZGC 直接在 64 位指针的高位里划出了 4 个 bit 做颜色位,低 42 位用来寻址,高位还剩下 18 位不用。也就是说,一个引用变量里存的不再光是“对象在哪个地址”,还额外带着 GC 要用的状态:Marked0、Marked1、Remapped、Finalizable 四个位。
这带来一个非常大的好处:判断对象状态时不需要访问堆内存。对象头的 Mark Word 在内存里,读它要访问缓存行;而染色指针的状态就在寄存器里、在引用变量里,读指针的同时就能知道这个对象当前处于什么状态。你可以把它理解成快递包裹上的电子面单——以前你要拆开箱子看里面有没有“已扫描”标签,现在面单上直接印着“已出库、待派送、已签收”,扫一眼就知道该走哪个流程。
ZGC 用 Marked0 和 Marked1 两个位来区分“上一轮标记”和“本轮标记”,这样并发标记过程中不需要清空所有对象的标记位,只需要在下一轮标记时切换使用另一个标记位。Remapped 位表示这个引用是否已经被修正到新地址。Finalizable 位则专门标记可终结对象。这套机制让 ZGC 能在并发环境下安全判断“这个引用是否需要处理”,而不用锁、不用 CAS、不用改对象头。
2.3 多重映射:一个物理内存域,三种虚拟视图
有染色指针还不够,因为虚拟地址是被程序直接使用的。如果 GC 想“批量”把所有引用标记从一个状态切到另一个状态,总不能遍历所有引用去改指针位。ZGC 借了操作系统虚拟内存的力:把同一块物理内存,同时映射到三个不同的虚拟地址空间。这三个空间分别对应 Marked0、Marked1、Remapped 三种颜色状态。业务线程手里的引用如果是 M0 视图,访问的物理内容其实和 M1 视图完全相同,只是地址前缀不一样。
这样,当 GC 需要从一个标记状态切到另一个状态时,只需要切换对应的页表映射,而不用扫描堆里的对象去改它们的指针。物理内存只有一份,虚拟地址却有三个入口,这就是多重映射。实现在 Linux 上依赖 mmap 和 memfd 之类的机制,也是 ZGC 目前不支持 32 位平台的根本原因——32 位地址空间根本塞不下这么多“颜色”。理解了这三块基石,再看 ZGC 的一个完整 GC 周期,所有阶段就都能串起来了。
3. 一次完整的 ZGC 周期到底在忙什么
ZGC 的一个 GC 周期看起来很长,因为它把传统 GC 的“暂停”分散成了多个短暂停和多个并发阶段。整个周期里有三个 STW 暂停点:Pause Mark Start、Pause Mark End、Pause Relocate Start,除此之外全是并发。你仔细观察就会发现,这三个暂停点做的事情都非常“薄”,不涉及大规模对象扫描和拷贝,所以才能真正把暂停时间压到 1ms 附近。
3.1 ZGC 周期的三处暂停和四个并发阶段
完整的 ZGC 周期可以粗分为:并发标记、并发准备重分配、并发重分配、并发重映射。流程可以这样记:
- Pause Mark Start:STW,短暂停。初始化本轮标记,切换 Marked0/Marked1 位,确定根集合扫描的起点。
- 并发标记 Concurrent Mark:业务线程照常运行,GC 线程并发遍历对象图,把存活对象标记到当前使用的标记位。
- Pause Mark End:STW,短暂停。收尾本轮标记,处理并发标记过程中积压的标记队列,最后确认存活对象集合。
- 并发预备重分配 Concurrent Prepare Relocation:分析每个 Region 的存活对象占比,挑选出一批需要转移的 Region,构成转移集,并规划对象要搬到哪里去。
- Pause Relocate Start:STW,短暂停。正式启动重分配,把转移集发布给所有线程,让读屏障可以识别“这个地址已经属于转移集”。
- 并发重分配 Concurrent Relocate:GC 线程把转移集中的存活对象拷贝到新 Region,同时修正引用。
- 并发重映射 Concurrent Remap:修正所有指向旧 Region 的引用,让它们指向新地址,为下一轮 GC 周期做准备。
实际 JVM 实现里,重映射阶段经常会被合并到下一轮标记阶段里,目的是减少一次“周期尾巴”的开销。你可以把它理解成保洁扫完地后,把垃圾袋放门口,等第二天收垃圾的人一起来拿走。不同 JDK 版本的实现细节略有差别,但整体框架是稳定的。
3.2 并发标记:为什么不需要 Stop The World
标记动作本身是最耗时的,ZGC 把它做成并发。GC 线程从根集合出发,沿着对象引用往下走,但业务线程同时也在跑,可能正在修改对象图。ZGC 怎么做才不漏标?这里有两个关键机制。第一,对于已经访问过的对象,用一个标记位记录,这个标记位就是染色指针里的颜色位。第二,如果业务线程在并发标记期间,把一个新对象赋值给某个字段,这个新对象必须保证被标记到,否则下一轮就会被误回收。
新分配的“活对象”怎么保证被标记?ZGC 会利用屏障机制,确保新对象要么在分配时直接设置为“已标记”状态,要么在访问时通过读屏障发现它的标记状态不对,顺手把它标上。这里有一个很重要的细节:ZGC 并不维护 RSet 或卡表这类跨代引用信息,因此并发标记阶段不需要像 G1 那样更新大量卡片,标记过程的压力小很多。标记结束后,Pause Mark End 里主要处理的是那些“并发标记过程中还在不断变化的根引用”,比如线程栈、寄存器里的引用,JVM 必须在这个短暂停里协调各个线程把这些根引用标记完毕。
3.3 转移集与并发重分配:ZGC 如何“边用边搬”
Prepare Relocation 阶段的核心是选择转移集。ZGC 不会回收所有垃圾 Region,而是挑出一部分“性价比高”的 Region,优先回收存活对象很少、垃圾很多、搬起来成本低的 Region。Region 的存活率是通过标记阶段统计出来的,如果某个 Region 里大部分对象都是垃圾,那把它整个搬空再释放,收益最大。反之,如果一个 Region 存活对象占 90%,搬它就是在给自己找麻烦,不如不动。
并发重分配阶段里,GC 线程把转移集中的对象搬到新的 Region,但业务线程可能正在读这些对象。读屏障就成了唯一的“门卫”:每次业务线程读取一个引用,它都会检查这个引用指向的地址是否属于转移集。如果属于转移集,并且该对象已经移动过了,那读屏障就直接返回“指向新地址的转发指针”,而不是让线程去读旧内存。更巧妙的是,读屏障拿到新地址后还会顺手把当前字段里的旧引用修正成新地址,这个动作叫“自愈”。第一次访问旧对象会有一次额外开销,后续再访问就已经是修正后的新地址了,等于把修正引用的成本摊到了每一次真实访问上,而不是集中在一个 STW 阶段里。
3.4 从日志看真实暂停:别被“亚毫秒”神话带偏
我见过不少团队一听到 ZGC“亚毫秒停顿”,就以为线上 GC 日志里所有暂停都会小于 1ms,结果一上线看到偶尔有几毫秒暂停就开始慌张。真实场景里,ZGC 的平均暂停确实可以做到 0.1ms 到 0.3ms,但以下几个因素会把暂停抬高:GC 线程数量不足、机器上其他进程抢占 CPU、大页配置没做、分配尖峰太猛导致并发回收跟不上。我在压测环境里实测过同一个服务,Xmx 32G、机器 32 核,GC 日志里 Pause Mark Start 长期稳定在 0.1ms 左右,但有一次把服务部署到虚拟化环境后,因为 vCPU 争抢,暂停直接跳到 4ms。所以 ZGC 的“与堆大小无关”说的是算法复杂度,不是物理机环境。真正评估的时候,永远要拿你自己的 GC 日志说话。
4. 读屏障:ZGC 的“哨兵”是怎么工作的
读屏障是 ZGC 所有机制里对业务代码影响最直接的部分。传统 GC 里你听说过写屏障,因为卡表更新靠写屏障;到了 ZGC,写屏障基本退出舞台,取而代之的是读屏障。每次 Java 代码从堆里加载一个引用类型的字段,JIT 编译后的机器码里都会被插入一段检查逻辑,用来判断这个引用是否“颜色正确”。
4.1 一次引用读取,读屏障做了什么
假设业务代码里有这么一行:User user = map.get(key);。JIT 得到这个引用值后,不是直接返回,而是先做一次快速检查。ZGC 的快路径非常轻量,通常是几条位运算指令:把引用值的高位颜色部分抠出来,和当前 GC 阶段的“正常颜色”做比较。如果一致,说明这个引用不需要处理,直接使用,这是绝大多数情况。如果不一致,说明这个引用要么是指向已移动对象的旧地址,要么是个还没标记的新对象,这时候就进入慢路径:调用 ZGC 的运行时函数,判断是否需要走转发、是否需要标记,然后返回正确地址。
这个过程听着复杂,但因为绝大多数引用的颜色都是对的,快路径几乎不产生分支预测失败。ZGC 的 CPU 开销比 G1 高,主要就高在这条路径上——每读一个引用,都要多做几次位运算。你可以把它想成小区门禁:业主平时刷卡,闸机秒开,几乎感觉不到存在,但闸机一直在那;如果每个业主进门都要停 0.1 秒验证身份,那高峰期就堵死了。ZGC 需要你把“刷卡”做得足够便宜。
4.2 为什么 ZGC 敢不维护 RSet 和卡表
G1 之所以需要写屏障,是因为它要维护跨代引用:年轻代对象可能被老年代对象引用,如果只回收年轻代,就必须知道老年代里有谁指向了年轻代。这个过程用的数据结构就是记忆集和卡表,写屏障的作用就是在每次字段赋值时,把“这里被修改了”记录下来。维护的成本不小,而且并发标记结束后,还得花时间扫描这些脏卡。
ZGC 最初不分代,不存在“只收集年轻代、保留老年代”的诉求,所以完全不需要跨代引用表。它靠染色指针和读屏障就够:标记时,每个引用都能从指针颜色看出状态;访问时,读屏障能修正旧地址;移动时,转发指针让正在运行的业务线程永远拿到最新地址。少了 RSet 这个复杂结构,ZGC 的并发标记阶段少了一大块压力,也少了很多因为 RSet 维护导致的 STW 抖动。这也是为什么 ZGC 的实现看起来“单薄”,实际效果却非常稳。
4.3 读屏障的代价:CPU 占用与自愈机制
任何事情都有代价,读屏障的代价是额外的 CPU 指令。在大量引用密集访问的程序里,ZGC 的吞吐量会比 G1 低几个百分点,这是很正常的,也是官网文档都承认的。为了避免每个引用都进慢路径,ZGC 在 JVM 内部做了不少优化。最核心的就是“自愈”:当慢路径发现一个引用是旧地址,它会把它修正为新的引用地址,写回原来的字段。这样同一个字段第二次读取时就走快路径了。对于栈中的引用,某些情况下也能在安全点同步修正。
还有一点值得注意:ZGC 的标记位设计让它可以“零标记清空”地进入下一轮。传统 GC 每次 GC 开始时要把上一轮的标记位清掉,或者用优雅的位切换机制。ZGC 直接用 M0、M1 两个位轮流当“当前标记位”,这一轮用 M0,下一轮用 M1,标记状态无需清空。这个细节是“环境友好”设计里很有意思的一个例子:不是靠更高频率清扫来解决脏,而是靠轮流换抹布。
5. ZGC 与 G1、Shenandoah 的选型对比:到底该用谁
每次讲完 ZGC 原理,总有人会问:“那我现在是不是应该把线上所有 Java 服务都换成 ZGC?”我的答案永远是一句话:先看你压不压得住读屏障的额外开销。ZGC 不是万金油,G1 在很长一段时间里依然是默认选择,是有原因的。这一节我放下术语,直接用选型视角对比三款主流回收器。
5.1 G1、ZGC、Shenandoah 核心差异一览
| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 默认状态 | JDK 9 起默认 | JDK 15 正式支持 | OpenJDK 12 引入,非默认 |
| 分代 | 分代,有 Young/Old | 默认不分代,JDK 21 起有实验性分代 ZGC | 默认不分代,后续有实验分代 |
| 跨代引用跟踪 | 记忆集 RSet + 卡表 | 不需要,染色指针 | 不需要,转发指针 |
| Region 粒度 | 动态大小 Region | Small/Medium/Large 三类 | Region 机制 |
| 核心屏障 | 写屏障为主 | 读屏障为主 | 读屏障为主 |
| 最大亮点 | 吞吐量较好、成熟稳定 | 暂停极短、与堆大小无关 | 并发移动、低延迟、无需多重映射 |
| 主要代价 | 大堆暂停不可控 | CPU 额外开销、不适合小堆 | 对象头多一个转发指针字段 |
Shenandoah 思路和 ZGC 很像,都是并发移动对象、用读屏障修正引用。区别在于 Shenandoah 在对象头里加了一个转发指针字段,访问时需要先读对象头取真实地址;ZGC 则是把状态放到染色指针里,访问前就能判断。Shenandoah 的优势是不依赖操作系统多重映射,实现上更“纯净”,在某些非 Linux 平台或者地址空间受限环境下可能更友好;劣势是对象头变大,且每次访问多一次间接跳转的开销。两者都没有打赢 G1 这种“全场景”回收器,但都在低延迟赛道做得很极致。
5.2 什么样的情况建议换 ZGC
如果你遇到下面这几类问题,ZGC 会很值得试:
- 堆内存很大,比如 32GB 以上,G1 的老年代混合回收暂停已经影响业务。
- 服务对 P99 延迟极度敏感,要求单次 GC 暂停稳定在 10ms 以内。
- 大量长存活对象,老年代持续性增长,CMS/G1 的并发阶段频繁触发。
- 业务线程数量多,或者对象分配率高,但 CPU 资源还有余量可以“买”低延迟。
反过来,如果堆只有 2GB、业务又是离线批处理,追求的是总吞吐量,那 ZGC 的读屏障开销只会拖后腿。别为了“新技术”三个字就强行上 ZGC,先测量,再切换,这是我在很多项目里踩出来的经验。
5.3 分代 ZGC 是怎么回事
ZGC 最初不分代有一个副作用:每次 GC 都要全堆标记和扫描,即使大多数对象都是短命的年轻对象,也逃不掉全堆遍历。这让 ZGC 在典型 Web 服务的短生命周期对象场景里不如 G1 高效。JDK 21 引入了实验性的分代 ZGC,思路是用 ZGC 的机制实现年轻代和老年代分离,把“高分配率”的短板补上。分代 ZGC 依然保留染色指针和读屏障,但增加了不同代际之间的交互处理。需要注意的是这个特性目前依然是实验性的,不要在生产环境无脑开启,除非你所在的 JDK 版本已经明确标注支持且经过了充分压测。
6. ZGC 常见调优参数与 GC 日志分析实录
ZGC 的“开箱即用”程度比 G1 高很多,默认参数通常已经不错。但这不代表你完全不用管它。这一部分我列出最常用的启动参数,再贴一段我实际压测时收集的 GC 日志做解读,最后讲一个排查“暂停突然变长”的真实案例。
6.1 关键启动参数速查
启用 ZGC 最简单的是这样的启动命令:
java -XX:+UseZGC -Xms32g -Xmx32g -jar app.jar有几个参数值得单独说。-Xms和-Xmx我强烈建议设成一样的,一方面避免运行期堆扩缩带来的不稳定性,另一方面 ZGC 基于 Region 和地址映射,提前把堆大小固定住更容易让操作系统做好大页和内存布局。-XX:ZCollectionInterval=120表示距离上一次 GC 超过 120 秒就主动触发一次,避免堆内存严重浪费;-XX:ZAllocationSpikeTolerance=2.0是分配尖峰容忍度,越高代表越倾向于提前回收,防止突然的分配尖峰导致线程卡在分配上;-XX:ZUncommitDelay=300控制堆内存归还操作系统的延迟,默认 300 秒,越小越积极地还给系统,但频繁 uncommit 也会带来额外开销。
如果机器是多路 CPU 或 NUMA 架构,还可以开启-XX:+UseNUMA,让 ZGC 尽量分配本地内存,降低跨 CPU 访问延迟。GC 线程数量默认根据 CPU 核数调整,但如果服务本身业务线程非常多,可以手动调-XX:ConcGCThreads来保证并发回收速度跟得上分配速度。注意 ConcGCThreads 也不是越大越好,GC 线程多了会和业务线程抢 CPU,反而拖累吞吐量。
6.2 把一条 ZGC 日志拆开看
下面是我在一台 32 核、32G 堆的压测机上收集到的日志片段,经过脱敏后保留了关键字段:
[2025-05-10T10:00:02.123+0800][info][gc,start] GC(0) Garbage Collection (Allocation Rate) [2025-05-10T10:00:02.124+0800][info][gc,phases] GC(0) Pause Mark Start 0.086ms [2025-05-10T10:00:02.135+0800][info][gc,phases] GC(0) Concurrent Mark 11.231ms [2025-05-10T10:00:02.136+0800][info][gc,phases] GC(0) Pause Mark End 0.112ms [2025-05-10T10:00:02.150+0800][info][gc,phases] GC(0) Concurrent Prepare Relocation 14.524ms [2025-05-10T10:00:02.151+0800][info][gc,phases] GC(0) Pause Relocate Start 0.141ms [2025-05-10T10:00:02.170+0800][info][gc,phases] GC(0) Concurrent Relocate 19.376ms [2025-05-10T10:00:02.171+0800][info][gc,phases] GC(0) Concurrent Remap 1.852ms [2025-05-10T10:00:02.171+0800][info][gc,stats] Allocation Rate: 156 MB/s注意这段日志里 Pause Mark Start、Pause Mark End、Pause Relocate Start 三个阶段都不到 0.2ms,这才是 ZGC 正常状态。Concurrent Mark 和 Concurrent Relocate 虽然耗时十几毫秒,但这是并发进行的,业务线程并不会被阻塞,只有触发了分配等待或者安全点的时候才会有感知。真正需要警惕的数字是 Allocation Rate,如果它长时间超过回收速率,那么堆会迅速被耗尽,你会看到很多线程进入 Allocation Stall,表现为线程卡顿而不是 GC 暂停。
6.3 排查案例:为什么 ZGC 的暂停突然从 0.1ms 涨到 8ms
有次线上服务启用了 ZGC,头两天 GC 日志非常漂亮,第三天开始每天早上都有一波 5-8ms 的全线程暂停。团队的直觉是 ZGC 出了问题,但看了 GC 日志,三个 STW 阶段依然在 0.2ms 左右。真正的问题出现在安全点时间标签里:GC pause 日志里多了一行 Safepoint 相关的长耗时。那是 JIT 在编译某个超大方法时触发的安全点,和 ZGC 本身没关系。
这个案例想说明一件事:排查暂停问题,必须有全局视角。ZGC 只控制了 GC 暂停,控制不了操作系统调度延迟、控制不了 JIT 编译器安全点、控制不了其他进程抢占 CPU。遇到长暂停,先分清楚时间到底花在哪个环节:看 GC 日志里的 phases 和 safepoint 明细,再用jcmd <pid> VM.native_memory看内存变化,用perf看是不是 CPU 被偷走了。不要一上来就调ConcGCThreads,方向错了越调越差。
6.4 避坑清单与个人体会
- 别把小堆硬切 ZGC。堆 1GB 以下时,G1 或 Parallel 的综合表现往往更好,ZGC 读屏障的开销占比太明显。
- 别忘记配大页。ZGC 对
-XX:+UseLargePages的支持很到位,透明大页能明显改善 TLB 命中率,对延迟和吞吐量都有帮助。 - 别把 ZGC 的“并发”理解为“零暂停”。GC 周期之间的高分配率仍然可能造成分配等待,本质是负载超过了回收能力。
- 别只用平均延迟做验证。低延迟系统的目标要看 P99、P999,GC 日志里的 pause 只是其中一个因子。
- 多关注 ZGC 的
gc+stats日志,里面的 Allocation Rate、Reclaimed、Relocation 是判断健康度的核心指标。
我在实际运维中最大的感受是:ZGC 不是拿来吹嘘的“黑魔法”,它是一套环环相扣的工程方案。Region 定义了物理布局,染色指针定义了状态载体,多重映射解决了地址切换,读屏障解决了并发访问,四个机制缺一不可。理解了这些之后,再去调参、看日志、排查问题,都会变得顺理成章。最后再分享一个小技巧:切换 GC 前先在一个非核心服务上跑两三天,全程开-Xlog:gc*:file=gc.log,把每天的暂停分布拉出来看,再对比 G1 的同指标数据。数据会说真话,而 ZGC 的“低延迟”只有在它真正适合你的场景时,才会变成你想要的惊喜。