做服务端的人应该都有过这种体验:线上接口平时稳定在几十毫秒,突然某一波流量上来,P99 延迟直接从 100ms 飙到两秒以上,上游超时重试,下游跟着堆积,最后整条链路雪崩。查了一圈,数据库没问题,线程池没打满,最后把 GC 日志拉出来一看,满屏都是 long full GC,老年代回收一次停了 3 秒多。这种事故我遇过不止一次,每次的结论几乎都一样:GC 停顿时间就是拖垮应用响应延迟的隐形杀手。
这篇文章就是围绕“GC 停顿时间优化”这件事来写的,核心是讲清楚停顿是怎么产生的、怎么通过日志和监控把它揪出来,以及我实际在线上用过的优化方案和踩过的坑。适合正在做 Java 服务端开发、JVM 调优、稳定性治理或 SRE 方向的同学参考。我一直觉得 GC 调优不是玄学,它跟做性能优化一样,得有数据、有目标、有验证手段,还要能忍受一步步试错。
1. 先搞清楚GC停顿是怎么拖垮响应延迟的
1.1 停顿的根源:STW 世界暂停
GC 停顿时间长,本质上是 JVM 在做垃圾回收时启动了 Stop-The-World(STW)机制。在 STW 期间,所有业务线程都会停在安全点,不再执行任何代码,最直观的表现就是应用整体“卡住”。对于同步接口来说,卡住的时间和请求耗时直接对得上;对于异步或消息消费场景,虽然不会立刻表现为 RT 变高,但会体现为处理速率下降、积压上涨。
很多人的第一反应是怪 GC 算法不够好。但说实话,几乎所有收集器都逃不过至少一次的 STW,区别只是停顿的长短和频率。比如 ParallelGC 在回收老年代时是纯 STW 的,一旦老年代满了,就会整堆扫描、压缩,停顿时间跟存活对象数量强相关;CMS 虽然做到了并发标记和并发清理,但最终在重新标记阶段还是要 STW,而且碎片化严重后会退化到 Serial Old,那个停顿更酸爽;G1 则把堆分成 region,尽量做到可预测的停顿,但遇到 Full GC 或者 humongous 分配失败,照样会有长时间停顿。
所以理解停顿的第一层逻辑是:任何收集器都有自己的停顿模型。优化前必须知道你当前用的是哪款收集器,它停顿发生在哪个阶段,以及触发这个停顿的条件是什么。不然你连日志里的 pause 是哪一段都对应不上,后面调参就是盲人摸象。
1.2 别被“堆大小”骗了,延迟瓶颈往往另有原因
有不少人一看到 GC 频繁,第一反应是加内存。加了内存之后可能确实减少了 GC 次数,但停顿时间不一定下降,有时候反而上升。原因很简单:堆越大,单次 GC 扫描的 region 和对象就越多,Full GC 的压缩时间也越长,尤其在使用 ParallelGC 时,一个 32G 堆的 Full GC 停顿可能比 8G 堆长好几倍。
我在一个订单服务上见过典型场景:原来 4G 堆,每天早高峰来一次 Full GC,停顿 1.2 秒;运维拍板把堆升到 16G,Full GC 确实变成几天一次,但每次停顿直接到了 4 秒以上,P99 照样崩。这说明堆大小只是影响延迟的一个维度,更要关注的是:对象分配速率、晋升速率、老年代增长斜率、大对象分配频率、以及 GC 触发时机。堆内存配置必须和业务对象的生命周期匹配,而不是一味求大。
1.3 那个神秘报错 “release of invalid gc handle” 是什么来头
有些排查 GC 问题的朋友会在日志里看到类似这样的提示:
release of invalid gc handle. the handle is from a previous domain. the release is ignored.第一次见这玩意很容易慌,以为是 JVM 出了严重故障。这里可以先给个定心丸:这行信息很多情况下是 JVM 内部对 GC 句柄(gc handle)释放逻辑的提示,和业务代码没有直接关系。所谓的 domain,指的是 JVMTI 或 GC 内部维护句柄的作用域上下文,如果某个句柄在旧的域里被创建,到新的域里去释放,JVM 就会打印类似的警告并忽略释放操作。
在实际工作中,这类信息出现时我会重点排查几类东西:一是是否加了比较激进的 JVM 调试代理、字节码插桩工具或者 APM agent,它们有时会通过 JVMTI 创建 GC 句柄;二是 JNI 代码里是否跨线程、跨作用域不适当地释放了全局/局部引用;三是有没有做线程 attach 和 detach 的非标准操作。大部分情况下,它不会直接导致应用停顿,但它提示了目标进程的 GC 接口被外部工具触碰过,如果业务本身特别敏感,最好把它当作一个检查信号去确认 agent 和 JNI 的规范性。
2. 制定优化方案前,先做三件准备工作
2.1 收集证据:GC 日志、jstat、监控面板
优化最忌讳拍脑袋。我一般会按下面的顺序先收集数据,不是一开始就去改参数。
第一件事:确认当前启动参数。直接用ps -ef | grep java看进程的启动命令,或者用jcmd <pid> VM.flags拿到当前生效的参数。这一步能确认你现在用的是 ParallelGC、CMS、G1 还是 ZGC,以及堆大小、GC 线程数等基准数据。
第二件事:开启详细 GC 日志。JDK 8 推荐这样开:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGCJDK 11+ 推荐用统一日志参数:
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags这里要注意权限和日志轮转。曾经有同事没做日志轮转,结果为 GC 日志磁盘打满,应用直接 hang 掉,这个属于典型的工具反噬事故。
第三件事:采集运行期指标。jstat -gcutil <pid> 1000可以实时观察 Eden、Survivor、Old、MetaSpace 的使用率,以及 YGC/FGC 的频次和时间;jmap -histo偶尔用来看看对象分布,注意不要在生产高峰期乱执行,因为它也会触发安全点停顿。配合监控面板上的 RT、QPS、线程状态、CPU 使用率,基本能拼出一张完整的性能画像。
2.2 从 GC 日志里读出停顿画像
拿到 GC 日志后,我不会急着找“罪魁祸首”,而是先做一次“停顿画像”。核心要回答这几个问题:停顿频次多高?每次停顿多少毫秒?停顿发生的时机和业务流量有什么相关性?停顿的类型主要是 YoungGC、MixedGC、FullGC,还是 Concurrent Cycle 里的某个子阶段?
以 G1 日志为例:
[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0134830 secs] [Parallel Time: 5.8 ms, GC Workers: 8] [Code Root Fixup: 0.1 ms] [Clear Claimed Marks: 1.1 ms] [Scan RS: 1.2 ms] [Evacuation: 2.0 ms] [Other: 4.0 ms]我会重点关注几个数字:
Parallel Time是并行执行阶段的总耗时,如果这里高,说明 GC worker 线程数量和 CPU 资源不匹配;Scan RS是扫描 RSet 的时间,偏高说明老年代 region 的引用关系复杂,可能需要配合-XX:G1HeapRegionSize或分配策略调整;Other里包含很多“杂活”,比如Ref Proc、Code Cache、Start VM等,如果 Other 时间特别大,大概率不是 GC 本身的问题,而是安全点、JIT 反优化或者同步工具在捣乱。
日志里还有一种常见现象:FGC 没有明显增长,但 RT 仍然很高。这时要去看是不是安全点全局停顿导致的,也就是 JIT 编译器或者偏向锁撤销把线程拉进了 safepoint。这个问题的排查和 GC 日志互相配合,才能定位到真正的 STW 来源。
2.3 明确优化目标:你想要的到底是吞吐还是低延迟
GC 调优前不设目标,后面大概率会反复横跳。你要先承认一个事实:吞吐量和低延迟在这个场景里是跷跷板。GC 运行越频繁,单位时间内的业务执行时间越少,如果堆内存设得很小,GC 虽然单次停顿低,但总耗时高,CPU 也高;反过来堆内存设得很大,GC 次数少了,但单次停顿可能不可接受。
我的做法是把服务按延迟敏感度分成两类。一类是同步接口类,比如交易、下单、查询,P99 就是命根子,这种服务适合优先控制最大停顿,必要时可以牺牲一点吞吐;另一类是异步批处理类,比如离线任务、消息消费、批量导出,吞吐优先,只要不会因为 GC 造成长时间不消费消息就可以,这类服务甚至可以留在 ParallelGC 并压缩停顿频率。
另外建议给优化定一个可量化的指标,比如“P99 从 120ms 降到 80ms 以内”“Full GC 频率控制在 10 分钟不超过一次”“单次 GC 停顿不超过 200ms”。这些指标会在后面每一步调参后告诉你,当前调整到底有没有效果。
3. 一套可落地的 GC 停顿优化方案
3.1 第一刀:堆内存和新生代配比
我习惯把堆内存调整放在第一步,因为这是其他参数生效的基础。以下是我线上常用的初始判断规则:
- 如果老年代增长速度很慢、YGC 后内存大量晋升到老年代,说明 Survivor 不够大或晋升阈值偏低;
- 如果 Eden 频繁打满,但对象大多是短生命周期,优先考虑扩大年轻代,而不是扩大整个堆;
- 如果大对象极多、老年代直接暴涨,需要检查代码层面的对象分配,单纯调 JVM 参数治标不治本。
假设当前服务是 8G 堆,G1 默认会动态调整年轻代大小,范围比较大。我一般会显式设置:
-Xms8g -Xmx8g -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=60 -XX:SurvivorRatio=8这里解释一下为什么Xms和Xmx要设成一样:避免运行期堆自动扩容带来的额外停顿。堆扩容是需要向操作系统申请内存的,在容器环境里更容易出现 OS 层抖动,干脆一次申请到位。
G1NewSizePercent=20表示年轻代最小值占堆的 20%,G1MaxNewSizePercent=60表示最大可以到 60%。如果业务对象普遍短命,把年轻代上限调大一些,理论上能减少对象提前晋升导致的 Old 区压力。不过这个比例不是越大越好,年轻代太大,每次 YoungGC 的疏散暂停时间也会变长,还要兼顾目标停顿时间。
3.2 第二刀:选择合适的收集器和目标停顿时间
很多程序员对收集器有偏爱,但我建议把“能不能达到业务延迟目标”作为唯一标准。JDK 8 默认 ParallelGC 吞吐高但 Full GC 停顿不可控;如果服务响应延迟敏感,我会优先切到 G1,并设定一个合理的停顿目标。
G1 的核心参数是:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=4MaxGCPauseMillis并不是硬性上限,它只是一个软目标。G1 会根据历史停顿数据自动调整年轻代大小,尝试让停顿不超过这个值。如果你设成 50ms,G1 可能一整天都在拼命缩小年轻代,结果是 YGC 频率暴增、Full GC 反而更容易出现。我一般建议先设 200ms 起步,观察稳定后再往下压。
至于要不要直接上 ZGC,我的看法是:如果应用跑在 JDK 17 或 21,且堆达到几十 GB,ZGC 的亚毫秒级停顿确实香;但如果你的堆只有 2-4G,ZGC 的优势发挥不出来,反而因为并发处理占用额外 CPU 导致吞吐下降。低延迟必须配合足够大的堆和足够多的核,否则不要盲目追新。
3.3 第三刀:常用参数速查与效果预期
在实施过程中,我整理过一张参数速查表,方便每次调整时对照。这里直接列出来:
| 参数 | 作用 | 设置建议 |
|---|---|---|
-Xms/-Xmx | 堆初始/最大值 | 线上建议相等,避免扩容停顿 |
-Xmn | 年轻代大小 | 先观察 YGC 频率和晋升速率,再调整 |
-XX:MaxGCPauseMillis | G1 目标停顿 | 从 200ms 开始,逐步收紧 |
-XX:G1HeapRegionSize | 单个 region 大小 | 大对象多时可调大,但建议默认 |
-XX:G1NewSizePercent/G1MaxNewSizePercent | 年轻代比例 | 短命对象多时可适当调大上限 |
-XX:InitiatingHeapOccupancyPercent | 触发并发周期阈值 | 默认 45,频繁并发 GC 时需观察 |
-XX:ParallelGCThreads | GC 并行线程数 | 和对端核数匹配,不是越大越好 |
-XX:ConcGCThreads | 并发标记线程数 | 通常为 ParallelGCThreads 的 1/4 |
-XX:+UseStringDeduplication | 字符串去重(G1) | 堆中有大量重复字符串时可开 |
这里特别提醒一下,InitiatingHeapOccupancyPercent在 JDK 8 里默认 45,表示堆占用率达到 45% 时开始并发标记周期。如果老年代增长很快,可以适当降低这个阈值让并发标记更早发生,避免堆占用太高后被迫 Full GC。但如果调得太低,并发周期过于频繁,会把 CPU 和停顿预算消耗在无谓的标记上。设置后必须观察两个周期:吞吐是否下降、Full GC 是否真的减少。
3.4 附优化前后效果对比
我拿一个实际案例来展示方案效果。服务背景:订单查询接口,4核8G 容器,JDK 8,原先使用默认 ParallelGC。
问题现象:早高峰接口 P99 抖动到 2s,CPU 平均 60%,FGC 每半小时一次,单次大约 2.5s。
第一步先从日志里看:老年代占用接近容量上限,大量订单对象晋升到老年代,并且伴随java.lang.System.gc()风格的显式调用。查代码后发现有一个缓存组件会定期调用System.gc(),这就解释了为什么明明内存够用却频繁 Full GC。
优化步骤:
- 移除或关闭业务代码里的
System.gc(),加上-XX:+DisableExplicitGC做防御; - 参数从 ParallelGC 切换为 G1:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xms8g -Xmx8g - 堆不变,但通过
jstat观察到老年代增长仍然偏快,于是把年轻代比例上限调高到 50%; - 加了一个缓存预热脚本,避免服务启动后集中触发大对象分配。
优化后结果:FGC 基本消失,单次 YoungGC 停顿稳定在 15ms 到 50ms 之间,P99 从 2s 降到 80ms 左右,CPU 也健康了很多。这个案例说明,很多时候真正的问题不是 GC 算法本身,而是堆模型和业务分配行为不匹配,外加代码层的显式 GC 在捣乱。
4. 实操过程中遇到的坑与排查技巧
4.1 常见问题速查表
下面这些是我在实际排查中总结的比较高频的坑,建议收藏:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| Full GC 频繁但老年代占用不高 | System.gc()、JNI、RMI 定时触发 | 代码排查,-XX:+DisableExplicitGC |
| YGC 后大量对象进入老年代 | Survivor 太小或晋升阈值过低 | 调整SurvivorRatio、TenuringThreshold |
| G1 MixedGC 后老年代仍然持续增长 | 并发标记周期跟不上分配速率 | 降低InitiatingHeapOccupancyPercent |
| P99 高但 GC 日志里没有明显的长停顿 | safepoint 停顿、JIT 优化导致 | 开启-XX:+PrintSafepointStatistics或诊断日志 |
| GC worker 时间高但业务 CPU 不高 | 内核调度问题、CPU 隔离策略 | 检查 cgroup 限制和 CPU quota |
| 日志里出现 release of invalid gc handle | JVMTI/agent/JNI 句柄作用域问题 | 检查 agent 和 native 代码,多数不影响业务 |
线上真实场景里,最后一项“日志里出现 release of invalid gc handle”往往和 GC pause 同时出现,会让新人误以为 GC 挂掉了。我处理过一个案例:某服务挂了一个自研的字节码增强 agent,agent 里注册了GarbageCollectionStart之类的 JVMTI 回调,回调里又去操作 JNI 引用,特定版本 JDK 下就会刷出这段提示。替换掉 agent 的实现方式后,提示消失,停顿也顺带变得规整。所以看到它别慌,先看是谁注册了 JVM 内部接口,再决定动不动手。
4.2 别急着换收集器:先调小场景
有一个策略我吃了很多亏才真正理解:收集器迁移是动作很大的操作,不要图一战成名。G1 切到 ZGC,或者 ParallelGC 切到 G1,这不是只改一个启动参数那么简单。不同收集器对堆布局、线程模型、CPU 缓存局部性的要求都不一样,切过去之后必须压测验证,至少跑够一周,覆盖流量高峰和低峰。
我建议先在预发环境搭一个小流量场景,只接入少量节点,把 GC 日志和 RT 监控完整留档,观察两个指标:最大停顿和并发阶段对 CPU 的额外占用。如果预发阶段都出现频繁 Full GC,说明单纯换收集器没法解决问题,还得回到业务分配模型上排查。
一个小技巧:切换收集器时,不要把老参数一次性全删掉。保留一份带-XX:+PrintGCDetails的启动命令副本,万一需要回滚,能迅速恢复到已知可用的配置。
4.3 “release of invalid gc handle” 这类异常的处理路径
最后单独讲讲这类“看着像 GC 崩溃”的提示要怎么系统排查。我先重申一句:它更像是一种“越界释放”的警告,而不是说 GC 本身腐蚀了你的堆。
处理路径我总结成三步:
第一步,确认异常出现的时间点和触发阶段。如果只是在 GC 并发周期开始或结束时出现,且业务指标没有明显劣化,先不要大动干戈;用jcmd <pid> JVMTI.data_dump拉一下 JVM 状态,确认是否有外部代理在活动。
第二步,检查 JNI 代码和本地库。搜索代码里是否有DeleteGlobalRef、DeleteWeakGlobalRef、DetachCurrentThread这类调用,尤其关注是否在错误线程释放了引用。虚拟机对 JNI 引用归属管理很严格,跨线程释放就会出现 domain 不匹配的提示。
第三步,如果项目里有 agent、字节码增强组件、监控组件,逐个做排除法。依次禁用这些组件重启应用,观察异常是否消失。不要嫌麻烦,这个办法虽然朴素,但定位最快。
提示:每次改动完启动参数后,建议都执行一次
jcmd <pid> VM.flags,确认你想要的参数真的生效了。有很多时候你以为改了,实际被别处覆盖,空跑一轮优化。
写在最后
前前后后处理过的 GC 卡顿案例不算少,说实话,没有哪次是靠某个“银弹参数”搞定的。优化的过程更像是在做实验:先摸清堆模型和对象分配规律,然后盯着 GC 日志和响应延迟做迭代。
我自己养成了一个习惯:每次调优都会把改动前后的 GC 日志、jstat 快照和 RT 数据留底,过一两个月再翻出来回看。很多当时以为正确的结论,回头看其实还有更好的调整空间。GC 停顿优化不是一锤子买卖,它需要你对业务对象的生命周期保持敏感,也需要对 JVM 的运行机制保持敬畏。