-Xlog:gc* · Pause Young / Mixed / Full · 226M->206M(256M) · GC 时间占比 · 最长停顿 · 回收后占用 · GCTimeRatio · MaxGCPauseMillis · NewRatio · -Xmn · 预热 · 泄漏 · 堆转储

GC 日志与调优:先写下目标,再从日志里找证据,最后才动一个参数

GC 调优要在三个目标之间取舍:停顿短、吞吐高(GC 占的时间少)、内存省,三个最多同时照顾两个。所以第一步是写下要哪两个、阈值是多少。第二步看 GC 日志。日志里每次停顿一行,从中算出三样数:GC 时间占比、最长停顿、回收后的堆占用。有了这三样数,就分得清问题出在参数上还是代码上。在交易靶场的大促流量下实测:G1 用默认参数就达标;换成“吞吐量收集器” Parallel,GC 时间占比是 98.4%;代码有泄漏时,把堆加一倍,OOM 只是从第 21 秒推迟到第 54 秒。

全景:一个对象从 new 到被回收 · 第 8 站(也用到第 5~7、9 站)地图:J03 · GC 日志与调优:先定目标,再动参数

①什么时候用得上:接口慢了,先要判断 GC 是不是凶手

全景第 8 站讲的是:老年代被一批回收不掉的订单占满,G1 只好做 Full GC。这一站要解决的是接下来的判断。告警说“Full GC 频繁”或者“下单接口 p99 变高”时,先要回答 GC 到底是不是原因;如果是,该改参数还是改代码。全景里那两个数字就是线索:154M->2M 说明回收得很干净,255M->255M 说明根本回收不动。

7.9 ms

大促流量,G1 默认参数,压测 30 秒里最长的一次停顿 实测

98.4%

同样的流量和堆,换成 Parallel:时间几乎全花在 Full GC 上 实测

21 → 54 秒

代码有泄漏,堆从 256 MB 加到 512 MB,OOM 只是推迟了 实测

≈ 8%

G1 默认允许 GC 占用的时间比例(GCTimeRatio=12,1/13);Parallel 默认是 1% 本机核对

场景交易靶场,大促。下单接口每秒 4000 单,每个请求解析报文、组装响应,产生约 48 KB 很快就没用的临时对象;“最近订单”缓存里常驻 160 万个订单,GC 日志显示活对象约 206 MB。容器内存 512 MB,堆给 256 MB。下面这张图是把收集器换成 Parallel 之后实际发生的事:GC 每次只停 0.2 秒,请求的延迟中位数却到了 3.3 秒。
请求按计划到达每秒 4000 个 业务线程只有一个,处理下单 Pause Full · 204 ms,所有业务线程停住 下一次 Pause Full 干活~10 ms 排队的请求还没处理完的 一次停顿里又到了 4000 × 0.204 ≈ 816 个 10 ms 只够处理几十个,队伍越来越长 实测(Parallel,堆 256 MB):36.7 秒里 172 次 Full GC 共停 36.1 秒。最长一次停顿只有 309 ms,请求延迟却是 p50 3.3 秒、最长 6.6 秒:停顿之后还要排队。

所以看 GC 有两个层次:一次停多久(日志直接写着),以及停顿占了多少时间(要自己加起来算)。后者一旦接近 100%,系统就处理不过来了,单次停顿再短也没用。

②常见笨办法:先改参数,再看结果

这几种做法看起来都在“调优”,共同的问题是没有先写下目标,也没有拿日志证明问题在哪。

常见做法

  • 从网上抄一套“生产级参数”:-Xmn、SurvivorRatio、MaxTenuringThreshold,再加几个 CMS 时代的开关
  • 接口一慢就加堆,256 MB 改 512 MB,再不行改 1 GB
  • 听说“Parallel 吞吐量高”“ZGC 停顿短”,就换收集器碰运气
  • 只看监控上的“平均 GC 停顿”,或者用 jstat 看一眼当前的数
  • 一次改五个参数,跑一会儿“感觉快了”就上线

结果(本页实测)

  • G1 写死 -Xmn128m 以后,最长停顿从 7.9 ms 变成 15.6 ms,日志里多了一种 “Preventive Collection”,G1 只好提前做 GC
  • 泄漏时把堆加一倍,OOM 从第 21 秒推迟到第 54 秒,每次 Full GC 还更长了(117 → 140 ms)
  • 换 Parallel:GC 时间占比 98.4%,处理速度从每秒 4000 单掉到 3279 单
  • Parallel 加到 768 MB 时,停顿中位数 2.8 ms,看着很好,最长一次却是 37.9 ms,请求 p99.9 是 19 ms
  • 五个参数一起改,变好了也说不清是哪个起的作用,变坏了也不知道该撤哪个

