☰
ZGC调优实战:把GC停顿压到0.5ms以内的完整方案
2026/10/5 3:08:01 网站建设 项目流程

聊起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为代表的非分代版本中的主要阶段,以及哪些阶段会STW:

GC阶段是否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稳定)、服务端负载起伏明显、业务线程多而不追求极致吞吐。如果你的应用堆只有几百MB,ZGC的着色指针和读屏障带来的额外CPU开销反而得不偿失,这属于典型的杀鸡用牛刀。官方也标注过,ZGC在吞吐量上会比Parallel GC低个位数到十来个百分比,因为每次对象访问都要过一遍读屏障。

另一个要注意的是分配速率。如果业务代码里大量产生临时对象,每秒分配量达到GB级别,那么即使ZGC并发再努力,也可能出现"回收速度赶不上分配速度"的分配停滞(Allocation Stall)。这种停滞外部表现跟STW一样,应用程序就像被卡住了一样,轻则几十毫秒,重则上百毫秒。后面我会专门讲这个坑。

2. 调优前的准备:把"0.5ms以下"这个目标翻译成可执行的参数

2.1 先定基准:停顿到底怎么量

在动手调参之前,先把"停顿"这件事量化。我们常说的GC停顿,在ZGC语境下有两个口径:

  • GC View停顿(GC pause):GC日志里记录的Pause Mark Start、Pause Mark End等事件的耗时。只包含JVM内部的STW时间。
  • 业务线程停顿(Mutator Stall / Allocation Stall):业务线程因为等待GC资源而实际被挂起的时间,包括分配停滞和读屏障触发的等待。这个从GC日志里也能看到,会出现"Allocation Stall"的记录。

调优目标是业务接口的P99延迟,而不是单纯看GC日志里的pause数字。如果GC pause已经0.2ms,但业务线程因为分配停滞卡了5ms,那等于白调。所以我做基准的方法通常有两层:

  1. 在测试环境挂上JFR(JDK Flight Recorder)和GC日志,用压测流量跑15分钟,看jdk.GCPhasePause和jdk.ZGarbageCollection事件。
  2. 在压测端记录请求延迟分位数,对比GC参数调整前后的P99变化,以这个为准。

每轮压测前要做的不是直接改参数,而是先把当前ZGC数据捞出来晒一晒:GC周期频率、单周期STW总时长、并发标记平均耗时、分配停滞次数。没有这组数据,后面所有参数调整都是在拍脑袋。

2.2 基础参数先摆平:堆大小、JDK版本、GC日志一个都不能少

要想压到0.5ms以下,有几个"地基"必须打牢。首先是JDK版本。ZGC在JDK 11才作为实验特性引入,JDK 15转正,到JDK 17已经比较成熟,JDK 21引入了分代ZGC(Generational 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:filecount=5,filesize=20m

这里解释下拆解方式:gc*是日志标签,表示捕获GC所有子标签的信息;后面的冒号依次是输出目标(文件)、log参数(时间、运行时间、级别、标签)、以及轮转策略(保留5个文件,每个不超过20MB)。压测时我会把gc+stats也开起来,这样能看到每次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"。这两者是有本质区别的。

如果只看单次STW,ZGC的初始标记+最终标记+清理加起来通常也在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本地帧。如果用了JNI,JNI本地帧里的引用也要扫描,这块往往不可控。能不用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_hugepages=512 # 挂载大页文件系统,并分配配额 mkdir -p /dev/hugepages mount -t hugetlbfs -o pagesize=2M,size=1G hugetlbfs /dev/hugepages # 检查是否成功 grep Huge /proc/meminfo

JVM侧加上-XX:+UseLargePages(JDK 17+也可用-XX:+UseZLargePages),启动时观察日志确认"Large Pages"生效。实测下来,显式大页配合ZGC,并发重分配的耗时能下降百分之二三十,GC pause也更平滑。

再讲NUMA。在多路服务器上,CPU访问本地内存和跨节点内存的延迟差异明显(通常跨节点延迟多出20%~40%)。JVM默认会启用NUMA感知(-XX:+UseNUMA),但如果堆内存没有通过numactl --interleave=all设置交错分配,ZGC在分配和搬运对象时,可能反复跨节点访问,增加停顿和CPU开销。我们的实践是:对于GC延迟敏感的服务,用numactl --interleave=all启动JVM,让内存尽量均摊到各个NUMA节点,GC线程访问哪块内存都不至于太吃亏。这一步带来的收益不一定每次都能看到,但NL-SMP架构下值得百分之百试一下。

3.5 分代ZGC:JDK 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等于-Xmx),ZGC默认参数,GC日志里Pause Mark End偶发3.5ms,Allocation 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:SoftMaxHeapSize=24G(32GB物理堆,软性上限降到24GB,让GC早点动手)。这一步的变化很明显:并发标记耗时从平均12ms降到8ms,Allocation Stall从每个压测周期20多次降到2~3次,P99从5ms降到2ms左右。

这里补一个计算逻辑。为什么SoftMaxHeapSize设24GB而不是更低?堆使用率曲线显示,高峰期堆占用在18~22GB之间波动。软上限设在24GB意味着,堆占用还没到24GB,ZGC就开始准备并发回收,等真正撞到硬顶32GB时,回收基本已完成,分配停滞自然消失。这个参数本质上是"提前量",设太低了会导致GC周期太频繁,白白增多STW次数。

第二步:压STW,优化GC Roots和多线程参数

分配停滞清零后,剩下的停顿集中在Pause Mark End。用JFR抓jdk.GCPhasePause,发现每次Pause Mark End在2.5~3.5ms,里面大头是"Root Scan"和"Class Unloading"。于是做了三件事:

  1. 把业务线程池从64降到32(同时确认CPU利用率不高、连接无积压)。
  2. 关掉元空间的类卸载(-XX:-ClassUnloading)——这个操作有风险,但我们确认了该服务动态生成的类数量不大,内存可接受。
  3. 显式开启大页(容器宿主机支持),减少TLB miss。

结果让人意外地好:Pause Mark End从3ms直接降到0.3~0.35ms,Pause 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:ConcGCThreads=4 -XX:ParallelGCThreads=8 -XX:SoftMaxHeapSize=24g -XX:ZAllocationSpikeTolerance=1.5 -XX:+UseLargePages -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=5,filesize=20m

4.3 结果对比与复盘

压测数据对比(15分钟稳定流量,客户端侧P99):

指标调优前调优后
单次STW最大停顿3.5ms0.35ms
Allocation 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:快速看堆使用、类加载情况,适合定位是不是类卸载环节拖后腿。
  • JFR(jcmd <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指标好看,而是让用户的请求觉得"快"。数据漂亮只是手段,延迟稳定才是目的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询