← 返回博客
2026-09-02 11:14:00

GC 调参入门:日志看懂了,该动哪些参数

GC 调参入门:日志看懂了,该动哪些参数

上一篇我们用 GC 日志和 jcmd 把堆看明白了:Young GC 频繁、Full GC 少见、巨对象在抢 Region。这篇把 JVM 调参最常用的参数收成一张速查卡,每个参数管什么、默认是多少、什么时候值得动。全文按 JDK 8 / JDK 9+ 双版本标注,实验环境是本机 JDK 26(默认 G1)。

调参前先立三条原则

先判断:这是吞吐问题还是延迟问题

调参前先给问题分类,决定用哪套参数:

症状问题对策
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/32想让年轻代更大就把值调小(如 1 → 1:1,年轻代占一半);晋升频繁先查存活对象,别指望纯调参
-XX:SurvivorRatioEden ÷ Survivor。8 = Eden:Survivor 8:1,即 Eden 占年轻代 8/10,两个 Survivor 各占 1/108想让 Eden 更大就把值调大(如 16);想让 Survivor 变大、减少提前晋升就把值调小
-XX:MaxTenuringThreshold晋升阈值(对象扛过 N 次 Young GC 才进老年代)15默认;不建议为调参乱动
-XX:ParallelGCThreads并行回收线程数按 CPU 核数默认;容器里核对可用核数
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动 dump 堆快照建议开启,配 -XX:HeapDumpPath
-XX:+UseCompressedOops压缩对象指针堆小于 32G 时开启默认;别手动关

按收集器挑参数

Parallel(吞吐优先,JDK 8 默认):老年代用标记整理、每次回收都 STW,追求把活干完。主要调 -XX:ParallelGCThreads,别拿延迟参数去调它——它的停顿高是设计使然。

java -Xms4g -Xmx4g -XX:ParallelGCThreads=8 -jar app.jar

G1(延迟优先,JDK 9+ 默认)

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

先问一句:参数改完,怎么确认它真生效了

网上抄来的参数经常「改了没效果」——调参前先学会看生效值。启动命令、运行参数、生效默认值三者不是一回事,两个入口:

# 默认
    uintx MaxGCPauseMillis                         = 200    {product} {default}
# 加了 -XX:MaxGCPauseMillis=150 之后
    uintx MaxGCPauseMillis                        := 150    {product} {command line}

查单个参数:Linux 上 java -XX:+PrintFlagsFinal -version | grep -i maxgcpausemillis 管道过滤(Windows 用 findstr /i)。一个常见的翻车点:G1 的 G1HeapRegionSize 默认显示 0——它是「0 = 自适应,由堆大小推导(1M~32M)」的意思,PrintFlagsFinal 里看到 0 别以为没设生效。开跑前把每个要调的参数用 PrintFlagsFinal 照一遍,能省掉「调了半天发现压根没传上去」的时间。

调完怎么验证:一场真实的压测对比

改一个参数,重启,用压测跑同一份流量,对比 GC 日志里的停顿时间和 jstat -gcutil 1000FGC/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 / 1220300 / 300
平均单次停顿≈1.1ms≈1.4ms
总停顿时间≈1.32s≈0.43s
Pause Full00
实测墙钟12.0s / 15.6s11.1s / 13.2s

这张表怎么读:

注意边界:这套流量没有晋升压力,不代表真实服务该把年轻代无限调大。真实服务里有缓存和长命对象,年轻代越大老年代越小,晋升压力上来反而诱发 Full GC——这正对应上面 NewRatio 那行说的「晋升频繁先查存活对象」。实验是演示方法、不是基准,上生产要拿自己的流量、自己的 P99 复测。

顺手把上面 JDK 8 那两行命令在 JDK 8(1.8.0_401,显式 -XX:+UseG1GC)上实跑了一遍,同一份流量、同样的 -Xmn 差异:

指标(GC 日志,JDK 8 G1)-Xmn64m-Xmn256m
Young 停顿次数1222300
平均单次停顿≈1.4ms≈2.1ms
总停顿时间≈1.77s≈0.64s
Pause Full00

读法和上面 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 贴着 -Xmn64m257M 贴着 -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,得构造缓存、长命对象那类晋升流量,那是另一类问题,不是改年轻代能逼出来的。

两个坑

面试被问「你调过 JVM 吗」,别背参数表,按这套讲:先看日志和堆定位问题,再改一个变量,用压测数据验证——这和上一篇「先理论、再看日志、最后动参数」是一条线。

本文关键词:JVM、GC 调优、G1、ZGC、参数速查