缺的是这几样:一个可以判对错的目标,一份同样流量下的基线日志,从日志里算出来的几个数,以及一次只改一处。有了它们,调优就是一个能验证的实验,而不是凭感觉。

③工具和路线:GC 日志是主证据,其余的工具补充它

工具看到什么什么时候用缺点
GC 日志 -Xlog:gc*每次 GC 的类型、原因、回收前后的占用、停顿时长,各个阶段的耗时一直开着,生产也开。本页的主角要自己汇总(下面的 GcLogStats 或日志分析工具)
jstat -gcutil <pid> 1000每秒打印各区占用的百分比、GC 次数和累计时间现场先看一眼:老年代是不是一直在涨只有累计值,看不到单次停顿
jcmd <pid> GC.heap_info / VM.flags堆现在怎么分的;JVM 最终用了哪些参数确认“我以为的参数”是不是真的生效了只是一个时刻的快照
JFR(jdk.GarbageCollection、jdk.ObjectAllocationSample)停顿,再加上“谁在分配”和调用栈要找出哪段代码在大量分配要录一段再分析,详见下一圈
Micrometer jvm.gc.pause停顿的次数、最大值,可以画进 Grafana 并配告警长期盯趋势,配告警按分钟聚合,单次细节还得回日志看
GCViewer、GCeasy 这类日志分析工具把日志画成图,并给出建议日志很长,想一眼看全貌工具给的建议也要回到目标上判断

本页选 GC 日志,配一个 80 行的统计程序:日志的格式简单,每次停顿都有记录,开着的开销很小,出了事故之后也还在。不管用哪个工具,都按下面这五步走。

① 写下目标停顿、占比、内存各一个数 ② 开日志,拿基线同样的流量,跳过预热 ③ 算三样数占比、最长停顿、回收后占用 ④ 判断是谁的问题参数,还是代码 ⑤ 只改一处,再跑和基线比,没变好就撤回 没达标:带着新结果回到 ②,一圈只改一处 达标:停手,别再动参数 回收不掉:改代码,用堆转储排查(第 9 站)

④背后的机制:日志里的数为什么能说明问题,收集器为什么各有各的目标

先知道这些Young GC 只回收新生代,Mixed GC 在 Young GC 的基础上再顺带回收几块老年代 region;Full GC 是兜底,停下所有业务线程,把整个堆整理一遍。G1 的 region、并发标记、为什么会退化成 Full GC,见 G1 与 ZGC;对象怎么从 Eden 熬到老年代,见全景第 5~7 站。这一页只关心这些过程在日志里留下了什么。

1一行日志怎么读

[7.871s][info][gc] GC(100) Pause Young (Normal) (G1 Evacuation Pause) 226M->206M(256M) 1.897ms 启动后第几秒 级别、标签 第几次 GC 停顿类型:普通的 Young GC 原因:Eden 用完了 回收前 → 回收后的堆占用 堆当前容量 这次停了多久 本机实测 OrderService 大促那一跑的第 100 次 GC(-Xlog:gc*,堆 256 MB)。装饰默认是 [uptime][level][tags],加 time 还会多出墙上时间。

开 -Xlog:gc 时,每次停顿只记一行,就是上图这行。开 -Xlog:gc* 时,同一个 GC(100) 还会多出十来行细节,其中最有用的是各区的 region 数:

GC(100) Eden regions: 20->0(20)        # Eden 用了 20 块,全部清空;下一轮还给 20 块
GC(100) Survivor regions: 1->1(3)
GC(100) Old regions: 188->188        # 老年代 188 块,这次 Young GC 不碰它
GC(100) Humongous regions: 18->18   # 缓存那张大哈希表的数组
GC(100)   Evacuate Collection Set: 1.53ms  # 复制活对象,停顿的大头
G1 日志里的停顿是什么
Pause Young (Normal)普通的 Young GC
(Concurrent Start)顺带启动一轮并发标记
Pause Remark / Cleanup并发标记中间两次很短的停顿
(Prepare Mixed) / (Mixed)开始回收老年代 region
Pause Full整堆整理,出现就要查原因

2三样数:分别对应三个目标

GC 时间占比 → 吞吐

