停顿 1.2 秒、RT 涨 18 倍:JDK8 默认 GC 在高并发下的失控
场景:一个延迟敏感网关,JDK 8 默认参数跑 Parallel Scavenge,高峰期 YGC 频繁 + Full GC (Ergonomics) 停顿飙到 1.2s,RT p99 从 80ms 涨到 1.5s 路径:事故画像 → 盲区(加堆/调参/MaxGCPauseMillis 三连无效)→ GC 日志锁定 PS → 选型决策树 → 换 G1 方案 → 标记命令
上篇讲了 JVM 参数不是越多越好,从 38 个删到 8 个,停顿反而更稳了。这篇我们来看另一个方向:一个参数都没加,只是把 GC 从默认值换掉,停顿就从 1.2 秒降到 80ms。
同一个服务,同一份代码,同一台机器。PS 引擎下高峰 YGC 每秒触发 8 次、Full GC (Ergonomics) 一次停顿 1.2 秒,RT p99 从 80ms 直接飙到 1.5s。换 G1 之后,停顿稳定在 80-120ms,RT p99 回到 90ms。
不是我们调了什么黑科技参数——是默认参数用错了收集器。
【画像】延迟敏感网关的停顿尖刺
事故时间线
监控告警的时间线是这样的:
| 时间 | 现象 |
|---|---|
| 14:02 | 支付网关 RT p99 开始上探,从 80ms 缓慢爬升 |
| 14:05 | 告警触发:RT p99 突破 500ms 阈值 |
| 14:08 | 顿挫出现规律性,每 12-15s 一次 1s+ 的尖刺 |
| 14:15 | 初步排障遇阻:先查网络再查数据库均正常,方向转向 GC |

应用画像
一个延迟敏感型支付网关,TPS 8000、RT p99 阈值 100ms、堆 4GB、JDK 8 默认参数启动——也就是说用的是 JDK8 服务器模式的默认收集器:
Parallel Scavenge(ParallelGC,吞吐量优先的新生代收集器,JDK8 服务器默认)+ Parallel Old(老年代收集器,同样并行但不并发,回收时 STW)。
一句话定义这里的关键词:STW(Stop-The-World),指 GC 运行时应用线程全部暂停。Parallel 系收集器每次 GC 都是全量 STW——吞吐优先就体现在这里:它让你"干活时间占比最大",代价是单次停顿时长不受控制。

停顿尖刺与 RT 的对齐
把 RT p99 曲线画出来,规律一目了然:每一次 GC 停顿尖刺,都精确对应一个 RT 尖刺——开头那个 1.5s 的 RT,正是 STW 期间被拦在队列里的请求集体超时。

(数据来自压测环境 GC 日志快照:4 并发压测 10 分钟,YGC 触发达 4800+ 次,Full GC Ergonomics 触发 47 次,最大停顿 1247ms。)
【盲区】大多数人的第一反应:加堆、调参、设停顿目标
三连操作,全部无效
按"停顿大 = 内存不够"的直觉,值班同学依次做了三个最典型的操作:
| 操作 | 预期 | 实际 |
|---|---|---|
-Xms4g -Xmx4g 固定堆 |
减少扩容抖动 | Full GC 间隔略变,1.2s 尖刺照旧 |
-Xmn1g 调大年轻代 |
降低 YGC 频率 | YGC 次数少了,但单次更大更久 |
-XX:MaxGCPauseMillis=100 |
把停顿压到 100ms | 停顿反而更不稳定,吞吐掉了 12% |
三个操作全部无效——因为它们的共同前提是错的:以为停顿是"参数问题",实际上是"收集器特性问题"。
被大多数人误解的 MaxGCPauseMillis
这里藏着全书最典型的一个参数陷阱。-XX:MaxGCPauseMillis 在 G1 下是一个"软目标",G1 会努力逼近但它也只是尽力而为;而在 Parallel Scavenge(PS)下,它根本不是停顿保证,只是一个给自适应策略的 hint:

