-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% 本机核对
所以看 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 行的统计程序:日志的格式简单,每次停顿都有记录,开着的开销很小,出了事故之后也还在。不管用哪个工具,都按下面这五步走。
④背后的机制:日志里的数为什么能说明问题,收集器为什么各有各的目标
1一行日志怎么读
开 -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。
196 MB/s ÷ Eden 20 MB ≈ 每秒 10 次,日志里 30 秒正好 300 多次 Young GC。新生代越小,GC 越频繁;新生代越大,对象在里面待得越久、越容易死掉,晋升到老年代的就越少。
据此可以分出两类问题。回收后的占用低、但 GC 很频繁或停顿长,是参数问题,可以靠堆大小、新生代大小或收集器解决。回收后的占用接近上限、而且一直在涨,是代码问题:东西还被引用着,怎么调参数都回收不掉。
3为什么这样设计:三个目标不能同时最好,收集器只能各选一个优先
每个收集器出厂时已经选好了优先照顾哪个目标,默认参数就是这个选择:
- 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 输掉,不是因为它“慢”,而是它的固定比例和这个场景的活对象对不上。⑤ 里把堆加到 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代码和命令
// 最近订单缓存: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; // 排队也算进延迟
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 秒) | 最长停顿 | 停顿 p99 | GC 占比 | Full GC | 请求 p99.9 | 对照目标 |
|---|---|---|---|---|---|---|
| G1 默认 · 256 MB(基线) | 7.9 ms | 3.5 ms | 2.0% | 0 | 3.35 ms | 三项都达标 |
| Parallel · 256 MB | 308.6 ms | 301.0 ms | 98.4% | 172 | 6620 ms | 停顿、占比都超 |
| Parallel · 768 MB | 37.9 ms | 37.9 ms | 0.7% | 0 | 19.08 ms | 停顿、占比达标,内存超预算 3 倍 |
G1 · 256 MB · MaxGCPauseMillis=5 | 5.5 ms | 4.1 ms | 2.0% | 0 | 3.77 ms | 达标,和基线差不多 |
G1 · 256 MB · -Xmn128m | 15.6 ms | 8.0 ms | 1.4% | 0 | 5.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 的堆跑。
| 堆 | 第一次 Full GC | OOM | Full GC 次数 | 最长停顿 | GC 占比 | GcLogStats 的结论 |
|---|---|---|---|---|---|---|
| 256 MB | 第 16.2 秒 | 第 21.4 秒 | 58 | 117 ms | 22.6% | Full GC 之后堆还是满的:回收不掉,是代码问题 |
| 512 MB | 第 48.1 秒 | 第 54.0 秒 | 59 | 140 ms | 10.0% | 同上 |
两条线的形状一模一样,只是 512 MB 那条更晚碰到上限。日志里对应的就是全景第 8 站说的那种行:Pause Full (G1 Compaction Pause) 253M->243M(256M) 82.802ms,整理完一遍几乎没变。这时候该做的是第 9 站的事:堆转储、直方图,找出是谁一直攥着这些对象。
-Xlog:gc* 拿一份基线日志,用 GcLogStats 或任何日志工具算出占比、最长停顿、回收后占用,就能回答“GC 是不是原因、该改参数还是改代码”。还没处理的细节:只看平均值、预热停顿混进统计、JDK 8 的日志参数在 17 上让 JVM 起不来、给 G1 写死新生代,都在第⑥步。⑥刻意练习:先认坑,再练读日志
注意事项:不知道就会出事故
- 现象
- 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 站)。
- 现象
- 听说 Parallel 吞吐高,换上之后 GC 时间占比 98.4%,接口延迟到了秒级。
- 根源
- Parallel 的目标是吞吐,不管停顿;JDK 15 起两代边界固定,老年代 = 堆的 2/3,活对象放不下就只能一直 Full GC。
- 做法
- 换收集器之前,先确认它优先照顾的目标和你的一致,再按 ④-2 估一下活对象在它的分法下放不放得下。
- 现象
- 监控上平均 GC 停顿 2.8 ms,看着很好,请求 p99.9 却是 19 ms(Parallel · 768 MB 那一跑,最长 37.9 ms)。
- 根源
- 每秒十几次 1~3 ms 的 Young GC 把平均值压得很低,偶尔一次长停顿被平均掉了;可停顿期间到达的请求都在等那一次。
- 做法
- 盯三样:最长停顿(或 p99)、GC 时间占比、Full GC 次数。告警用最大值,不用平均值。
- 现象
- 加了
-Xmn128m,最长停顿从 7.9 ms 变成 15.6 ms,日志里出现(G1 Preventive Collection)。 - 根源
- G1 是靠每次重新决定 Eden 的大小来满足停顿目标的。Oracle 文档原话:用
-Xmn、NewRatio固定新生代大小,“实际上就关掉了停顿时间控制”。 - 做法
- G1 只留
-Xms/-Xmx和MaxGCPauseMillis;从别的收集器迁移过来时,先删掉所有 GC 参数。参见 G1 与 ZGC 坑 2。
- 现象
-Xmn128m那一跑,整份日志里最长的停顿是 135 ms,可压测期间最长只有 15.6 ms:那 135 ms 发生在启动时装缓存的阶段。- 根源
- 装缓存时新对象几乎全是活的,Young GC 要复制一大批;正式跑起来以后,新对象大多是请求结束就没用的。
- 做法
- 基线和对比都从同一个时刻开始统计(
GcLogStats的第 4 个参数);生产上,启动阶段的停顿另外看,用预热或分批加载来解决。
- 现象
- 升级到 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、容器规格对得上。
防:调到最后,说不清算不算调好了。
开销很小;文件名带 %p,按需调大 filecount、filesize。
防:出了事故才发现没有证据(坑 6)。
改之前的日志留着当基线;没变好就撤回。参数的改动也走代码评审,写清目的。
防:几个参数互相影响,最后谁也说不清。
Micrometer 的 jvm.gc.pause 取 max;再加一条“回收后的老年代占用持续上涨”。
防:坑 3,以及泄漏发现得太晚。
按文档,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;默认收集器是 Parallel | 8 上的日志格式和本页不同,箭头前后的数字含义一样 |
| 9 | 统一日志 -Xlog(JEP 158),GC 日志并入(JEP 271);G1 成为默认收集器 | 从这一版开始用本页的写法 |
| 15 | 去掉 Parallel 的 UseAdaptiveGCBoundary(JDK-8228991) | Parallel 的两代边界固定(④-3) |
| 17 | PrintGCDetails、-Xloggc 只警告,自动换成 -Xlog;PrintGCDateStamps、轮转参数不认,JVM 起不来;新增 Preventive Collection | 坑 6;升级前先搜一遍启动脚本 |
| 21 | 删除 Preventive Collection(JDK-8297639) | 同样的参数,日志里少了一类停顿 |
GC 日志体检台先猜,再体检
先选一段日志。
算法和 GcLogStats.java 基本一样,已在 node 里用三段样本核对过:统计时长从第一次停顿开始算到最后一行(日志的时间戳记在停顿结束的时刻);分配量是“这次回收前”减去“上次回收后”的累加;结论按“Full GC 后仍占堆 90% 以上的次数是否过半 → 回收后占用是否一路上涨 → 有没有 Full GC → 是否超出目标”的顺序判断。
刻意练习:由易到难
从自己项目的 GC 日志里挑一行 Young GC,说出类型、原因、回收前后的占用、停了多久。
做对了:和 ④-1 那张图逐段对得上。
找最近几次 Mixed GC 或 Full GC 后的占用,估出活对象有多少,再用 jcmd <pid> GC.heap_info 核对。
做对了:估出来的数和 heap_info 里老年代已用的部分相差不大。
Parallel 跑大促流量,堆至少要多大才不会一直 Full GC?先按“老年代 = 2/3 堆,要装下 201 MB 还得有余量”算,再改 -Xmx 去验证。
做对了:日志里的 Full GC 从每次都是变成没有,算出来的界线和实测相差不远。
用 -Dleak=true 跑,在 OOM 之前执行 jcmd <pid> GC.class_histogram | head。
做对了:排第一的是 [B(byte[]),个数和“已处理的请求数”差不多。
把停顿目标改成 ≤ 2 ms,先试 G1 的 MaxGCPauseMillis=2,再在 JDK 21 上试分代 ZGC,比较最长停顿和 CPU。
做对了:能说清楚为了这个更严的停顿目标,付出了多少 CPU 或内存(④-3 的三角)。
下一圈
- JFR 录一段:用
jdk.ObjectAllocationSample找到每秒分配 196 MB 的是哪几行代码,减少分配,而不是去调 GC - G1 的停顿预测模型:
G1Policy怎么根据前几次的复制速度决定下一轮 Eden 用几块 - 安全点日志
-Xlog:safepoint:停顿里有多少是在等所有线程到齐 - 容器里的 JVM(地图上 J03 的第 8 个知识点):
MaxRAMPercentage和堆外内存怎么一起算 - JVM 故障排查(第 9 站):堆转储、MAT 里的 GC Root 路径,把 ⑤ 第 6 步的泄漏查到具体的那一行
↺回到⑤:改造你的案例
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 线程池被占满,上游开始超时重试,问题从一个服务扩散出去。