Σ 停顿时长 ÷ 统计时长

业务线程被停住的时间比例。接近 100% 时系统处理不过来,请求越积越多(① 的图)。

最长停顿、p99 → 延迟

把停顿时长排序取尾部

一次停顿期间到达的请求都要等它结束,所以要看尾部,平均值会把偶尔的长停顿抹平。

回收后的占用 → 内存

箭头右边的数:226M->206M

回收完还剩多少,约等于活对象有多少。它决定堆至少要多大;它要是一直涨,就是泄漏。

这三样数为什么能下结论,靠的是复制式回收的两条规律。它们能从 G1 页讲的“复制活对象”直接推出来:

一次停多久 ≈ 要复制的活对象有多少

和堆有多大、垃圾有多少关系不大。大促那一跑每秒分配 196 MB,几乎全是请求结束就没用的临时对象,所以每次 Young GC 只要 1~2 ms。预热时缓存正在装进 160 万个订单,新对象几乎全是活的,一次 Young GC 就要 27~135 ms。

多久 GC 一次 ≈ 分配速率 ÷ 新生代大小

196 MB/s ÷ Eden 20 MB ≈ 每秒 10 次,日志里 30 秒正好 300 多次 Young GC。新生代越小,GC 越频繁;新生代越大,对象在里面待得越久、越容易死掉,晋升到老年代的就越少。

据此可以分出两类问题。回收后的占用低、但 GC 很频繁或停顿长,是参数问题,可以靠堆大小、新生代大小或收集器解决。回收后的占用接近上限、而且一直在涨,是代码问题:东西还被引用着,怎么调参数都回收不掉。

3为什么这样设计:三个目标不能同时最好,收集器只能各选一个优先

停顿短(最长停顿) 吞吐高(GC 占比小) 内存省(堆小) 堆给大:两头都好,花内存 GC 更勤、并发多干:花 CPU 一次攒很多再收:停顿长 挑两个, 第三个就是代价

每个收集器出厂时已经选好了优先照顾哪个目标,默认参数就是这个选择:

  • Parallel:吞吐优先。GCTimeRatio=99,目标是 GC 只占 1/(1+99) = 1% 的时间;不设停顿目标,一次停多久都可以。
  • G1:停顿优先,兼顾吞吐。MaxGCPauseMillis=200,GCTimeRatio=12(约 8%,Oracle 文档原话 “about 8%”)。
  • ZGC:停顿压到 1 ms 以下,代价是更多的 CPU 和内存,见 G1 与 ZGC 坑 1。

所以“调优”的第一步是确认收集器的优先目标和你的目标一致,然后在它的框架里给它足够的空间。这一点一错,后面怎么调参数都没用。

G1 为什么只要一个停顿目标:它不把新生代的大小写死,每次 GC 之后,按“上次复制了多少活对象、花了多久”预测下一次,再决定 Eden 给几块 region。被放弃的做法是 CMS、Parallel 那一代的思路:新生代和老年代按比例切死,大小由人用 -Xmn、NewRatio、SurvivorRatio 来定。流量一变,原来调好的比例就不对了。同样是 256 MB 的堆、206 MB 的活对象,两种做法实测的分法:

Parallel边界固定(老年代 = 2/3) 老年代 171 MB(日志:ParOldGen 175104K,一直不变) 新生代 75 MB 活对象 201 MB ↑ 比老年代大 30 MB:晋升放不下,每次 GC 都是 Full GC 放不下的 30 MB G11 MB 一块,按需分配 老年代 188 块 大对象 18 Eden 20 空着 ~27 G1 自己把 Eden 压到 20 块:每秒约 10 次 Young GC,每次 1~2 ms,换来的是活对象放得下 实测:JDK 17,堆 256 MB,大促流量。Parallel 的数取自 -Xlog:gc+heap;G1 的数取自第 100 次 GC。JDK 15 起去掉了 UseAdaptiveGCBoundary,Parallel 的两代边界不再移动(JDK-8228991)。

这里 Parallel 输掉,不是因为它“慢”,而是它的固定比例和这个场景的活对象对不上。⑤ 里把堆加到 768 MB,老年代变成 512 MB,Parallel 的 GC 时间占比反而是全场最低的 0.7%。但那样就超出了 256 MB 的内存预算。所以哪个收集器更好,要看目标是什么。

参数最少要会的几行
# 1. 日志(JDK 9+):%p = pid,%t = 启动时间;默认就会轮转(5 个文件 × 20 MB),这里改大一点
-Xlog:gc*:file=gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=50m