JVM 官方文档的原话:
"A hint to the virtual machine that pause times of N milliseconds or less are desired... This may cause the VM to reduce overall throughput, and in some cases the VM will not be able to meet the desired pause time goal."
翻译:它表示"希望"停顿 ≤N ms,VM 会调整堆和代的大小去尝试逼近,但可能降低吞吐,且不保证达成。PS 的自适应策略(UseAdaptiveSizePolicy)有三个目标按优先级排列——先满足停顿、再满足吞吐(默认 GCTimeRatio=99,即最多 1% 时间用于 GC)、最后压缩 footprint。你设 MaxGCPauseMillis=100,策略就不断缩小年轻代和堆去压停顿,结果 GC 更频繁、吞吐下滑,停顿目标还是不一定守住。
【路标】GC 日志锁定 PSYoungGen + 选型决策树
GC 日志原文
开启 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 后,GC 日志暴露出两个特征字符串。先看 YGC:
267.745: [GC (Allocation Failure) [PSYoungGen: 209715K->32768K(289280K)] 921788K->59148K(4202488K), 0.0423110 secs]
267.789: [GC (Allocation Failure) [PSYoungGen: 245760K->32768K(289280K)] 1029888K->63120K(4202488K), 0.0451200 secs]
再看 1.2s 停顿的元凶——Full GC (Ergonomics),Ergonomics 表示这是 JVM 自适应策略自己决定的 Full GC,不是业务代码触发的:
267.789: [Full GC (Ergonomics) [PSYoungGen: 256K->0K(132096K)] [ParOldGen: 1378456K->523456K(1398272K)], 1.2470890 secs]
[Times: user=4.83 sys=0.02, real=1.25 secs]

(日志来自 8C16G 容器压测快照,JDK 1.8.0_212。红色高亮:Full GC (Ergonomics)、real=1.25。)
两个特征连起来看:
- PSYoungGen = 新生代是 Parallel Scavenge 在跑
- Full GC (Ergonomics) = 自适应策略在高峰期反复调代大小触发全堆回收
这就是「吞吐优先 + 固化停顿目标」的结构性错配。PS 为了压停顿疯狂缩代,缩完之后堆没地方装晋升对象,又 Full GC 一次——越调越乱。
PS 的停顿失控机理
PS 停顿失控在高峰期是必然的,三个机制叠加:
- 无并发阶段:Parallel Scavenge 整个回收过程没有任何并发标记,YGC 和 Full GC 全程 STW,等于"回收多快,停顿多久"
- 晋升路径短:高并发下 Eden 迅速填满,存活对象被过早晋升到老年代,老年代膨胀速度远超你加的堆
- 自适应策略添乱:
Ergonomics的 Full GC 平均每 12-15s 一次,且每次回收后老年代还保留可观存活对象,下次又循环
GC 选型决策树
到这里,问题已经不是一个参数能救的——是该换收集器了。选型逻辑长这样:

- 吞吐敏感型(批处理/离线计算,停顿无所谓)→ 保留 Parallel Scavenge,它是同类里的吞吐之王
- 延迟敏感型(网关/在线接口,p99 有硬约束)→ 换 G1(JDK8 可启用的分区式低延迟收集器)或 ZGC(JDK11+ 超低延迟)
- 堆很大(≥6G)又要求低停顿 → G1;追求 <10ms 停顿且堆大 → ZGC
为什么是 G1 而不是 CMS
JDK8 时代很多团队会条件反射地换 CMS(Concurrent Mark Sweep,标记清除并发收集器)。这篇明确不推荐:
- CMS 已被淘汰:JDK9 起标记 deprecated(JEP 291),JDK14 移除
- 碎片化宿命:CMS 标记-清除不整理,老年代碎片化必然积累,最终
Concurrent Mode Failure退化全堆 STW——这正是上一篇 CMS promotion failed 案例的同款根因 - 维护成本:CMS 的阈值参数(CMSInitiatingOccupancyFraction 等)极其微妙,团队维护成本远超 G1
G1 用 Region 分区 + 并发标记 + Mixed GC,把"全量压缩"拆成增量回收,天然规避碎片化和全堆大停顿。同为 JDK8 可用,G1 是比 CMS 更接近"现场可落地"的选择。
【方案】换 G1:参数没加多少,停顿降一个数量级
最小改动参数集
把启动参数从默认值改成 G1,核心只加了 3 个参数(呼应上篇"参数精简"主题):
| 参数 | 作用 | 为什么在这里生效 |
|---|---|---|
-XX:+UseG1GC |
JDK8 显式启用 G1(JDK9+ 默认) | 把收集器从"吞吐优先"切到"停顿可控" |
-XX:MaxGCPauseMillis=100 |
给 G1 一个软目标停顿预算 | G1 按预算挑要回收的 Region,而非全堆 |
-XX:G1HeapRegionSize=16m |
4G 堆固定 Region 大小 | 减少大对象(humongous)跨 Region 的不连续回收 |

