本节目标:让两次实验的差异只来自被测变量,而不是 JVM 版本、核数、堆上限或启动参数的悄悄变化。
适用版本:JDK 25(Temurin 25.0.5);G1;macOS/aarch64。
承接的问题
1.1 用 jcmd 取得了一份环境快照,但那份快照只能说明「当时那台机器是这样」。如果下一次实验换了一台 8 核机器、堆从 1 GB 变成 2 GB、又顺手加了 -XX:+UseZGC,两次数字放在一起比较就没有意义。
一个具体的例子:同一个 LabApp,只改堆大小,GC.heap_info 的 region size 就从 1 MB 变成 4 MB,年轻代 Region 数、GC 触发频率、单次停顿都会跟着变。如果再改 ActiveProcessorCount,GC 线程与 JIT 编译线程也会变。所谓「可复现」,不是把命令抄下来,而是把影响结果的所有输入固定住,只留一个自变量。
这一节先列出必须固定的五件事,再用本机真实输出看它们各自改变了什么,最后把采集动作固化成 scripts/jvm/collect.sh。
固定五件事
| 项目 | 固定方式 | 为什么 |
|---|---|---|
| JDK | JAVA_HOME 指向 Temurin 25.0.5+8,javac --release 25 | 不同 JDK 的默认 GC、ergonomics、flag 都不同 |
| CPU | 物理机固定核数;容器设 -XX:ActiveProcessorCount | 决定 GC 线程、JIT 编译线程、ForkJoin 并行度 |
| 堆 | -Xms 与 -Xmx 相等 | 避免运行中扩容,committed 与 region 数才是稳定值 |
| GC | 显式 -XX:+UseG1GC,需要对比时只换这一项 | 默认 GC 会随 JDK 与平台变化 |
| 启动参数 | 全部写进脚本,实验前打印 VM.flags | 防止「以为没加,其实加了」 |
固定不等于永远不改。它的意思是:一次实验里只改一项,并且把改之前的值记录在案。
堆:-Xms 与 -Xmx 为什么必须相等
先看四组真实配置的 VM.flags 关键字段。命令都是:
jcmd <pid> VM.flags
| 启动参数 | InitialHeapSize | MaxHeapSize | GC.heap_info committed | region size |
|---|---|---|---|---|
| 不加堆参数 | 536870912(512 MB) | 8589934592(8 GB) | 528384K | 4096K |
-Xms256m -Xmx256m | 268435456 | 268435456 | 262144K | 1024K |
-Xms1g -Xmx1g | 1073741824 | 1073741824 | 1048576K | 1024K |
-Xms2g -Xmx2g | 2147483648 | 2147483648 | 2097152K | 1024K |
-Xms256m -Xmx2g | 268435456 | 2147483648 | 264192K | 1024K |
前三行说明:不加参数时,初始堆是物理内存的 1/64(32 GB 的机器上是 512 MB),最大堆是 1/4(8 GB),G1 Region 是 4 MB。堆降到 1 GB 或 2 GB 后,Region 自动变成 1 MB。Region 大小不是常量,它由 -Xmx 和 ergonomics 共同决定;写文章或做容量模型时不能把 4 MB 当成所有环境的事实。
最后一行的 -Xms256m -Xmx2g 更值得看:
garbage-first heap total reserved 2097152K, committed 264192K, used 38982K [0x0000000780000000, 0x0000000800000000)
region size 1024K, 37 young (37888K), 1 survivors (1024K)
reserved 是 2 GB 的地址空间,committed 只有约 258 MB。也就是说,堆的虚拟地址已经按 -Xmx 留好,但物理内存是按需提交的。随着分配增长,JVM 会多次扩展 committed,每次扩展都可能伴随 GC 或元数据调整。对需要稳定延迟的服务,-Xms 小于 -Xmx 意味着运行早期多了一段「堆还在长」的阶段;这段阶段的数字不能和稳态混在一起。
反过来,-Xms 等于 -Xmx 也不是没有代价:进程一启动就向系统要满堆内存,容器里如果 limit 设得比堆小,会在启动阶段就被 OOM kill。正确顺序是先确认容器 limit,再设堆,再让 -Xms = -Xmx。
-Xmn 不属于这一组。它在 JDK 25 的 G1 下会被翻译成 NewSize / MaxNewSize,但 G1 仍然按暂停目标动态调整年轻代。本机 -Xmn256m -Xmx512m 下 MaxNewSize=268435456,而两次 GC.heap_info 的 young 分别是 166 MB 和 170 MB,并不是固定 256 MB。不要用 -Xmn 精确控制 G1 新生代;要影响年轻代,用 -XX:MaxGCPauseMillis 和 Region 数去推。
CPU:ActiveProcessorCount 改的不只是 GC 线程
本机 10 核,默认与 -XX:ActiveProcessorCount=2 的对比:
| flag | 默认(10 核) | -XX:ActiveProcessorCount=2 |
|---|---|---|
ActiveProcessorCount | -1(default) | 2(command line) |
ParallelGCThreads | 9 | 2 |
ConcGCThreads | 2 | 1 |
G1ConcRefinementThreads | 9 | 2 |
CICompilerCount | 4 | 2 |
采集命令:
jcmd <pid> VM.flags -all | grep -E ' (ParallelGCThreads|ConcGCThreads|CICompilerCount|ActiveProcessorCount|G1ConcRefinementThreads) '
本机输出(ActiveProcessorCount=2 的进程):
int ActiveProcessorCount = 2 {product} {command line}
intx CICompilerCount = 2 {product} {ergonomic}
uint ConcGCThreads = 1 {product} {ergonomic}
uint G1ConcRefinementThreads = 2 {product} {ergonomic}
uint ParallelGCThreads = 2 {product} {default}
三个观察:
ActiveProcessorCount是{command line},其余几项是{ergonomic},说明后者是由前者推出来的。ParallelGCThreads从 9 降到 2,Young GC 的并行度随之下降;在小堆上这通常够用,在大堆上可能拉长单次停顿。CICompilerCount从 4 降到 2。这一项常被忽略:限制 CPU 数不仅影响 GC,也影响 JIT 编译线程数,冷启动和预热阶段可能变慢。做「低配容器」实验时,必须把编译线程的影响算进去,不能只盯 GC。
容器里还有一层:cgroup 的 CPU quota 与 ActiveProcessorCount 不是一回事。JVM 25 会读 cgroup 限制来做 ergonomics,但不同运行时、不同 cgroup 版本下的行为不完全一致。生产上更稳的做法是显式写 -XX:ActiveProcessorCount=<quota 核数>,并在启动日志里打印 VM.flags 核对。本机没有跑容器实验,这里不给容器内的实测数字;第 9、10 章涉及容器时会再验证。
测量:warmup、重复与分位
固定环境只解决了「输入一致」,还解决不了「一次运行的代表性」。同一个进程连续两次 Young GC 的停顿就可能差一倍。本机 -Xms64m -Xmx64m 跑 4 秒的真实 GC 日志:
[2026-10-28T11:29:19.676+0800][2.095s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 35M->2M(64M) 1.986ms
[2026-10-28T11:29:21.474+0800][3.893s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 31M->2M(64M) 1.199ms
两次停顿分别是 1.986 ms 和 1.199 ms,回收量接近。单次运行,未做统计,这两个数字只能说明「这个量级」,不能说明「G1 的平均停顿是 1.6 ms」。要得到可用的数字,至少做到:
- 丢弃前 30 到 60 秒的 warmup,等 JIT 编译和类加载稳定。
- 同一配置重复 3 到 5 次,每次重新起进程,避免上一次的堆状态影响下一次。
- 报告分位数而不是平均值。平均值会被少数长停顿拉平,P99 才对应「用户偶尔遇到的慢」。
- 记录当次负载。空跑的 GC 日志不能预测有请求时的 GC 行为。
JMH 适合方法级微基准,不适合服务级延迟;服务级测量在第 10 章用压测客户端做。这一节只强调纪律:单次运行不能当结论。
scripts/jvm/collect.sh
把一次采集需要的命令写进脚本,输出落到带时间戳的目录。下面这个版本在本机对 LabApp 跑通过:
#!/usr/bin/env bash
# scripts/jvm/collect.sh —— 对一个运行中的 JVM 采集一次性基线
# 用法: collect.sh <pid> [outdir] [record-seconds]
set -euo pipefail
PID="${1:?usage: collect.sh <pid> [outdir] [record-seconds]}"
OUT="${2:-jvm-$(date +%Y%m%d-%H%M%S)}"
RECORD_SECONDS="${3:-30}"
JCMD="${JAVA_HOME:+$JAVA_HOME/bin/}jcmd"
JFR="${JAVA_HOME:+$JAVA_HOME/bin/}jfr"
mkdir -p "$OUT"
"$JCMD" "$PID" VM.version > "$OUT/version.txt"
"$JCMD" "$PID" VM.command_line > "$OUT/command-line.txt"
"$JCMD" "$PID" VM.flags > "$OUT/flags.txt"
"$JCMD" "$PID" VM.uptime > "$OUT/uptime.txt"
"$JCMD" "$PID" GC.heap_info > "$OUT/heap-info.txt"
"$JCMD" "$PID" VM.classloaders > "$OUT/classloaders.txt"
"$JCMD" "$PID" JFR.start name=collect \
filename="$OUT/recording.jfr" duration="${RECORD_SECONDS}s" settings=profile
sleep $((RECORD_SECONDS + 2))
"$JFR" summary "$OUT/recording.jfr" > "$OUT/jfr-summary.txt"
# 线程栈与类直方图开销更高,按需打开:
# "$JCMD" "$PID" Thread.print > "$OUT/threads.txt"
# "$JCMD" "$PID" GC.class_histogram -all > "$OUT/class-histogram.txt"
echo "collected to $OUT"
本机执行与输出:
./collect.sh 71819 /tmp/vol4-a/baseline 5
71819:
Started recording 2. The result will be written to:
/tmp/vol4-a/baseline/recording.jfr
collected to /tmp/vol4-a/baseline
目录内容:
version.txt 64B
command-line.txt 152B
flags.txt 807B
uptime.txt 17B
heap-info.txt 188B
classloaders.txt 226B
recording.jfr 341.6K
jfr-summary.txt 11.9K
脚本故意把 Thread.print 与 GC.class_histogram 注释掉。前者在几千个线程时会产生很大的文件,后者默认只统计存活对象、会触发 Full GC。它们应该按需执行,不能塞进每次例行采集。
-Xlog 不在这里,因为 GC 日志必须在启动时开启。zsh 交互式输入时,-Xlog:gc*:file=... 的 * 会被展开,必须写成 "-Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags"。放进启动脚本则没有这个问题,但统一加引号更安全。
在 libraryd 里落地
libraryd-app 的启动脚本把实验口径固化下来,环境变量只允许改堆和核数,GC 与日志格式不允许临时改:
#!/usr/bin/env bash
set -euo pipefail
JAVA_HOME="${JAVA_HOME:?JAVA_HOME is required}"
HEAP="${LIBRARYD_HEAP:-2g}"
CPUS="${LIBRARYD_CPUS:-4}"
exec "$JAVA_HOME/bin/java" \
-Xms"$HEAP" -Xmx"$HEAP" \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:ActiveProcessorCount="$CPUS" \
"-Xlog:gc*:file=/var/log/libraryd/gc.log:time,uptime,level,tags:filecount=5,filesize=20M" \
-jar libraryd-app.jar
两个细节:-Xms"$HEAP" 和 -Xmx"$HEAP" 用同一个变量,避免手误写成一高一低;-Xlog 的文件名带了轮转参数,防止日志写满磁盘。MaxGCPauseMillis=200 是 G1 的目标值,不是保证值,后面第 6 章会解释它如何影响年轻代大小。
服务自己也要能说清「我是用什么参数启动的」。在 RuntimeProbe 启动时打印一次:
package com.plume.library.app;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
public final class StartupBanner {
public static String describe() {
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
return "jvm.args=" + runtime.getInputArguments()
+ " startTime=" + runtime.getStartTime()
+ " uptime=" + runtime.getUptime() + "ms";
}
}
getInputArguments() 返回的是 JVM 实际收到的参数列表,和 jcmd VM.command_line 同源。把它写进启动日志,复盘时不用再猜「当时到底加没加 -Xmx2g」。
常见错误与边界
错误一:-Xms 与 -Xmx 不相等还拿 committed 做对比。 本机 -Xms256m -Xmx2g 的 committed 是 264192K,而 -Xms2g -Xmx2g 是 2097152K。两者的 used 可能接近,但 committed、Region 扩展次数、GC 触发点都不同,不能直接比较停顿。
错误二:以为 ActiveProcessorCount 只影响 GC。 本机把它设为 2 后,CICompilerCount 从 4 降到 2。冷启动阶段编译线程变少,方法达到 C2 的时间会变长;用低配容器做启动优化时,这个变量必须固定。
错误三:用 -Xmn 固定 G1 新生代。 本机 -Xmn256m 下 MaxNewSize 确实是 256 MB,但实际 young 只有 166 到 170 MB。G1 仍按暂停目标调整,-Xmn 只是一个上限,不是固定值。
错误四:把一次 GC 日志当成分布。 1.986 ms 和 1.199 ms 是同一进程相邻两次停顿,差距接近 40%。只跑一次就写「平均 1.6 ms」,等于用两个样本编一个统计量。
边界一:容器内存与堆不是一回事。 -Xmx 只限制 Java 堆;元空间、代码缓存、线程栈、直接内存、JVM 自身结构都在堆外。容器 limit 要大于「堆 + 堆外 + 余量」,否则 OOM kill 不会因为堆没满而不发生。
边界二:ZGenerational 在 JDK 25 已移除。 本机加上它会得到:
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
分代 ZGC 在 JDK 23 起是默认形态,不需要再开这个开关。旧文章里的「加 -XX:+ZGenerational 开启分代」在 JDK 25 上是错的。
小结
- 可复现的前提是固定 JDK、CPU、堆、GC、启动参数五件事,并且一次只改一项。
- 不加参数时本机初始堆 512 MB、最大堆 8 GB、G1 Region 4 MB;
-Xmx降到 1 到 2 GB 后 Region 变成 1 MB。 -Xms小于-Xmx会让 committed 在运行中增长;稳定延迟场景应让两者相等,同时确认容器 limit。-XX:ActiveProcessorCount=2把ParallelGCThreads从 9 降到 2、CICompilerCount从 4 降到 2,影响的不只是 GC。- 单次 GC 停顿不能当结论;至少丢弃 warmup、重复 3 到 5 次、报告分位。
collect.sh把轻量命令和一段 JFR 记录落到同一目录;Thread.print与GC.class_histogram按需执行。
环境固定之后,下一个问题是「用哪个工具看哪一层」。1.3 节把 jcmd、JFR、GC 日志、JMX 和 JMC 摆到一张桌上,说明每个工具回答什么问题、开销多大。
阅读导航:上一节:1.1 从现象到机制:JVM 研究的四条路径 · 下一节:1.3 观测工具全景:jcmd、JFR、GC 日志与 JMC 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。