# 2. 目标(先只动这三个)
-Xms256m -Xmx256m            # 内存预算;两者相等,堆不用伸缩
-XX:MaxGCPauseMillis=200     # G1 的停顿目标,默认 200
-XX:GCTimeRatio=12           # GC 时间占比目标 = 1/(1+N);G1 默认 12,Parallel 默认 99

# 3. 现场看一眼
jstat -gcutil <pid> 1000     # O 列(老年代 %)是不是只涨不跌
jcmd <pid> VM.flags          # JVM 实际用的参数
jcmd <pid> GC.heap_info

JDK 8 的写法(-XX:+PrintGCDetails -Xloggc: 等)、-Xmn 为什么别写、停顿目标设多小合适,都放到第⑥步。

⑤跟着做一遍:交易靶场大促,按五步走一圈

两个类,JDK 8 写法,不需要依赖。OrderService 把下单接口压成一个进程,请求按固定速率到达,延迟从“计划到达的时刻”算起,所以 GC 期间排队的请求都会被算慢。GcLogStats 读 GC 日志,算出 ④-2 的三样数,并给出结论。

1写下目标

停顿

≤ 50 ms

下单接口的 SLA 是 p99.9 < 100 ms,GC 最多占一半

GC 时间占比

≤ 5%

大促时 CPU 要留给业务

内存

堆 ≤ 256 MB

容器 512 MB,另一半给元空间、线程栈、直接内存(全景第 10 站)

2代码和命令

1OrderService.java(节选)完整代码 →
// 最近订单缓存:LRU,满了踢最老的
static final Map<Long, Order> RECENT = new LinkedHashMap<>(…, true) {
  protected boolean removeEldestEntry(Map.Entry<Long, Order> e) {
    return size() > CACHE; }                // -DcacheK=1600
};
static long handle(long id, ThreadLocalRandom r) {
  String body = "{\"userId\":" + … + "}";  // 解析报文:临时对象
  …
  RECENT.put(id, new Order(…));            // 订单:活得久
  byte[] resp = new byte[TEMP_KB * 1024];  // 响应:48 KB 临时对象
  if (LEAK) LEAKED.add(new byte[4096]);    // -Dleak=true:只放不删
  …
}
// main:先把缓存装满,空闲 1 秒,再按 -Drate 匀速发请求
long plan = start + n * interval;          // 计划到达时刻
if (plan > now) LockSupport.parkNanos(plan - now);
handle(n, r);
lat[n] = System.nanoTime() - plan;         // 排队也算进延迟
2编译、运行2 核 ARM · JDK 17.0.20
cd study-labs/java
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 \
  mvn -q -pl jvm/jvm-gc-log-tuning compile
cd jvm/jvm-gc-log-tuning
J=/usr/lib/jvm/java-17-openjdk-arm64/bin/java
M=labs.jvm.jvm_gc_log_tuning

# 基线:G1 默认,大促流量
$J -XX:+UseG1GC -Xms256m -Xmx256m \
   -Xlog:gc*:file=g1.log \
   -Drate=4000 -DcacheK=1600 \
   -cp target/classes $M.OrderService

# 统计:目标 50 ms、5%,从压测开始的那一秒算
$J -cp target/classes $M.GcLogStats g1.log 50 5 1.4

3基线:G1 默认参数

$ java -XX:+UseG1GC -Xms256m -Xmx256m … OrderService
缓存预热 1600000 个订单,用了 311 ms;堆上限 256 MB
压测从 JVM 启动后第 1.4 秒开始(GcLogStats 的第 4 个参数填它,跳过预热)
处理 120000 个请求,用了 30.0 秒(计划 30 秒),实际 4000 个/秒
延迟:p50 0.06 ms,p99 1.54 ms,p99.9 3.35 ms,最长 8.3 ms
$ java … GcLogStats g1.log 50 5 1.4
统计 1.4~31.5 秒,停顿 417 次,合计 589 ms
  Pause Young (Concurrent Start)     54 次
  Pause Young (Normal)               225 次
  Pause Remark                       54 次
  Pause Cleanup                      54 次
  Pause Young (Prepare Mixed)        15 次
  Pause Young (Mixed)                15 次
