GC 调参入门:日志看懂了,该动哪些参数
上一篇我们用 GC 日志和 jcmd 把堆看明白了:Young GC 频繁、Full GC 少见、巨对象在抢 Region。这篇把 JVM 调参最常用的参数收成一张速查卡,每个参数管什么、默认是多少、什么时候值得动。全文按 JDK 8 / JDK 9+ 双版本标注,实验环境是本机 JDK 26(默认 G1)。
调参前先立三条原则
- 先看日志和堆,再动参数:GC 日志(JDK 9+ 用
-Xlog:gc*)和jcmd会告诉你问题在分配还是在晋升。连问题都没定位就调参,是拿参数赌运气。GC.heap_info - 一次只改一个变量:同时改三个参数,出了效果不知道是谁的功劳,出了事故不知道是谁的锅。
- 参数不是越多越好:默认值大多够用。调优是在默认值上做减法,不是往参数表上做加法。
先判断:这是吞吐问题还是延迟问题
调参前先给问题分类,决定用哪套参数:
| 症状 | 问题 | 对策 |
|---|---|---|
| Young GC 频繁但停顿都小于 10ms | 健康形态,不是问题 | 别动(上一篇讲过) |
| Full GC 每几分钟一次、停顿秒级 | 老年代被占满 | 先 jmap -histo 看谁占的,多数是缓存/大对象没释放,先修代码 |
| 停顿达标但吞吐上不去 | 追求吞吐 | 考虑 Parallel |
| P99 要求几十毫秒内 | 延迟敏感 | 用 G1 调 MaxGCPauseMillis,仍不满足再上 ZGC |
通用参数:所有收集器都适用
先立一个换算规律,下面几个比值参数都要用它:「A 比 B」类的参数,值 = A ÷ B。所以 NewRatio 的 2 是老年代 ÷ 年轻代 = 2,即老年代 : 年轻代 = 2 : 1——老年代是年轻代的 2 倍,年轻代约占总堆 1/3,不是 1 : 2。
| 参数 | 含义 | 默认值 | 建议 |
|---|---|---|---|
-Xms / -Xmx | 初始堆 / 最大堆 | 视机器 | 生产设为相等,避免扩容抖动 |
-Xmn | 年轻代总大小(Eden + 两个 Survivor) | 默认不显式设,由 NewRatio 推出(约堆的 1/3) | 设了它就别再设 NewRatio,两套都写以 -Xmn 为准 |
-XX:NewRatio | 老年代 ÷ 年轻代。2 = 老年代:年轻代 2:1,年轻代约占总堆 1/3 | 2 | 想让年轻代更大就把值调小(如 1 → 1:1,年轻代占一半);晋升频繁先查存活对象,别指望纯调参 |
-XX:SurvivorRatio | Eden ÷ Survivor。8 = Eden:Survivor 8:1,即 Eden 占年轻代 8/10,两个 Survivor 各占 1/10 | 8 | 想让 Eden 更大就把值调大(如 16);想让 Survivor 变大、减少提前晋升就把值调小 |
-XX:MaxTenuringThreshold | 晋升阈值(对象扛过 N 次 Young GC 才进老年代) | 15 | 默认;不建议为调参乱动 |
-XX:ParallelGCThreads | 并行回收线程数 | 按 CPU 核数 | 默认;容器里核对可用核数 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump 堆快照 | 关 | 建议开启,配 -XX:HeapDumpPath |
-XX:+UseCompressedOops | 压缩对象指针 | 堆小于 32G 时开启 | 默认;别手动关 |
按收集器挑参数
Parallel(吞吐优先,JDK 8 默认):老年代用标记整理、每次回收都 STW,追求把活干完。主要调 -XX:ParallelGCThreads,别拿延迟参数去调它——它的停顿高是设计使然。
java -Xms4g -Xmx4g -XX:ParallelGCThreads=8 -jar app.jar
G1(延迟优先,JDK 9+ 默认):
-XX:MaxGCPauseMillis:停顿软目标,默认 200ms,别写 50ms 这种激进值。-XX:G1HeapRegionSize:Region 大小,默认自动(1M~32M),一般不用动。-XX:InitiatingHeapOccupancyPercent:并发标记阈值(IHOP),默认 45%,JDK 8u40 前叫G1HeapOccupancyPercent;默认开自适应,阈值会在 40%~55% 浮动,别记成死数。-XX:G1MixedGCCountTarget:Mixed GC 分摊轮数,默认 8。
java -Xms8g -Xmx8g -XX:MaxGCPauseMillis=150 -jar app.jar
ZGC(超低延迟,JDK 11 实验 / JDK 15 转正):-XX:+UseZGC 开启,停顿 <10ms 且不随堆大小增长。代价是 CPU 高,可配 -XX:ConcGCThreads。堆不大、CPU 紧张的场景不值。
java -Xms8g -Xmx8g -XX:+UseZGC -jar app.jar # JDK 15+;JDK 11~14 需加 -XX:+UnlockExperimentalVMOptions
先问一句:参数改完,怎么确认它真生效了
网上抄来的参数经常「改了没效果」——调参前先学会看生效值。启动命令、运行参数、生效默认值三者不是一回事,两个入口:
java -XX:+PrintFlagsFinal -version:把所有参数和当前生效值打出来,JDK 8 到 JDK 26 通用。输出里=表示走默认,:=表示被显式改过——看到:=才说明你的设置真的进了生效值。实测(JDK 26,只截一行):
# 默认
uintx MaxGCPauseMillis = 200 {product} {default}
# 加了 -XX:MaxGCPauseMillis=150 之后
uintx MaxGCPauseMillis := 150 {product} {command line}
- 运行时:进程已经在跑、想知道它实际带什么参数,用
jcmd(JDK 8 起有);JDK 8 的老牌替代是VM.flags jinfo -flags。注意这个看的是进程启动时的设置,运行中一般改不了这些 GC 参数(能动态改的少数几个除外)。
查单个参数:Linux 上 java -XX:+PrintFlagsFinal -version | grep -i maxgcpausemillis 管道过滤(Windows 用 findstr /i)。一个常见的翻车点:G1 的 G1HeapRegionSize 默认显示 0——它是「0 = 自适应,由堆大小推导(1M~32M)」的意思,PrintFlagsFinal 里看到 0 别以为没设生效。开跑前把每个要调的参数用 PrintFlagsFinal 照一遍,能省掉「调了半天发现压根没传上去」的时间。
调完怎么验证:一场真实的压测对比
改一个参数,重启,用压测跑同一份流量,对比 GC 日志里的停顿时间和 jstat -gcutil 的 FGC/FGCT。P99 降了、Full GC 不再涨,才是有效;没有数据支撑的调参等于没调。
这句话听着空,补一场本机真实跑的对比。实验环境:JDK 26.0.1(默认 G1)。下面是我实际跑实验用的完整压测源码,存成 GcProbe.java 就能编译运行:
import java.util.ArrayList;
import java.util.List;
/** GC 压测探针:模拟「每请求一个短命对象、用完即弃」的流量。用法:java GcProbe 150000000 */
public class GcProbe {
// INFLIGHT = 这批「还没处理完的请求」。
// 对象先 add 进它、逃逸出分配点,逼 JVM 真实分配——否则 JIT 的逃逸分析
// 会把分配优化掉,测不到 GC;攒满 FLUSH 个再整批 clear = 这批请求处理完了,
// 对象又确实是短命的。
static final List<byte[]> INFLIGHT = new ArrayList<byte[]>();
static final int FLUSH = 8192;
public static void main(String[] args) {
long total = Long.parseLong(args[0]); // 固定流量:请求对象总数
long seed = 0;
for (long i = 0; i < total; i++) {
byte[] req = new byte[512]; // 每个请求一个 ~512B 对象
req[(int) (i & 511)] = (byte) (i & 0xff); // 真实写入,防止分配被消除
INFLIGHT.add(req);
if (INFLIGHT.size() >= FLUSH) {
seed += INFLIGHT.size(); // 累加校验值,确认两轮流量一致
INFLIGHT.clear(); // 整批释放
}
}
System.out.println("requests=" + total + " seed=" + seed);
}
}
请求总数写死,保证两轮压测分配量完全一致(结果表那两组跑出来的 seed 相同,可复核)。编译运行——源码里带中文注释,javac 固定加 -encoding UTF-8,在默认按 GBK 解码的中文 Windows 上才不会报错:
javac -encoding UTF-8 GcProbe.java
java -Xms512m -Xmx512m -Xmn64m -Xlog:gc:file=gc-64m.log GcProbe 150000000
java -Xms512m -Xmx512m -Xmn256m -Xlog:gc:file=gc-256m.log GcProbe 150000000
以上是 JDK 26(JDK 9+ 语法)的命令。手头只有 JDK 8 想跑同一套对比,要换三个地方:JDK 8 没有 -Xlog,日志参数改回 -XX:+PrintGCDetails -XX:+PrintGCDateStamps + -Xloggc:;JDK 8 的默认收集器是 Parallel 不是 G1,补 -XX:+UseG1GC 才和上面同一个收集器、结果才有可比性;编译必须用 JDK 8 自带的 javac——JDK 26 编出的 class 是 major 70 字节码,JRE 8 只认到 major 52,直接跑会抛 UnsupportedClassVersionError。javac 同样加 -encoding UTF-8:
javac -encoding UTF-8 GcProbe.java
java -Xms512m -Xmx512m -Xmn64m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc-64m.log GcProbe 150000000
java -Xms512m -Xmx512m -Xmn256m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc-256m.log GcProbe 150000000
JDK 8 的日志里没有 Pause Young 这种行,G1 停顿打的是 [GC pause (G1 Evacuation Pause) (young) ...],数停顿、量单次耗时按 G1 Evacuation Pause 来 grep,别套 9+ 的关键词。
两轮唯一差异:-Xmn 从 64m 改成 256m,堆都是 -Xms512m -Xmx512m。改完参数重启、重跑同一份流量,从 GC 日志里数停顿。结果(每组都跑了两遍):
| 指标(GC 日志,JDK 26 默认 G1) | -Xmn64m | -Xmn256m |
|---|---|---|
| Pause Young 次数 | 1220 / 1220 | 300 / 300 |
| 平均单次停顿 | ≈1.1ms | ≈1.4ms |
| 总停顿时间 | ≈1.32s | ≈0.43s |
| Pause Full | 0 | 0 |
| 实测墙钟 | 12.0s / 15.6s | 11.1s / 13.2s |
这张表怎么读:
- 停顿次数 1220 → 300:年轻代大了 4 倍,Eden 被装满的次数少了约 4 倍,Young GC 自然少做这么多轮。1220 ÷ 300 ≈ 4,和 64m → 256m 的倍数对得上,说明停顿次数主要被「年轻代多久被装满」决定。
- 单次停顿 1.1 → 1.4ms 反而略长:一次要搬的存活数据多了。但单次长一点,换来总停顿从约 1.3s 砍到约 0.43s(约 3 倍)——总停顿才是要盯的指标。
- Full GC 都是 0:这批流量全是短命对象,根本不进老年代,FGC 自然不动。想让 FGC 涨,得构造晋升流量(缓存、长命对象),那是开头判断表里「Full GC 频繁先查缓存和代码」的另一类问题——不是改年轻代能掩盖的。
- 墙钟只在方向上是可信的:两轮都是小年轻代更慢,但绝对值在 11~15s 之间抖(JIT 预热、本机负载),所以严谨对比看 GC 日志里的停顿,别拿墙钟当基准。
注意边界:这套流量没有晋升压力,不代表真实服务该把年轻代无限调大。真实服务里有缓存和长命对象,年轻代越大老年代越小,晋升压力上来反而诱发 Full GC——这正对应上面 NewRatio 那行说的「晋升频繁先查存活对象」。实验是演示方法、不是基准,上生产要拿自己的流量、自己的 P99 复测。
顺手把上面 JDK 8 那两行命令在 JDK 8(1.8.0_401,显式 -XX:+UseG1GC)上实跑了一遍,同一份流量、同样的 -Xmn 差异:
| 指标(GC 日志,JDK 8 G1) | -Xmn64m | -Xmn256m |
|---|---|---|
| Young 停顿次数 | 1222 | 300 |
| 平均单次停顿 | ≈1.4ms | ≈2.1ms |
| 总停顿时间 | ≈1.77s | ≈0.64s |
| Pause Full | 0 | 0 |
读法和上面 JDK 26 那张表完全一样:停顿 1222 → 300(约 4 倍,正好对应年轻代 4 倍)、单次略升、总停顿降约 2.8 倍、Full GC 为 0。这说明结论跟着方法走、不挑 JDK 版本。唯一别做的是拿 JDK 8 的绝对值和 JDK 26 比:单次和总停顿都偏高一点,是 G1 在 8 与 26 的实现、GC Worker 数不同,跨版本比绝对值没有意义。
下面贴几行这轮跑出来的真实日志(与结论无关的机器信息已剪掉:JDK 26 无时间戳、JDK 8 剪掉了 PrintGCDateStamps 的日期前缀、文件头的物理内存横幅),对着回一遍上一篇「先看日志和堆,再决定动哪个参数」:
# JDK 26,-Xmn64m # JDK 26,-Xmn256m
[0.080s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 65M->2M(512M) 1.742ms
[0.180s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 257M->4M(512M) 3.376ms
一行怎么拆:停顿前堆 65M/257M → 停顿后 2M/4M,括号里的 512M 是总堆,行尾 1.742ms/3.376ms 是本次停顿耗时。盯两个停顿前的数:65M 贴着 -Xmn64m、257M 贴着 -Xmn256m——每次都是 Eden 被装满才触发 Young GC,正好印证上一篇那句「Young GC 频繁 = Eden 满得太快」;-Xmn 把装满前的阈值抬高了,触发次数才从 1220 掉到 300。
JDK 8 里同一次停顿长这样(这就是前面说 8 和 9+ 关键词不同的原因),它还会展开每个阶段的耗时:
# JDK 8,-Xmn64m
0.144: [GC pause (G1 Evacuation Pause) (young), 0.0019771 secs]
[Parallel Time: 1.6 ms, GC Workers: 8]
值域和 JDK 26 一个量级(千分之一秒级)。第二行 GC Workers: 8 是回收线程数 = CPU 核数,对应上面表格里 -XX:ParallelGCThreads 默认值「按 CPU 核数」那行——8 核机器不用设。所有停顿单次都在 10ms 以内,正对应开头判断表第一行说的「Young GC 频繁但停顿都小于 10ms」那种健康形态:单次停顿不是问题,值得优化的只是它发生的次数(也就是总停顿)。
两份日志从头到尾只有这种 young 停顿行,Pause Full 一行都没有(grep -c "Pause Full" 和 JDK 8 的 grep -c "Full GC" 都是 0)。Full GC 不动不是运气,是这批流量全是短命对象、根本没有晋升压力——对应上面怎么读的第三条:想让实验里出现 Full GC,得构造缓存、长命对象那类晋升流量,那是另一类问题,不是改年轻代能逼出来的。
两个坑
-Xmn和-XX:NewRatio同时设,-Xmn生效,NewRatio 被忽略。控制年轻代大小只用一个入口。- 参数名随 JDK 版本变:IHOP 在 JDK 8u40 改名;GC 日志 JDK 8 用
-XX:+PrintGCDetails -XX:+PrintGCDateStamps,JDK 9+ 用-Xlog:gc*。网上旧文章的参数名先核对版本再用。
面试被问「你调过 JVM 吗」,别背参数表,按这套讲:先看日志和堆定位问题,再改一个变量,用压测数据验证——这和上一篇「先理论、再看日志、最后动参数」是一条线。
本文关键词:JVM、GC 调优、G1、ZGC、参数速查