ARTICLE DETAIL

资讯详情

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

ZGC调优实战:把GC停顿压到0.5ms以内的完整方案

ZGC调优实战:把GC停顿压到0.5ms以内的完整方案 聊起JVM调优很多人第一反应是看堆大小、调GC参数但真正能把停顿时间压到极致的团队并不多。ZGC从JDK 11亮相开始就顶着低延迟神器的光环官方设计目标是停顿不超过10ms可大多数生产环境用起来实际停顿能稳定在1ms上下已经算不错了。我最近在一批高并发在线服务上做了完整的ZGC调优把GC引起的暂停从3~5ms逐步压到了0.5ms以内整个过程踩了不少坑也验证了几个关键判断。这篇就把完整的思路、参数依据、实测过程和一些容易翻车的细节整理出来想直接把ZGC用明白的朋友可以直接照着走。先说结论ZGC停顿压到0.5ms以下完全可行但前提是你得搞清楚停顿到底从哪来。ZGC的STW阶段本来就很少、很短大部分问题出在配置不合理、GC Roots扫描过重、以及系统层面的内存分配上。下面我会把这几个方向挨个拆开讲。1. 为什么ZGC能实现超低停顿先把原理吃透再说调优1.1 着色指针和读屏障推翻标记-复制的老账本要理解ZGC为什么能把停顿压这么低得先看它和传统GC的根本区别。传统垃圾回收器比如CMS、G1在做对象转移移动时必须在一个安全点暂停所有业务线程然后统一完成对象复制和引用修正。这个暂停的时间和存活对象数量直接相关堆越大、对象越多停顿越长。G1的RSet在维护跨区引用时也有不小的开销这是它无法做到亚毫秒级停顿的根本原因。ZGC的颠覆性设计是着色指针Colored Pointer。简单说ZGC把64位指针里的高4位拿出来当作元数据位分别标记对象的Finalizable、Remapped、Marked0、Marked1状态。这样对象有没有被标记、需不需要重映射就不用去对象头里查直接从指针本身就能判断。搭配读屏障Load Barrier业务线程在读取对象引用时如果发现指针状态处于需要修正的中间态会顺路把引用更新成最新地址再返回给调用方。这笔顺路费摊到了每次读操作上换来的代价是不需要全局停顿去修正引用。这就好比图书馆里要搬书架传统做法是先喊所有人停下手里的书站到过道里搬完再各自回去继续读。ZGC的做法是每个人拿书的时候看一眼书脊标签如果标签显示这书该挪到新书架了那就自己顺手从新书架拿老书架的书签当场作废。读者没被强制暂停只是每个人多花了零点几秒确认标签而已。1.2 ZGC到底在哪个环节停顿STW舞台上的少数派很多第一次接触ZGC的朋友以为它是零停顿的这是误解。ZGC只是把停顿次数和停顿时间压缩到了极小范围。下面这张表是ZGC在以JDK 17为代表的非分代版本中的主要阶段以及哪些阶段会STWGC阶段是否STW耗时特征说明初始标记Pause Mark Start是通常0.1~1ms标记GC Roots线程栈、JNI句柄、Class指针等并发标记Concurrent Mark否和存活对象量、堆大小相关从根对象出发遍历引用图业务线程并行运行并发预备重分配Concurrent Prepare Relocation否短毫秒到数十毫秒确定重分配集建立转发表并发重分配Concurrent Relocate否与需要移动的对象量相关搬对象同时用读屏障修正后续访问最终标记Pause Mark End是通常0.5~3ms处理并发标记阶段的遗留类卸载、引用处理等清理Pause Cleanup是通常0.1~1ms清理不用的堆内存资源算下来一个典型的ZGC回收周期里STW只发生在初始标记和最终标记以及清理这几个环节。剩下的并发标记、并发重分配都是GC线程和业务线程同时跑的。换句话说ZGC的设计哲学是把原来全程暂停的活变成大家顺便搭把手只留下一个非常短的开会时间让大家把手里的引子说明白。说到JVM内存模型大家通常关注的是线程私有的栈区、程序计数器以及线程共享的堆区和元空间。ZGC的调优本质上就是调整这几块空间的协作方式堆给大了并发回收周期变长给太小分配指针撞墙触发分配停滞。这也是接下来所有参数调整的内在逻辑。1.3 低停顿不是玄学ZGC的适用场景与边界ZGC适合的场景有几个明显特征堆内存比较大至少4GB以上推荐16GB以上、延迟敏感要求P99稳定、服务端负载起伏明显、业务线程多而不追求极致吞吐。如果你的应用堆只有几百MBZGC的着色指针和读屏障带来的额外CPU开销反而得不偿失这属于典型的杀鸡用牛刀。官方也标注过ZGC在吞吐量上会比Parallel GC低个位数到十来个百分比因为每次对象访问都要过一遍读屏障。另一个要注意的是分配速率。如果业务代码里大量产生临时对象每秒分配量达到GB级别那么即使ZGC并发再努力也可能出现回收速度赶不上分配速度的分配停滞Allocation Stall。这种停滞外部表现跟STW一样应用程序就像被卡住了一样轻则几十毫秒重则上百毫秒。后面我会专门讲这个坑。2. 调优前的准备把0.5ms以下这个目标翻译成可执行的参数2.1 先定基准停顿到底怎么量在动手调参之前先把停顿这件事量化。我们常说的GC停顿在ZGC语境下有两个口径GC View停顿GC pauseGC日志里记录的Pause Mark Start、Pause Mark End等事件的耗时。只包含JVM内部的STW时间。业务线程停顿Mutator Stall / Allocation Stall业务线程因为等待GC资源而实际被挂起的时间包括分配停滞和读屏障触发的等待。这个从GC日志里也能看到会出现Allocation Stall的记录。调优目标是业务接口的P99延迟而不是单纯看GC日志里的pause数字。如果GC pause已经0.2ms但业务线程因为分配停滞卡了5ms那等于白调。所以我做基准的方法通常有两层在测试环境挂上JFRJDK Flight Recorder和GC日志用压测流量跑15分钟看jdk.GCPhasePause和jdk.ZGarbageCollection事件。在压测端记录请求延迟分位数对比GC参数调整前后的P99变化以这个为准。每轮压测前要做的不是直接改参数而是先把当前ZGC数据捞出来晒一晒GC周期频率、单周期STW总时长、并发标记平均耗时、分配停滞次数。没有这组数据后面所有参数调整都是在拍脑袋。2.2 基础参数先摆平堆大小、JDK版本、GC日志一个都不能少要想压到0.5ms以下有几个地基必须打牢。首先是JDK版本。ZGC在JDK 11才作为实验特性引入JDK 15转正到JDK 17已经比较成熟JDK 21引入了分代ZGCGenerational ZGC。我的建议是直接用JDK 17或21。如果你还在JDK 11或者12上跑ZGC那有很多bug和性能问题不是靠调优参数能救回来的。接下来是堆内存的初始值和最大值。老生常谈的原则在ZGC这里依然适用尽量让-Xms等于-Xmx。ZGC虽然支持动态伸缩堆但频繁扩缩堆带来的系统调用、内存映射操作在极端条件下会产生额外停顿。直接把堆固定住省掉这部分麻烦。然后是GC日志。ZGC的日志开关和G1不太一样规范的打开方式是-Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount5,filesize20m这里解释下拆解方式gc*是日志标签表示捕获GC所有子标签的信息后面的冒号依次是输出目标文件、log参数时间、运行时间、级别、标签、以及轮转策略保留5个文件每个不超过20MB。压测时我会把gcstats也开起来这样能看到每次ZGC周期的详细统计数据。-XX:PrintGCDetails这类老参数在JDK 11已经废弃了别再用。基础参数面板整理如下供直接抄参数推荐值作用注意事项-Xms / -Xmx和服务内存预算匹配且两者相等固定堆大小避免动态伸缩抖动堆不能超过物理内存可分配范围-XX:UseZGC启用ZGC无争议JDK 15不需要加-XX:UnlockExperimentalVMOptions-XX:ParallelGCThreads默认即可控制并行STW阶段的线程数线程数超过必要值会加重根扫描竞争-XX:ConcGCThreads默认或适当增加控制并发GC线程数上文分析的核心参数之一-XX:SoftMaxHeapSize等于-Xmx或稍低软性堆上限默认等于-Xmx不建议随便调低-Xlog:gc*...按需开启采集停顿数据生产环境务必开日志轮转2.3 目标拆解0.5ms是一次STW还是整周期这里有一个必须说清楚的定义问题。标题里的0.5ms在实际工程里我一般定义为单次业务停顿GC pause allocation stall不超过0.5ms同时整个压测周期的P99不超过0.5ms。这两者是有本质区别的。如果只看单次STWZGC的初始标记最终标记清理加起来通常也在0.5~1.5ms左右压到0.5ms以下是可能的但需要精细化调。如果看P99那就要求GC周期的频率不能太高分配停滞必须清零系统层面的抖动比如大页分配、NUMA跨节点访问也不能拖后腿。本文的目标是双管齐下先让单次STW降到0.5ms以内再通过节奏控制让业务侧P99稳定在0.5ms以内。3. 核心调优实操从GC Roots到并发节奏的逐个击破3.1 GC Roots扫描0.3ms停顿的胜负手回到最初的原理。ZGC的初始标记Pause Mark Start要扫描的就是GC Roots这里面最重的项通常是线程栈。一个服务动辄几十个业务线程每个线程栈里有多少局部变量、多少个方法帧直接决定扫描时间。当初我们服务有40个业务线程初始标记稳定在0.4~0.8ms后来优化了一下线程池大小和栈深度这个阶段降到了0.15ms左右。具体做法有几个减少线程数但别少于CPU核心数。线程池不是越大越好线程栈扫描是逐线程进行的线程越多初始标记越慢。我们当时把一个混合线程池从64个线程压到32个初始标记肉眼可见地下降了。当然这是在确认无连接积压的前提下做的。控制栈深度。深递归调用不仅占用栈空间也让根扫描遍历深度变长。排查业务代码里有没有深递归、超大循环链对根扫描的影响很直接。留意JNI本地帧。如果用了JNIJNI本地帧里的引用也要扫描这块往往不可控。能不用JNI就尽量别用实在要用的确保引用句柄的回收及时。第二个和根扫描强相关的是JVM内部结构比如类加载器、JNI全局句柄等。类卸载在ZGC最终标记阶段会处理但如果你的应用是万物皆动态代理的框架结构不断生成新类那么每次GC周期都要处理大量类和类加载器停顿想压也压不下来。条件允许时关闭不需要的类卸载功能-XX:-ClassUnloading可以省掉一大块工作但会带来永久代元空间增长一般不建议生产环境这么干。3.2 ConcGCThreads与ParallelGCThreads并发线程参数的正确姿势ZGC的调优参数看着少但实际上有一个CPU资源分配的核心矛盾并发线程和业务线程抢CPU。GC线程给多了并发标记跑得快但业务线程被挤得卡顿给少了并发标记跟不上分配速度触发分配停滞延迟照样爆炸。这里给出一套我在实践中验证过的设置逻辑-XX:ParallelGCThreads这个参数控制STW阶段初始标记、最终标记的并行线程数。它默认值是CPU核心数乘以某个比例但在物理核数不超过32的机器上默认值其实够用。我一般不建议动它除非你的机器是超线程特别明显的云主机出现大量逻辑核但实际物理核少这时可以把它压到物理核数。-XX:ConcGCThreads控制并发GC线程数默认值通常是CPU核数的1/8左右。对低延迟目标来说这个值往往偏保守。我实测下来在16核机器上从默认的2调高到4并发标记时间能缩短接近三分之一而对业务线程的CPU抢占并不明显。但要警惕超过CPU核数/4后收益就消失了反而因为GC线程和业务线程激烈争抢P99开始恶化。为什么有个最佳区间因为并发标记本身不是无限可并行化的它内部有任务队列、有同步同步点。线程数超过并行粒度大部分时间花在锁同步上就变成负优化了。所以调ConcGCThreads的正确姿势是用压测观察并发标记耗时曲线找到拐点再往回调一档留出安全余量。另外给容器环境的朋友一个提醒容器内的CPU核数是JVM通过os::active_processor_count探测的依赖cgroup限制是否透传。很多容器平台没配好JVM以为自己在128核的宿主机上结果ConcGCThreads按128核的八分之一算反而开出了16个GC线程争抢业务CPU。遇到这类问题可以用-XX:ActiveProcessorCount可用的CPU核数来显式指定效果立竿见影。3.3 分配节奏控制ZAllocationSpikeTolerance和SoftMaxHeapSize的秘密说完CPU参数再说内存分配节奏。ZGC内部有一个概念叫分配尖刺容忍度对应的参数是-XX:ZAllocationSpikeTolerance默认值为2.0。这个值用来调整GC周期触发的提前量值越大ZGC越容易恐慌会更早、更频繁地启动并发回收好留出更多余量应对突发的分配尖峰值越小GC周期越平滑、频率越低但遇到分配高峰时更容易出现分配停滞。这个参数怎么选如果业务流量是平稳型的比如内部数据同步服务建议把ZAllocationSpikeTolerance从2.0降到1.5甚至1.2让GC周期拉长STW总次数减少。如果业务有秒杀、抢购这种瞬时高峰保持2.0甚至调高到3.0更安全宁可多几次并发标记不STW或者STW极短也别等到分配停滞。再来说-XX:SoftMaxHeapSize。这个参数有意思它约等于建议堆上限默认和-Xmx一样。调低它可以让ZGC在接近这个上限之前就主动开始回收避免堆使用率触顶后出现紧急回收。这就像仓储管理货架还剩10%空间时才紧急清理和货架剩30%时开始清理体验天差地别。如果你的服务内存有富余可以试试把它设为-Xmx的80%左右配合压测观察分配停滞是否清零。0.5ms这个目标里分配停滞绝对是最需要警惕的敌人。只要出现一次Allocation Stall前面所有STW优化都白搭。所以我的原则是先保证分配停滞清零再去抠STW的每0.1ms。3.4 大页与NUMA优化把系统层停顿也按下去JVM参数只是ZGC停顿的一部分系统层面有两大隐形杀手内存页分配和NUMA跨节点访问。先说大页。ZGC的着色指针机制依赖操作系统的内存映射小页通常4KB意味着TLB缓存命中率低指针操作时会有额外的地址转换开销。启用大页通常2MB能显著减少TLB miss对ZGC的标记、重分配效率都有好处。但这里有个明显的坑别用Linux的透明大页THP要用显式大页。THP虽然零配置但它在后台有khugepaged线程做内存整理这个整理过程可能引起不可控的毛刺停顿对延迟极度不友好。显式大页配置步骤大致如下# 配置系统大页数量假设每页2MB需要1GB即512个大页 sysctl -w vm.nr_hugepages512 # 挂载大页文件系统并分配配额 mkdir -p /dev/hugepages mount -t hugetlbfs -o pagesize2M,size1G hugetlbfs /dev/hugepages # 检查是否成功 grep Huge /proc/meminfoJVM侧加上-XX:UseLargePagesJDK 17也可用-XX:UseZLargePages启动时观察日志确认Large Pages生效。实测下来显式大页配合ZGC并发重分配的耗时能下降百分之二三十GC pause也更平滑。再讲NUMA。在多路服务器上CPU访问本地内存和跨节点内存的延迟差异明显通常跨节点延迟多出20%~40%。JVM默认会启用NUMA感知-XX:UseNUMA但如果堆内存没有通过numactl --interleaveall设置交错分配ZGC在分配和搬运对象时可能反复跨节点访问增加停顿和CPU开销。我们的实践是对于GC延迟敏感的服务用numactl --interleaveall启动JVM让内存尽量均摊到各个NUMA节点GC线程访问哪块内存都不至于太吃亏。这一步带来的收益不一定每次都能看到但NL-SMP架构下值得百分之百试一下。3.5 分代ZGCJDK 21带来的额外红利如果你能用上JDK 21分代ZGC是一个几乎白送的加速项。分代ZGC把堆分成年轻代和老年代年轻代回收频率远高于老年代但每次回收的STW时间更短、并发标记范围更小。官方数据是分代ZGC能把吞吐量提升接近G1水平同时保持亚毫秒级停顿。对0.5ms目标来说分代ZGC让年轻对象频繁分配这类压力不再需要全堆并发标记来消化直接击中了痛点。开启方式极其简单JDK 21直接-XX:UseZGC -XX:ZGenerational注意分代ZGC目前有一些限制比如不支持显式大页的部分组合、某些监控数据通过jstat看不到。但大部分线上业务用起来收益大于限制。还有一个与实际场景相关的参数-XX:ZCollectionInterval。它设置ZGC主动发起回收的最长间隔默认是0不强制。如果业务有明显的大对象周期性分配适当设一个不低于业务浪涌周期的值比如5s可以让GC节奏和业务节奏对齐避免峰值前搞回收峰值时顶不住的窘境。4. 实战记录一个5ms停顿服务压到0.4ms的完整过程4.1 初始状态与问题定位有段时间我们内部的一个订单查询服务P99一直不稳定监控显示的GC停顿偶尔到5ms。现场情况是这样的16核64GB容器JDK 17堆设成32GB-Xms等于-XmxZGC默认参数GC日志里Pause Mark End偶发3.5msAllocation Stall时不时冒出来一次每次60~120ms。业务线程64个流量白天高、晚上低波动明显。初步判断方向有两个一是STW单点有点高3.5ms远超0.5ms目标二是分配停滞频繁是主要矛盾。先解决分配停滞再回来压STW。分配停滞为什么会出现原因很直接堆虽然32GB但ZGC的并发标记进度赶不上业务的分配速度。GC日志里能看到Concurrent Mark阶段耗时长、周期频繁说明并发线程数在默认值下不够用。另外32GB堆在这个负载下偏大因为堆越大一次并发标记需要遍历的存活对象越多周期越长反而更容易在高峰期撞上分配停滞。堆大不一定是好事ZGC里堆大小和分配速率之间需要动态平衡。4.2 参数调整三步走的完整记录第一步解决分配停滞调整并发节奏把-XX:ConcGCThreads从默认的2调到了4-XX:ZAllocationSpikeTolerance保持2.0加-XX:SoftMaxHeapSize24G32GB物理堆软性上限降到24GB让GC早点动手。这一步的变化很明显并发标记耗时从平均12ms降到8msAllocation Stall从每个压测周期20多次降到2~3次P99从5ms降到2ms左右。这里补一个计算逻辑。为什么SoftMaxHeapSize设24GB而不是更低堆使用率曲线显示高峰期堆占用在18~22GB之间波动。软上限设在24GB意味着堆占用还没到24GBZGC就开始准备并发回收等真正撞到硬顶32GB时回收基本已完成分配停滞自然消失。这个参数本质上是提前量设太低了会导致GC周期太频繁白白增多STW次数。第二步压STW优化GC Roots和多线程参数分配停滞清零后剩下的停顿集中在Pause Mark End。用JFR抓jdk.GCPhasePause发现每次Pause Mark End在2.5~3.5ms里面大头是Root Scan和Class Unloading。于是做了三件事把业务线程池从64降到32同时确认CPU利用率不高、连接无积压。关掉元空间的类卸载-XX:-ClassUnloading——这个操作有风险但我们确认了该服务动态生成的类数量不大内存可接受。显式开启大页容器宿主机支持减少TLB miss。结果让人意外地好Pause Mark End从3ms直接降到0.3~0.35msPause Mark Start从0.4ms降到0.15ms。GC日志里整体STW单次已经全部低于0.5ms。第三步节奏调优把GC周期放平稳STW降下来后P99还能再压一截。我们把-XX:ZAllocationSpikeTolerance从2.0调到1.5目标是减少单位时间内的GC周期次数。这个调整让GC周期从平均每2.7秒一次降到每4秒一次STW总次数减少了30%以上而并发标记的耗时没有明显恶化因为负载是相对稳定的。最终配置大致是这样-Xms32g -Xmx32g -XX:UseZGC -XX:ConcGCThreads4 -XX:ParallelGCThreads8 -XX:SoftMaxHeapSize24g -XX:ZAllocationSpikeTolerance1.5 -XX:UseLargePages -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount5,filesize20m4.3 结果对比与复盘压测数据对比15分钟稳定流量客户端侧P99指标调优前调优后单次STW最大停顿3.5ms0.35msAllocation Stall次数24次0次GC周期平均间隔2.7s4.2s接口P99延迟5ms0.4ms接口P999延迟23ms0.8ms服务CPU使用率22%26%CPU涨了4个百分点换来延迟下降一个数量级这笔买卖相当划算。这4个百分点主要来自读屏障的额外开销和增多的并发GC线程。如果你的服务CPU已经顶到80%以上建议先扩容而不是指望用更激进的GC参数在满负载下创造奇迹。复盘的时候我们团队有个共识之前卡在5ms迟迟无法突破根因是默认参数足够用这个思维惯性。ZGC默认参数保证的是50分位的体验要达到0.5ms这个量级必须针对性分析本服务特有的冲突点。5. 常见问题与排查技巧实录5.1 排查利器jcmd、JFR与GCViewer的正确打开方式调优过程中手里得有趁手的工具不然等于瞎调。我一般按三个层次下手jcmd pid GC.class_stats和jcmd pid GC.heap_info快速看堆使用、类加载情况适合定位是不是类卸载环节拖后腿。JFRjcmd pid JFR.start/JFR.dump低开销的飞行记录器里面记录了ZGC的阶段耗时、分配停滞、对象分配速率。最适合结合压测流量做停顿归因。GC日志分析ZGC的日志信息量很大配图不方便但文字日志里的关键行信息量足够。直接把GC日志丢给GCViewer或fastjvm这类工具可以可视化地看出Pause分布和并发阶段耗时曲线。排查顺序建议先看Allocation Stall有没有、频率多少再看单次STW里各阶段占比最后才动参数。一次只改一个参数、观察一轮压测是调优的基本规矩。5.2 你可能遇到的坑ZGC调优的四个反面教材第一个坑是照抄网上的JVM参数。ZGC对堆大小、CPU核数、分配速率的敏感度远高于G1抄来的参数几乎不可能正好匹配你的场景。就算要参考也必须把自己服务的GC日志先跑一轮摸清基座数据。第二个坑是把-Xmx设得和物理内存一样大。ZGC的着色指针需要额外的地址空间映射换页、懒分配和操作系统overcommit之间容易打出毛刺。经验值JVM堆不要超过容器物理内存的70%留出元空间、线程栈、堆外内存和系统缓存的空间。第三个坑是忽略容器和物理机的CPU差异。前面提到ActiveProcessorCount的问题很多服务在容器里部署JVM探测到的CPU核数是宿主机核数ConcGCThreads、ParallelGCThreads按宿主机算直接导致GC线程数虚高。遇到GC停顿不可控第一件事就是打印一下Runtime.getRuntime().availableProcessors()确认JVM眼里有多少核。第四个坑是用ZGC跑小堆服务。堆只有几GB的话ZGC每次GC周期里并发标记、并发重分配的基础开销分摊下来可能比G1的停顿还要难看。不是ZGC不行是你用错了场景。小堆、低延迟场景优先考虑G1或Shenandoah。5.3 参数速查与一句话经验场景参数方向经验值并发标记太慢、分配停滞频繁调高ConcGCThreadsCPU核数/8 → CPU核数/4观察拐点业务线程多、根扫描慢减少线程池线程数精简栈深度观察初始标记耗时变化GC周期太频繁、STW次数多调低ZAllocationSpikeTolerance从2.0 → 1.5注意流量高峰堆占用接近上限触发紧急回收调低SoftMaxHeapSize高峰堆占用20%余量系统层TLB miss高显式大页vm.nr_hugepages按堆大小规划JDK 21环境开启分代ZGC-XX:ZGenerational最后再分享一个小技巧调优ZGC别老盯着GC pause那个数字要盯业务P99。我见过一个团队调出GC pause只有0.2ms的漂亮数据但线上接口延迟还是经常超1s——后来一看是Allocation Stall和系统swap在作祟。GC调优的终点不是让某个JVM指标好看而是让用户的请求觉得快。数据漂亮只是手段延迟稳定才是目的。
返回列表