GC 时间占比 2.0%(目标 ≤ 5%)
停顿:p50 1.6 ms,p99 3.5 ms,最长 7.9 ms(目标 ≤ 50 ms)
分配速率约 196 MB/s
GC 后堆占用:最低 206 MB,前一半平均 210 MB,后一半平均 210 MB,最后 207 MB,堆上限 256 MB
结论:达标:别动参数

三样数都达标,回收后的占用也平稳,按五步法的规则到这里就该停手。日志里还有一个值得记下的信号:30 秒内做了 54 轮并发标记,平均半秒多一轮。原因是活对象占了堆的 80%,余量只有 50 MB,G1 一直在忙着回收老年代,这部分开销占的是 CPU,不算在停顿里。活对象再涨一点,就该加堆或给缓存瘦身了。

4有人“优化”了一下:换成吞吐量收集器 Parallel

$ java -XX:+UseParallelGC -Xms256m -Xmx256m … OrderService
处理 120000 个请求,用了 36.6 秒(计划 30 秒),实际 3279 个/秒
延迟:p50 3300.05 ms,p99 6516.55 ms,p99.9 6619.65 ms,最长 6649.0 ms
$ java … GcLogStats parallel.log 50 5 1.7
统计 1.7~38.4 秒,停顿 172 次,合计 36086 ms
  Pause Full                         172 次
GC 时间占比 98.4%(目标 ≤ 5%)
停顿:p50 204.4 ms,p99 301.0 ms,最长 308.6 ms(目标 ≤ 50 ms)
GC 后堆占用:最低 201 MB,前一半平均 201 MB,后一半平均 201 MB,最后 201 MB,堆上限 245 MB
结论:有 Full GC,但回收后降下来了:堆相对活对象太小,或晋升太快,先看堆是不是活对象的 3 倍以上

判断:回收后稳定在 201 MB,没有上涨,所以不是泄漏,而是参数问题。原因在 ④-3 那张图里:Parallel 的老年代固定是 171 MB,比活对象还小。处理办法只能二选一:换回 G1,或者给 Parallel 加内存。两种都跑了一遍:

5只改一处,和基线比

配置(大促流量,各跑 30 秒)最长停顿停顿 p99GC 占比Full GC请求 p99.9对照目标
G1 默认 · 256 MB(基线)7.9 ms3.5 ms2.0%03.35 ms三项都达标
Parallel · 256 MB308.6 ms301.0 ms98.4%1726620 ms停顿、占比都超
Parallel · 768 MB37.9 ms37.9 ms0.7%019.08 ms停顿、占比达标,内存超预算 3 倍
G1 · 256 MB · MaxGCPauseMillis=55.5 ms4.1 ms2.0%03.77 ms达标,和基线差不多
G1 · 256 MB · -Xmn128m15.6 ms8.0 ms1.4%05.14 ms达标,但最长停顿翻倍

实测 本机 2 核 ARM,JDK 17.0.20.1,每行各跑一次。“停顿”只统计压测期间,预热时的停顿已经跳过。在这个场景里,满足全部三个目标的只有 G1。如果预算允许 768 MB,而且更看重吞吐,Parallel 就是更好的选择。所以哪个更好,取决于第 1 步写下的目标。

6再来一次:这回是代码有泄漏

日常流量(每秒 2000 单,缓存 90 万个订单),打开 -Dleak=true:每个请求往一个只放不删的 static 列表里塞 4 KB。分别用 256 MB 和 512 MB 的堆跑。

0 100 200 300 400 500 0 10 20 30 40 50 60 压测开始后的秒数 GC 后的堆占用(MB) 堆上限 256 MB 堆上限 512 MB ✕ OOM · 第 21.4 秒 ✕ OOM · 第 54.0 秒 第 16 秒起,每秒十几次 Full GC(红点),回收完还是 253M:堆翻倍只是把同一件事推迟了 33 秒
堆 256 MB堆 512 MB这一秒有 Full GC每秒取最后一次 GC 之后的占用;鼠标移到点上看数
堆第一次 Full GCOOMFull GC 次数最长停顿GC 占比GcLogStats 的结论
256 MB第 16.2 秒第 21.4 秒58117 ms22.6%Full GC 之后堆还是满的:回收不掉,是代码问题
512 MB第 48.1 秒第 54.0 秒59140 ms10.0%同上

两条线的形状一模一样,只是 512 MB 那条更晚碰到上限。日志里对应的就是全景第 8 站说的那种行:Pause Full (G1 Compaction Pause) 253M->243M(256M) 82.802ms,整理完一遍几乎没变。这时候该做的是第 9 站的事:堆转储、直方图,找出是谁一直攥着这些对象。

