本节目标:用一次真实的调优把「怀疑是 GC」变成可证伪的假设,并说明哪些改动有效、哪些无效,最后留下可回归的记录。
适用版本:JDK 25(Temurin 25.0.5+8),默认 G1。本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125),压缩 oops,默认 MaxHeapSize 8 GB,G1 Region 4 MB。负载工具为/usr/sbin/ab -k。
承接的问题
10.1 建立了压测口径。现在看一个具体现象:libraryd 同构的报告端点 /api/reports/summary?n=20000,在 50 并发下,p99 在两次会话里分别是 57 ms 和 40 ms,而 p50 只有 8 到 9 ms。
有人提出一个看起来很标准的方案:
-Xms2g -Xmx2g -XX:MaxGCPauseMillis=50
理由是三句话:
- 固定堆能避免运行时扩容。
- 2 GB 比默认的小堆更宽裕。
MaxGCPauseMillis=50能把停顿压在 50 ms 以内。
这三句话都不算错,但它们回答的是「GC 停顿是否可控」,不是「p99 为什么是 40 到 57 ms」。如果 GC 停顿只有 2 ms,把停顿目标设成 50 ms 不会改善端到端 p99,反而可能让 G1 选择更大的新生代、减少 GC 次数但增加单次停顿。
调优的第一步不是改参数,而是确认 p99 的构成。
一、现象与证据:先看 GC 日志
用默认 G1 启动,GC 日志写到文件。zsh 下 -Xlog: 参数必须加引号,否则 * 会被展开:
java "-Xlog:gc*:file=/tmp/vol4-e/logs/gc-default.log:time,uptime,level,tags" \
-jar minihttpd.jar 18160
压测命令固定为:
ab -k -n 300 -c 20 "http://127.0.0.1:18160/api/reports/summary?n=20000" > /dev/null
ab -k -n 2000 -c 50 "http://127.0.0.1:18160/api/reports/summary?n=20000"
三次重复、取中位数,默认 G1 的结果:
默认 G1: 4331.4 req/s mean=11.54ms p50=9ms p95=27ms p99=40ms
GC: 38 次 Young GC,最大停顿 1.8ms
p99 是 40 ms,最大 GC 停顿是 1.8 ms。即使把每一次 GC 停顿都算进尾部,也只占 p99 的 4.5%。GC 日志里没有任何 Full GC。这条证据直接否定了「p99 主要是 GC 造成」的假设。
GC 次数也不等于停顿。另一组 -Xms128m -Xmx128m 的实验里,Young GC 次数从 38 涨到 276,但最大停顿仍然只有 2.3 ms:
128m: 3913.8 req/s p50=11ms p95=24ms p99=50ms Young GC=276 最大停顿=2.3ms
G1 的 Young GC 是复制式的,停顿与存活对象和 Region 数量相关,不是与 GC 次数线性相关。276 次 2 ms 的停顿加起来是 552 ms,分散在几秒的压测里,不会直接变成某一次请求的 50 ms 尾部。它影响的是吞吐(更多 CPU 花在回收上),不是单次 p99 的主要来源。
二、假设与实验:三组参数的同负载对照
把三个候选放在同一负载、同一台机器、同一轮会话里跑,每组 3 次取中位数:
| 配置 | 吞吐 req/s | 平均 ms | p50 ms | p95 ms | p99 ms | Young GC | 最大停顿 ms |
|---|---|---|---|---|---|---|---|
| 默认 G1 | 4331.4 | 11.54 | 9 | 27 | 40 | 38 | 1.8 |
-Xms2g -Xmx2g -XX:MaxGCPauseMillis=50 | 4539.2 | 11.02 | 8 | 26 | 51 | 22 | 2.2 |
-Xms128m -Xmx128m | 3913.8 | 12.78 | 11 | 24 | 50 | 276 | 2.3 |
逐列读:
- 固定 2 GB 把吞吐提高了约 4.8%(4331 到 4539),但 p99 从 40 ms 涨到 51 ms,恶化 27%。GC 次数从 38 降到 22,最大停顿从 1.8 ms 升到 2.2 ms。这是「少而长」的取舍:G1 在更大的堆和 50 ms 目标下选择了更大的新生代。
- 128 MB 小堆的吞吐下降 9.6%,p99 涨到 50 ms,Young GC 276 次。小堆让回收更频繁,CPU 花在 GC 上的比例上升,吞吐下降;但单次停顿仍然只有 2.3 ms,p99 没有爆炸。
- 默认 G1 的 p99 最好(40 ms),吞吐居中。它不是「最快」,但在 p99 这个口径上最优。
如果只看吞吐,2g+p50 是赢家,+4.8%。如果看 p99,它输 27%。调优目标必须先写清是吞吐还是尾部延迟;两个目标在这个负载上不一致。
MaxGCPauseMillis=50 也不是硬保证,它是 G1 的期望值。本机三组配置的最大停顿都远低于 50 ms,说明这个参数根本没有成为约束。把它加进去,只是让 G1 在「新生代大小」和「停顿目标」之间重新做了一次选择,结果并不更好。
三、真正的证据:p99 随并发上升
GC 解释不了 p99,下一步看并发。固定默认 G1,只改 ab 的 -c,每个并发跑两轮取中位数:
| 并发 | 吞吐 req/s | 平均 ms | p50 ms | p95 ms | p99 ms | L = λW |
|---|---|---|---|---|---|---|
| 10 | 3964.7 | 2.52 | 2.0 | 6.5 | 9.5 | 10.0 |
| 20 | 3994.4 | 5.03 | 3.5 | 12.5 | 21.0 | 20.1 |
| 50 | 4146.3 | 12.09 | 8.5 | 30.0 | 57.0 | 50.1 |
| 100 | 3447.1 | 29.02 | 24.0 | 62.5 | 87.5 | 100.0 |
吞吐在 10 到 50 并发之间几乎不变(3965 到 4146),p99 从 9.5 ms 涨到 57 ms;100 并发时吞吐反而降到 3447,p99 87.5 ms。
L = λW 与并发数几乎完全一致(10.0、20.1、50.1、100.0),说明闭路压测器下「在途请求数 ≈ 并发数」。服务端每秒能处理的请求数已经到顶,多加的并发只是在队列里等。p99 的增长主要来自排队,不是 GC。
把候选假设列成表,逐条淘汰:
| 假设 | 证据 | 结论 |
|---|---|---|
| GC 停顿造成尾部 | 最大停顿 1.8 ms,无 Full GC | 贡献不到 5%,淘汰 |
| 堆太小造成频繁回收 | 128 MB 下 276 次 Young GC,吞吐 -9.6% | 影响吞吐,不是 p99 主体 |
| 并发超过最佳工作点 | p99 随 c 从 9.5 涨到 87.5 ms,L≈c | 主因,保留 |
| 客户端连接开销 | 不带 -k 时 p99 78 ms,带 -k 17 ms | 测量口径问题,先修正 |
这就把假设从「GC 造成尾部」改成了「50 并发超过单实例的最佳工作点」。对应的改动不是 JVM 参数,而是限制单实例的在途请求数,把多余流量分给副本。
默认堆在这轮压测里没有出现扩容尖刺,原因是压测只持续几秒,G1 的堆扩展发生在早期,warmup 已经把它推到位。长时间运行时,堆扩展可能带来额外的页分配和停顿;那种场景要用更长的测量窗口重新验证,不能直接套用本节结论。
四、有效与无效的改动
把这一轮的改动分成两类:
无效改动:
-Xms2g -Xmx2g:吞吐 +4.8%,p99 +27%,不满足尾部目标。-XX:MaxGCPauseMillis=50:三组配置的最大停顿都低于 3 ms,参数没有成为约束;加上它只是改变 G1 的启发式。-Xms128m -Xmx128m:GC 次数增加 7 倍,吞吐 -9.6%,p99 没有改善。-XX:+UseZGC:本节前的一组单次对照里,默认堆的 ZGC 吞吐只有默认 G1 的一半左右,固定 4 GB 后才追平;这组数字噪声大,不足以支持「ZGC 更快」或「ZGC 更慢」的结论,只能说明低延迟收集器不是无条件更优。
有效改动:
- 把单实例在途请求从 50 降到 20。本机 c=20 时 p99 21 ms、吞吐 3994 req/s;c=50 时 p99 57 ms、吞吐 4146 req/s。用 3.7% 的吞吐换 63% 的 p99 改善。
- 按单实例 4000 req/s、目标 30% 余量规划,单实例承载 2800 req/s;5000 QPS 需要 2 个实例(5600),再加 1 个 N+1 副本,共 3 个。
- 保留默认 G1,不固定堆。默认堆会按需增长,但本机压测期间没有出现扩容导致的停顿尖刺;固定 2 GB 反而让 p99 变差。
这不是「参数无用」的结论,而是「参数要对准瓶颈」。如果 GC 日志显示 Full GC 或单次停顿 200 ms,那么固定堆、换收集器、调 Region 都可能有效。本节的数据不支持那个场景,所以不写「2g+p50 能提升 p99」这种没有证据的结论。
在 libraryd 里落地
调优记录要能回答「当时为什么这么改」。用一个 record 把五段固定下来:
package com.plume.library.app;
public record TuningRecord(
String date,
String symptom,
String evidence,
String hypothesis,
String change,
String before,
String after,
String decision) {
}
本次实验填进去的真实内容是:
TuningRecord record = new TuningRecord(
"2026-10-28",
"报告端点 c=50 时 p99 40-57ms,p50 8-9ms",
"GC 最大停顿 1.8ms,38 次 Young GC,无 Full GC;p99 随并发从 9.5ms 涨到 87.5ms",
"GC 尾巴;或 50 并发超过单实例最佳工作点",
"对照组:默认 G1 / 2g+p50 / 128m;变量组:c=20",
"默认 G1 c=50:4331 req/s,p99 40ms",
"默认 G1 c=20:3994 req/s,p99 21ms;2g+p50 c=50:4539 req/s,p99 51ms",
"保留默认 G1;单实例在途上限 20,按 2800 req/s 规划容量");
RuntimeProbe 负责把运行时的堆、GC、线程和 uptime 打进同一行日志,和压测报告对齐:
package com.plume.library.app;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.ThreadMXBean;
public final class RuntimeProbe {
public String oneLine() {
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
long gcCount = 0;
long gcTime = 0;
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
gcCount += gc.getCollectionCount();
gcTime += gc.getCollectionTime();
}
return "heapUsed=" + memory.getHeapMemoryUsage().getUsed() / 1024 / 1024 + "MB"
+ " heapMax=" + memory.getHeapMemoryUsage().getMax() / 1024 / 1024 + "MB"
+ " gcCount=" + gcCount
+ " gcTime=" + gcTime + "ms"
+ " threads=" + threads.getThreadCount()
+ " uptime=" + ManagementFactory.getRuntimeMXBean().getUptime() + "ms";
}
}
压测前后各打一行,报告里就能同时看到「吞吐/p99」和「堆/GC/线程」。如果 p99 变差而 gcTime 几乎没动,优先怀疑排队、锁、数据库或下游,而不是继续调 GC。
GC 日志的采集命令固定成脚本:
#!/usr/bin/env bash
set -euo pipefail
JAVA="$HOME/.jdks/jdk-25.0.5+8/Contents/Home/bin/java"
LOG="${1:-/tmp/libraryd-gc.log}"
exec "$JAVA" "-Xlog:gc*:file=$LOG:time,uptime,level,tags" \
-XX:+HeapDumpOnOutOfMemoryError \
-jar /opt/libraryd/libraryd-app.jar
统计时不要只看行数,要区分 Young/Full 和停顿:
grep -c "Pause Young" /tmp/libraryd-gc.log
grep "Pause Young" /tmp/libraryd-gc.log | sed -E 's/.* ([0-9.]+)ms/\1/' | sort -n | tail -1
grep -c "Pause Full" /tmp/libraryd-gc.log
第一条数次数,第二条取最大停顿,第三条确认有没有 Full GC。三条合起来才能判断「GC 是否值得继续调」。
常见错误与边界
错误一:一次改多个参数。 同时加 -Xms2g、-Xmx2g 和 MaxGCPauseMillis=50,无法判断 p99 的变化来自哪一个。本节的对照组一次只改一组,才有「2g+p50 吞吐 +4.8%、p99 +27%」的可解释结论。
错误二:把 MaxGCPauseMillis 当硬上限。 它是 G1 的期望值,不是承诺。本机三组配置的最大停顿都低于 3 ms,这个参数没有成为约束;真正的停顿由存活对象、Region 数量和分配速率决定。
错误三:只看 GC 次数,不看停顿分布。 128 MB 配置有 276 次 Young GC,但最大停顿 2.3 ms;它的问题是吞吐下降,不是单次停顿。反过来,一次 500 ms 的 Full GC 比 1000 次 2 ms 的 Young GC 更值得处理。
错误四:用均值判断调优是否成功。 2g+p50 的平均延迟从 11.54 降到 11.02 ms,看起来更好;p99 从 40 涨到 51 ms,尾部变差。目标如果是 p99,这个改动应该被拒绝。
错误五:在压测端不带 -k 的情况下调服务端。 10.1 已经证明,连接建立能让同一端点的 p99 从 17 ms 变成 78 ms。压测口径不稳定时,任何参数对比都是噪声。
边界一:本机结论不能外推到生产。 10 核 M1 Pro、localhost、无数据库、无 TLS,与 libraryd 的生产环境差很多。这里验证的是方法和决策链,不是生产容量数字。
边界二:默认 G1 的「好」是这组负载下的好。 如果生产出现大对象、长生命周期缓存、Full GC 或 200 ms 停顿,默认 G1 不一定最优,需要重新采集证据。不要把这节的结论固化成「永远不要调 GC」。
小结
- 调优链是「现象 → 证据 → 假设 → 改动 → 回归」,不是「看到 p99 高就加 GC 参数」。
- 本机默认 G1 在报告端点 c=50 下是 4331 req/s、p99 40 ms;GC 最大停顿 1.8 ms,只占 p99 的 4.5%。
-Xms2g -Xmx2g -XX:MaxGCPauseMillis=50把吞吐提高 4.8%,但 p99 恶化 27%;-Xms128m把吞吐拉低 9.6%,GC 次数增加 7 倍。- p99 随并发从 9.5 ms(c=10)涨到 87.5 ms(c=100),吞吐在 c=50 后不再增长;真正的瓶颈是排队,不是 GC。
- 有效改动是限制单实例在途请求到 20,并把容量按 2800 req/s/实例、N+1 副本规划;JVM 参数保持默认。
- 调优记录要同时写清症状、证据、假设、改动、前后数字和决策;没有证据的「提升」不进主线。
单次调优的结论要能变成长期门禁。下一节把吞吐、分位、GC 和启动时间写成基线 JSON,用脚本对比两次运行,并给出容量表和扩容触发条件。
阅读导航:上一节:10.1 压测设计与指标口径:吞吐、分位与协调缺失 · 下一节:10.3 建立回归基线与容量规划 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。