这里解释它的"为什么生效":
- G1 用 Region 分区(堆切成等大小区域),YGC/Mixed GC 只回收部分 Region,而不是整个堆
- 并发标记期间应用线程可运行——但 initial-mark/remark 及 Young/Mixed 拷贝阶段仍有 STW,只不过停顿由回收 Region 数决定,比 PS 的全堆回收短得多
- MaxGCPauseMillis=100 对 G1 是真正的软目标——它按停顿预算选择要回收的 Region 集合
切换前后对比
同一个压测,同样的 10 分钟 4 并发:
| 指标 | Parallel Scavenge | G1 | 变化 |
|---|---|---|---|
| 最大 GC 停顿 | 1247ms | 118ms | 降 90% |
| 平均 GC 停顿 | 340ms | 72ms | 降 79% |
| Full GC 次数 | 47 | 0 | 归零 |
| YGC 次数 | 4832 | 2850 | 降 41% |
| RT p99 | 1.5s | 90ms | 恢复至基线 |

停滞时间没了,吞吐量呢?纯吞吐量的确略降(约 5-8%)——G1 的屏障和并发标记有开销。对网关这种延迟敏感服务,5-8% 吞吐换 90% 停顿优化,划算。
边界条件:什么场景不要换 G1
换 G1 不是普适答案,两类场景要刹车:
- 吞吐敏感批处理(离线计算、数据清洗):Parallel Scavenge 的吞吐优势明显,换成 G1 白白丢 5-10% 吞吐,还多了 GC 线程竞争
- 堆太小(<2G):G1 的 Region + 并发标记开销相对占比高,小堆下收益不明显,PS 反而更省心
判断标准一句话:停顿有硬约束就选低延迟收集器,纯看每秒产出量就留在 PS。
【标记】🔍 在你的 GC 日志中搜索
确认你当前跑的是不是这个组合——一条命令:

grep -E 'PSYoungGen|Ergonomics' gc.log | head -20
预期输出与判读:
267.789: [GC (Allocation Failure) [PSYoungGen: ...] ← 你在跑 Parallel Scavenge
267.789: [Full GC (Ergonomics) [PSYoungGen: ...][ParOldGen: ...] real=1.25 ← 停顿失控的元凶
- 看到
PSYoungGen→ 你在用吞吐优先的 Parallel Scavenge,停顿不受控是它的固有特性,不是参数没调好 - 看到
Full GC (Ergonomics)且real频繁 >500ms → 自适应策略在高峰期反复全堆回收,已出现结构性问题 - 看到
Pause Young (G1 Evacuation Pause)→ 已经在跑 G1,停顿发生在可控区间
GC 日志不是一个日志文件——它是一份性能体检报告。PSYoungGen 就是体检单上的"默认套餐已超标"红章。
现在的你,已经能一眼判断:这个停顿是换参数能救的,还是换收集器才能救的。
下篇我们聊 GC 日志怎么读:从一次 GC 调优看懂所有关键指标,把 PSYoungGen、Ergonomics、Pause Young 这些关键词一次性讲透。