到这里已经能交差了写下三个目标,开着 -Xlog:gc* 拿一份基线日志,用 GcLogStats 或任何日志工具算出占比、最长停顿、回收后占用,就能回答“GC 是不是原因、该改参数还是改代码”。还没处理的细节:只看平均值、预热停顿混进统计、JDK 8 的日志参数在 17 上让 JVM 起不来、给 G1 写死新生代,都在第⑥步。

⑥刻意练习:先认坑,再练读日志

第一圈

注意事项:不知道就会出事故

坑 1泄漏的时候加堆← ④-2 回收后占用⑤ 实测
现象
Full GC 频繁,把堆从 256 MB 加到 512 MB,好了半分钟,又开始 Full GC,最后还是 OOM,而且每次 Full GC 更长(117 → 140 ms)。
根源
回收后的占用 ≈ 活对象。活对象一直在涨,堆多大都会被填满;堆越大,整理一次要搬的越多。
做法
先看 Full GC 那几行箭头右边的数:接近上限(253M->243M(256M))就别动参数,直接 jcmd <pid> GC.class_histogram 或堆转储(第 9 站)。
坑 2把“换收集器”当调优← ④-3 收集器的目标⑤ 实测
现象
听说 Parallel 吞吐高,换上之后 GC 时间占比 98.4%,接口延迟到了秒级。
根源
Parallel 的目标是吞吐,不管停顿;JDK 15 起两代边界固定,老年代 = 堆的 2/3,活对象放不下就只能一直 Full GC。
做法
换收集器之前,先确认它优先照顾的目标和你的一致,再按 ④-2 估一下活对象在它的分法下放不放得下。
坑 3只看平均停顿← ④-2 看尾部⑤ 实测
现象
监控上平均 GC 停顿 2.8 ms,看着很好,请求 p99.9 却是 19 ms(Parallel · 768 MB 那一跑,最长 37.9 ms)。
根源
每秒十几次 1~3 ms 的 Young GC 把平均值压得很低,偶尔一次长停顿被平均掉了;可停顿期间到达的请求都在等那一次。
做法
盯三样:最长停顿(或 p99)、GC 时间占比、Full GC 次数。告警用最大值,不用平均值。
坑 4给 G1 写死新生代← ④-3 G1 靠调 Eden 达标⑤ 实测
现象
加了 -Xmn128m,最长停顿从 7.9 ms 变成 15.6 ms,日志里出现 (G1 Preventive Collection)。
根源
G1 是靠每次重新决定 Eden 的大小来满足停顿目标的。Oracle 文档原话:用 -Xmn、NewRatio 固定新生代大小,“实际上就关掉了停顿时间控制”。
做法
G1 只留 -Xms/-Xmx 和 MaxGCPauseMillis;从别的收集器迁移过来时,先删掉所有 GC 参数。参见 G1 与 ZGC 坑 2。
坑 5把启动预热的停顿算进统计← ④-2 停顿 ≈ 活对象⑤ 实测
现象
-Xmn128m 那一跑,整份日志里最长的停顿是 135 ms,可压测期间最长只有 15.6 ms:那 135 ms 发生在启动时装缓存的阶段。
根源
装缓存时新对象几乎全是活的,Young GC 要复制一大批;正式跑起来以后,新对象大多是请求结束就没用的。
做法
基线和对比都从同一个时刻开始统计(GcLogStats 的第 4 个参数);生产上,启动阶段的停顿另外看,用预热或分批加载来解决。
坑 6JDK 8 的日志参数带到 17,JVM 起不来历史原因本机核对
现象
升级到 17 以后启动失败:Unrecognized VM option 'PrintGCDateStamps',或者 'UseGCLogFileRotation'。
根源
JDK 9 起 GC 日志并入统一日志 -Xlog。-XX:+PrintGCDetails、-Xloggc 只会警告,并自动换成 -Xlog:gc* / -Xlog:gc:file;时间戳和轮转相关的参数直接不认了。
做法
换成 ④ 里的那一行 -Xlog:gc*:file=gc-%p-%t.log:…。默认就会轮转,重启时同名的旧文件会先存成 .0;文件名带上 %p,几个实例就不会互相覆盖。
第二圈

使用规范

目标写进文档,带上数字

“最长停顿 ≤ 50 ms、GC 占比 ≤ 5%、堆 ≤ 256 MB”,和接口的 SLA、容器规格对得上。

