《Java 虚拟机原理与性能调优》10.2 一次完整的调优:从现象到参数

报告端点 p99 40 到 57ms,团队第一反应是加 -Xms2g -Xmx2g -XX:MaxGCPauseMillis=50。本节按现象、证据、假设、改动、回归走一遍,实测三组参数与并发曲线,结论是 GC 停顿只占 p99 的 5%,真正有效的是限制单实例在途请求。

本节目标:用一次真实的调优把「怀疑是 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平均 msp50 msp95 msp99 msYoung GC最大停顿 ms
默认 G14331.411.5492740381.8
-Xms2g -Xmx2g -XX:MaxGCPauseMillis=504539.211.0282651222.2
-Xms128m -Xmx128m3913.812.781124502762.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平均 msp50 msp95 msp99 msL = λW
103964.72.522.06.59.510.0
203994.45.033.512.521.020.1
504146.312.098.530.057.050.1
1003447.129.0224.062.587.5100.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 建立回归基线与容量规划 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发