防:调到最后,说不清算不算调好了。

GC 日志一直开着,带 pid 和轮转

开销很小;文件名带 %p,按需调大 filecount、filesize。

防:出了事故才发现没有证据(坑 6)。

一次只改一处,用同样的流量对比

改之前的日志留着当基线;没变好就撤回。参数的改动也走代码评审,写清目的。

防:几个参数互相影响,最后谁也说不清。

告警看最大值和占比,不看平均值

Micrometer 的 jvm.gc.pause 取 max;再加一条“回收后的老年代占用持续上涨”。

防:坑 3,以及泄漏发现得太晚。

-Xms 和 -Xmx 设成一样

按文档,G1 和 Parallel 在 GC 时间超过 GCTimeRatio 时会先扩堆;两个值相等,堆就不会忽大忽小。

防:内存曲线锯齿状,容器的内存余量算不准。

堆按活对象估,留足余量

先从回收后的占用估出活对象有多少,堆给到它的好几倍。⑤ 里 256 MB 的堆装 206 MB 活对象能达标,但 G1 一直在做并发标记。

防:活对象一涨就退化成 Full GC。

第三圈

更深一层

停顿和延迟不是一回事

同一个 Parallel 跑法里,GC 最长只停了 309 ms,请求延迟的中位数却是 3.3 秒。原因是 GC 占了 98% 的时间,请求处理不过来,越积越多。如果用“发一个、等它回来、再发下一个”的方式压测,停顿期间本来该到的请求根本没发出来,量出来的延迟会好看得多,这个现象叫 coordinated omission。OrderService 从计划到达的时刻开始算延迟,就是为了避开它。

停顿达标了,CPU 还在被 GC 占用

基线 30 秒里做了 54 轮并发标记。这部分工作和业务线程并行,不算在停顿里,但要占 CPU:本机 2 核,G1 默认用 1 个并发 GC 线程(ConcGCThreads=1,PrintFlagsFinal 核对)。所以除了停顿,还要看 CPU 使用率;用 -Xlog:gc+cpu 能看到每次 GC 的 User / Sys / Real 时间。

Preventive Collection 是什么

-Xmn128m 那一跑里出现的 (G1 Preventive Collection),是 JDK 17 新加的(JDK-8257774):G1 估计剩下的空 region 不够复制活对象用了,就提前做一次 Young GC,免得复制到一半失败。JDK 21 又把它删掉了(JDK-8297639)。所以同一份参数在 17 和 21 上,日志会不一样。

活对象到底多大,以哪次为准

Young GC 后的占用里还包括老年代中已经死了、但还没轮到回收的对象,所以会偏高。比较准的是 Mixed GC 或 Full GC 之后的数,以及并发标记结束时的统计。⑤ 的基线一直在 206~210 MB 之间,和缓存估算的大小(160 万 × 约 130 字节)对得上。

版本差异

JDK变化对你意味着什么
8日志参数:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50m;默认收集器是 Parallel8 上的日志格式和本页不同,箭头前后的数字含义一样
9统一日志 -Xlog(JEP 158),GC 日志并入(JEP 271);G1 成为默认收集器从这一版开始用本页的写法
15去掉 Parallel 的 UseAdaptiveGCBoundary(JDK-8228991)Parallel 的两代边界固定(④-3)
17PrintGCDetails、-Xloggc 只警告,自动换成 -Xlog;PrintGCDateStamps、轮转参数不认,JVM 起不来;新增 Preventive Collection坑 6;升级前先搜一遍启动脚本
21删除 Preventive Collection(JDK-8297639)同样的参数,日志里少了一类停顿

GC 日志体检台先猜,再体检

先选一段日志。

选好日志并猜一下,再点“体检”。体检之后改目标或者改日志,结果会马上重算。
还没体检。

算法和 GcLogStats.java 基本一样,已在 node 里用三段样本核对过:统计时长从第一次停顿开始算到最后一行(日志的时间戳记在停顿结束的时刻);分配量是“这次回收前”减去“上次回收后”的累加;结论按“Full GC 后仍占堆 90% 以上的次数是否过半 → 回收后占用是否一路上涨 → 有没有 Full GC → 是否超出目标”的顺序判断。

刻意练习:由易到难

1. 读一行

从自己项目的 GC 日志里挑一行 Young GC,说出类型、原因、回收前后的占用、停了多久。

做对了:和 ④-1 那张图逐段对得上。

2. 估活对象

找最近几次 Mixed GC 或 Full GC 后的占用,估出活对象有多少,再用 jcmd <pid> GC.heap_info 核对。

做对了:估出来的数和 heap_info 里老年代已用的部分相差不大。

3. 先猜再跑

Parallel 跑大促流量,堆至少要多大才不会一直 Full GC?先按“老年代 = 2/3 堆,要装下 201 MB 还得有余量”算,再改 -Xmx 去验证。

做对了:日志里的 Full GC 从每次都是变成没有,算出来的界线和实测相差不远。

4. 找泄漏

用 -Dleak=true 跑,在 OOM 之前执行 jcmd <pid> GC.class_histogram | head。

做对了:排第一的是 [B(byte[]),个数和“已处理的请求数”差不多。

5. 换一个目标

把停顿目标改成 ≤ 2 ms,先试 G1 的 MaxGCPauseMillis=2,再在 JDK 21 上试分代 ZGC,比较最长停顿和 CPU。

做对了:能说清楚为了这个更严的停顿目标,付出了多少 CPU 或内存(④-3 的三角)。

下一圈

↺回到⑤:改造你的案例

1. 把泄漏修掉,证明是代码问题

按坑 1,把 LEAKED 换成有上限的 LRU,同样用 256 MB 再跑一次 -Dleak=true,用 GcLogStats 检查:回收后的占用应该平稳,结论应该变回“达标”。

2. 把日志参数配成生产级

按坑 6 和第二圈,给启动命令换上 -Xlog:gc*:file=gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=50m,连续启动两次,看旧文件变成了什么名字。

3. 缓存涨到 200 万

按第二圈最后一条,-DcacheK=2000,堆仍是 256 MB,先猜三样数会怎么变,再跑。然后在“加堆”和“缓存瘦身”(上一页的 OrderLite)之间选一个,说出理由。

自测

1. 用一句话说出这一页的精华。

先写下停顿、吞吐、内存三个目标里要哪两个、阈值多少;再从 GC 日志算出占比、最长停顿、回收后占用,判断是参数问题还是代码问题;最后一次只改一处,用同样的流量对比。

2. 常见的“先改参数”的做法,疼在哪?

没有目标,就判断不了调得好不好;没有基线和日志里的数,就分不清问题在参数还是代码。⑤ 里两个反例:换 Parallel 后 GC 占比 98.4%;泄漏时加堆,OOM 只是推迟了 33 秒。

3. 泄漏时加堆没用,根源是哪条原理?

④-2:回收后的占用 ≈ 活对象。泄漏就是活对象一直在涨,堆再大也会被填满;而 Full GC 的停顿和要整理的活对象成正比,堆越大,停得越久(实测 117 → 140 ms)。

4. 预测:大促流量从每秒 4000 单翻到 8000 单,缓存大小不变,G1 的 Young GC 次数和每次停顿会怎么变?

次数大约翻倍:分配速率翻倍,Eden 大小差不多,按 ④-2 的“频率 ≈ 分配速率 ÷ 新生代大小”。每次停顿基本不变:多出来的都是请求结束就死的临时对象,要复制的活对象没变多。GC 时间占比会跟着翻倍,从 2% 到 4% 左右,离 5% 的目标近了。(这是推算,没跑。)

5. 预测:同一个大促场景,把 -Xmx 从 256 MB 改成 2 GB、收集器用 G1,三样数会怎么变?算不算调优成功?

余量从 50 MB 变成 1.8 GB,并发标记和 Mixed GC 会少很多,Young GC 次数会下降,Eden 可以给得更大;停顿依然很短,因为活对象没变。可是内存目标是 ≤ 256 MB,超了 8 倍。按第 1 步写下的目标,这不算成功:三角里拿内存换来了吞吐。

6. 放回全景:第 8 站的前后各发生了什么?读不懂 GC 日志,整条链路会怎样?

前面是第 7 站:老年代占用超过阈值,G1 启动并发标记,再用 Mixed GC 回收垃圾最多的那些 region。后面是第 9 站:回收不掉,就 OOM,然后用堆转储和直方图排查。读不懂日志,就会把第 8 站当成“参数没调好”:加堆或换收集器,结果只是把第 9 站的 OOM 往后推(实测从第 21 秒推到第 54 秒)。在那之前,每秒十几次 Full GC 会把下单接口的 p99 拖到秒级,Tomcat 线程池被占满,上游开始超时重试,问题从一个服务扩散出去。