G1 与 GC 日志入门课程(以本次离线菜单 Full GC 事故为教材)
配套文件:<fullgc-repro.md>(本地复现报告)、
GcRepro.java、gc-materialize.log、gc-stream.log、ZstdParseBench.javaJDK 17.0.15 + G1,与生产一致
一、小朋友版:我们发现了什么问题,改了什么
问题:一个"大箱子"引发的 3 秒卡顿
想象你家开餐厅,菜单非常厚(73MB )。为了省仓库钱,菜单是压缩袋装着的(zstd 压缩后只有 3.5MB),放在外面的储物柜(Redis)里。
每次要用菜单时,旧代码是这样干的:
把压缩袋拿回来 → "刺啦"一下全部倒出来装进一个大箱子 (
new byte[73MB])→ 再从大箱子里慢慢读
问题出在那个大箱子。JVM 的内存仓库被划成了很多小格子 (每个 16MB),大箱子要占 5 个连着的格子。关键是"连着"------仓库里明明还有 100 个空格子,但如果不连续,就放不下这个箱子。
这时候仓库管理员(G1)只有一个办法:大扫除(Full GC) ------把所有东西都搬起来重新摆放,硬腾出 5 个连着的格子。大扫除期间整个仓库停止营业 ,所有客人的请求全部排队等着。在生产环境,这一停就是 3 秒------这就是那个神秘的 3 秒长尾。
更讨厌的是:大扫除完管理员常常发现"白扫了"(内存其实够,只是不连续),日志里就是 109M->109M------垃圾一点没少,纯粹是为了挪位置。
改动:扔掉大箱子,改用"吸管"
新代码:把压缩袋拿回来 → 开个小口,用吸管一点一点吸 (ZstdInputStream 流式)→ 每吸一口(小块)马上处理掉、马上扔 → 处理完,堆里从头到尾都没出现过那个 73MB 的大箱子。
小格子随便哪里有空的都行,不需要连续------大扫除的理由彻底消失了。
本地实验证明:同样的压力,旧方式 Full GC 2 次 ,新方式 0 次。
改动就两个文件:
RedisZstdUtils.java--- 新增getAndParse()方法(拿压缩字节 → 流式解压 → 边解边解析)OfflineMenuCacheService.java--- 调用从get()+parseFrom(byte[])改成一行getAndParse(key, parseFrom)
二、学生版:G1 入门知识
1. 堆的划分:G1 的"棋盘思维"
老式收集器(CMS)把堆切成三大块固定区域 :年轻代、老年代,各自连续。G1 不同------它把堆切成 2048 个左右等大的格子(Region),每个 1~32MB,角色是临时贴的标签:
[ E ][ E ][ S ][ O ][ O ][ H ][ H ][ H ][ 空 ][ E ][ O ][ 空 ]...
E=Eden S=Survivor O=Old H=Humongous
好处:回收时不用整块年轻代/老年代一起动,可以挑垃圾最多的几行格子回收(这就是名字 Garbage-First 的含义:先捡最值钱的垃圾)。
2. Humongous:G1 的特殊通道(本案主角)
- 对象大小 > region/2 → 直接判定为"巨型对象"(humongous)
- 不进年轻代,直接在老年代找连续 N 个 region,头放第一个 region
- 生产环境:region=16m → 阈值 8MB → 菜单 73MB = 5 个连续 region 的需求
- 它还有个专属福利 eager reclaim:短命的巨型对象,哪怕在老年代,下一次 young GC 也能被提前收走
humongous 的死穴就一个字:连续。这为碎片问题埋下伏笔。
3. 解析完的对象住在哪?------巨石 vs 鹅卵石
修复生效的物理本质:连续性要求是"单个对象"的,不是"对象图"的。
旧路径的 byte[73MB] |
解析后的对象图 | |
|---|---|---|
| 数量 | 1 个对象 | 几十万个小对象(每个 entry ~1-2KB + 若干 String) |
| 物理布局 | 必须占 5 个连续 region | 散落在几十上百个 region 里,完全随机 |
| "连接"方式 | 物理相邻(内存地址连续) | 引用(指针)------对象里存个地址指过去 |
byte[73MB](一个对象): 解析后的对象图(6万个对象):
[H][H][H][H][H] [e1] [e2] [e3]
必须连着,缺一格都不行 / \ /
e1.next ──→ e2 ──→ e3
引用随便指,region 不相邻也无所谓
为什么数组非要连续? 因为 arr[i] 的地址是用公式算的:首地址 + i × 4。数组必须保证这个算术成立 → 一整块连续内存。而对象图走的是"每个对象里存下一个对象的地址",地址指向哪里都行。
这些小对象怎么住进堆、怎么搬走:
- 解析时在 Eden 区的 TLAB(线程私有分配缓冲)里指针碰撞分配------JVM 里最快的分配方式,几乎零成本;
- 菜单是长命对象(Caffeine 缓存 ~30 分钟),熬过几轮 young GC 后晋升老年代------一个 region 一个 region 地填,哪有空填哪;
- 内存紧张时 G1 做 Mixed GC / 压实,可以把每个小对象单独搬走、顺手改一下引用地址------搬家是一本书一本书地搬;
- 而那个 73MB 大箱子是一块巨石:要么整块 5 连格放下,要么 Full GC 全堆搬运,没有中间态。
两个延伸认知:
- 解析后反而更占内存,但更健康:对象头(16B)+ String 开销 + 引用,6 万条解析后约 150~250MB,比 73MB 字节大 2~3 倍------没关系,它们全是小对象,G1 的整个设计就是为这种形态优化的。G1 唯一处理不好的就是"巨石"。
- 风险清单从此变短 :改完后堆里最大的单体只剩几百 KB 级(entries 列表的 backing array,~250KB,远低于 8MB humongous 阈值)------全堆再无任何对象需要连续 region。
一句话:G1 讨厌的不是"大",是"一整块"。流式解压把"一块 73MB 的巨石"变成了"6 万颗可以随便摆、随便搬的鹅卵石"。
4. G1 的正常运转(四级流水)
| 级别 | 干什么 | 停顿 |
|---|---|---|
| Young GC | 复制存活对象:Eden → Survivor / 晋升老年代 | 毫秒级,正常 |
| 并发标记 | 后台线程边跑业务边标记活对象 | 几乎不停顿 |
| Mixed GC | young GC + 顺便挑几行老年代垃圾最多的格子一起收 | 十几毫秒级 |
| Full GC | 全堆大整理(compaction),把对象全部搬到一起挤碎碎片 | 秒级,G1 的投降 |
G1 设计目标就是永远不走到第四级。走到了,说明前三级的招都用完了。
5. 本案的升级链(背下来,这是核心)
73MB blob 申请 5 个连续 region 失败
→ 触发 Young GC 试试(cause = "G1 Humongous Allocation")
→ eager reclaim 收走了几个旧 blob,凑出来了!成功(大多数时候)
→ 但某次:常驻缓存 + 在途请求对象 + 新旧 blob 并存 → 空格全被占,凑不出连续的
→ 反复 Young GC 重试(日志里 6ms 一次的"风暴")
→ G1 认输:Pause Full (G1 Compaction Pause) ------ 全堆搬运,STW 3 秒
关键理解:Full GC 时 109M->109M 回收量为 0,不是没垃圾,是那次瞬间所有对象都活着。它整理的不是垃圾,是碎片------缺的从来不是内存总量,是"连续地址空间"。
三、GC 日志阅读课(拿我们自己的日志当教材)
1. 先学会读一行
[2026-08-28T13:55:43.009+0800] [1.869s] [gc] GC(553) Pause Full (G1 Compaction Pause) 109M->109M(128M) 1.948ms
└──────── 绝对时间 ────────┘ └─启动后─┘ └标签┘ └编号┘ └──────事件────── └────原因────┘ └前─└后 └堆总量┘ └耗时┘
五个要素:时间、事件类型、原因(括号里)、内存变化 前->后(总量)、耗时。
2. 记住三种"红灯"信号
红灯 1:Pause Full 出现 = G1 投降了
GC(553) Pause Full (G1 Compaction Pause) 109M->109M(128M) 1.948ms
健康系统里这个词应该几乎不出现。出现一次就该查。
红灯 2:原因里写着 G1 Humongous Allocation
GC(829) Pause Young (Concurrent Start) (G1 Humongous Allocation) 112M->108M(128M) 0.496ms
GC(831) ... (G1 Humongous Allocation) ...
GC(833) ... (G1 Humongous Allocation) ... ← 每 6ms 一次,连成一串
翻译:有个大对象反复申请内存反复失败,GC 在不停地"救场"。单个不可怕,密集成串 = 有大对象在折磨 G1,Full GC 前兆。
红灯 3:前->后 几乎不动(112M->108M)
回收完只掉 4MB?说明活对象把堆钉死了,GC 白干------要么内存真不够,要么就是碎片问题(本案)。
3. 必看的专属行:Humongous regions
GC(68) Humongous regions: 111->107
当前有 111 个格子被巨型对象占着,这次 GC 后剩 107。两个用法:
- 看峰值:125/128 = 堆的 98% 被巨型对象占着 → 就是这种病
- 看趋势 :
111->107说明 eager reclaim 在干活(收走了 4 个,救场成功)
4. 诊断口诀(生产 pod 上两条命令)
bash
grep "Pause Full" /app/logs/gc.log | tail # 红灯1:有没有投降记录
grep -c "G1 Humongous Allocation" /app/logs/gc.log # 红灯2:大对象风暴次数
看到"2 串密集 humongous + 1 次 Pause Full + 时间点对得上菜单刷新" → 结论就可以下了。
5. 一页总结表
| 日志片段 | 含义 | 严重度 |
|---|---|---|
Pause Young (Normal) |
日常年轻代回收 | 正常 |
Pause Young (Concurrent Start) |
young GC + 顺手启动后台并发标记 | 正常 |
Humongous regions: X->Y |
巨型对象格子数变化,Y<X 说明救场成功 | 观察 |
(G1 Humongous Allocation) 成串 |
大对象反复申请失败 | 黄灯 |
112M->108M 纹丝不动 |
活对象钉死堆,回收无效 | 黄灯 |
Pause Full (G1 Compaction Pause) |
全堆整理,全站停顿 | 红灯 |
课后作业 :打开 gc-materialize.log 自己找一遍这三盏灯;再打开 gc-stream.log 对比------前两盏黄灯还在(请求对象造成的),但红灯一盏都没有。这就是修复的全部意义。
四、Q&A:流式解压会影响性能吗?
答:不会到可感知的程度。实测同规模消息(73MB)新旧路径总耗时只差 2%(约 4ms),换来的是消灭 3 秒级 Full GC 风险。
实测数据(ZstdParseBench.java,真实 OfflineMenuMapProto,73.2MB / 61950 条,20 轮平均,3g 大堆无 GC 干扰)
| 路径 | 步骤 | 耗时 |
|---|---|---|
| A | 仅整块解压 Zstd.decompress(旧路径前半) |
49.5ms |
| B | 仅解析 parseFrom(byte[])(旧路径后半) |
185.4ms |
| C | 仅解析流 parseFrom(InputStream) |
176.1ms(与 B 打平,甚至略快) |
| D | 解压+解析流 parseFrom(ZstdInputStream)(新路径完整) |
239.0ms |
旧路径 A+B = 234.9ms vs 新路径 D = 239.0ms → 差 +4.0ms(+2%)
正确性验证(重要):73.2MB > 64MB 的消息,parseFrom(InputStream) 与 parseFrom(ZstdInputStream) 解析结果均与原消息 equals==true------protobuf 的 64MB size limit 只作用于带长度前缀的子消息,不限制顶层流式解析总大小,生产 73MB blob 无兼容性问题。
为什么几乎不慢:三层原因
- 解压总量不变。zstd 解压速度 ~1.5GB/s,73MB 无论整块还是分块都是 ~50ms 的活。流式只是把"一次干完"改成"分 128KB 块干完",每块一次 JNI 调用的开销摊在 6 万条数据上是噪声。
- protobuf 流式解析不吃亏 。解析的大头是构造 Java 对象(6 万条 entry 的对象图,~180ms),这在两条路径完全相同;读字节的差异(随机访问 vs 顺序缓冲读)实测互相抵消。
- 省掉的成本比新增的开销大 :
- 旧路径要
new byte[73MB]:分配 + 零填充内存 + 之后被 GC 处理; - 旧路径峰值内存 = 73MB 数组 与 已解析对象同时在场(双倍);新路径堆里只有解析后的对象;
- 端到端看(GcRepro 25 次迭代含 GC 压力):materialize 2698ms vs stream 2577ms------有 GC 压力时流式反而更快。
- 旧路径要
什么场景才会真的变慢?
只有"同一份数据以极高频率反复解压、CPU 是瓶颈"才会感知到这 2%(例如每秒几百次全量解压同一个 key)。本场景是 Caffeine 缓存刷新(约 30 分钟一次)+ 冷启动加载,单次 ~240ms 的操作省 4ms 还是多 4ms 毫无意义;而它消灭的是"赌输一次 = 3 秒停顿砸中在途请求"的尾部风险。
结论一句话
用 4ms 的确定性开销(2%),换掉 3000ms 的随机性停顿(以及 73MB 的峰值内存),这笔账闭着眼睛都该做。
五、GC 日志是怎么生成的?(动手篇)
答案很简单:不用写一行代码,JVM 参数就能让 JVM 自己写日志 。本课程的 gc-materialize.log / gc-stream.log 全部是这样生成的。
1. 万能公式:-Xlog(JDK 9+ 统一日志)
JDK 9 起,所有 JVM 子系统(GC、JIT、类加载......)的日志统一走一个开关,语法是四段冒号:
-Xlog:<选什么>[:<输出到哪>][:<加什么前缀>][:<输出选项>]
tags file=xx.log time,uptime,tags filecount=5,filesize=20m
| 段 | 例子 | 作用 |
|---|---|---|
| tags | gc / gc* / gc,heap |
订阅哪些子系统的日志;* 是通配符,把 gc 下面所有子标签(heap、phases、start......)全收 |
| output | file=gc.log |
不写就打到 stdout;写了就落文件 |
| decorators | time,uptime,tags |
每行前面加什么:绝对时间、启动后秒数、标签名 |
| options | filecount=5,filesize=20m |
日志轮转:最多 5 个文件、每个 20MB,防止写满磁盘 |
2. 三个常用档位(背下来就够用)
-Xlog:gc # 基础版:只有每次 GC 的一行摘要
-Xlog:gc* # 详细版:+ 堆细节(Humongous regions 行!)、各阶段耗时
-Xlog:gc*:file=gc.log:time,uptime,tags:filecount=5,filesize=20m # 生产版:落文件+轮转
关键区别 :gc 和 gc* 差一个星号,但诊断 humongous 问题必须要 gc* ------因为 Humongous regions: 111->107 这种行属于 gc,heap 子标签,基础版 gc 根本不输出。我们本地复现用的就是 gc*,所以才能看到那行关键证据。
3. 本地复现实际敲的命令(可以照抄改)
powershell
# Windows PowerShell 完整原样
& "D:\java\java17\bin\java.exe" `
"-Xms128m" "-Xmx128m" "-XX:G1HeapRegionSize=1m" -XX:+UseG1GC `
"-Xlog:gc*:file=gc-materialize.log:time,uptime,tags" `
-cp ".;zstd-jni-1.5.5-11.jar" GcRepro materialize 25 12 44 4
拆解:-Xms128m -Xmx128m 固定小堆(模拟生产固定堆);-XX:+UseG1GC(JDK 17 默认就是 G1,写出来是为了醒目);-XX:G1HeapRegionSize=1m 把格子调小到 1MB(生产是 16m);-Xlog:gc*... 就是生成本文日志的那行------程序跑完,gc-materialize.log 就躺在当前目录了。没有任何代码参与,纯 JVM 参数。
Linux/macOS 上等价写法(生产同款):
bash
java -Xms128m -Xmx128m -XX:+UseG1GC -XX:G1HeapRegionSize=1m \
-Xlog:gc*:file=gc-materialize.log:time,uptime,tags \
-cp .:zstd-jni-1.5.5-11.jar GcRepro materialize 25 12 44 4
4. 认识老写法(JDK 8,看旧文档/旧服务时会遇到)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/logs/gc.log
JDK 8 及更早用的是这一堆独立开关,JDK 9 起全部废弃、统一成 -Xlog。看到 -Xloggc: 就知道是老配置。两代输出的内容格式也不同 (老版本没有 Pause Full (G1 Compaction Pause) 这种统一格式),但读法相通。
5. 生产环境:日志在哪、怎么开、怎么看
- 在哪 :本项目生产 JVM 参数里已经开了
-Xlog:gc:file=/app/logs/gc.log:time,uptime,tags,所以 pod 里/app/logs/gc.log天然存在------这正是最初定位本案的证据来源。 - 建议升级 (随本次修复一起):改成
-Xlog:gc*:file=/app/logs/gc.log:time,uptime,tags:filecount=5,filesize=50m------多出的gc*让Humongous regions行可见,filecount/filesize防止无限增长写满盘。改的位置在部署的 JVM 参数(Dockerfile 的JAVA_OPTS或启动脚本)。 - k8s 里怎么看:
bash
kubectl exec <pod> -- grep "Pause Full" /app/logs/gc.log | tail # 有没有红灯
kubectl exec <pod> -- grep -c "G1 Humongous Allocation" /app/logs/gc.log
kubectl exec <pod> -- tail -f /app/logs/gc.log # 实时盯着看
6. 动手练习(今天的作业)
随便一个 Java 程序 + 小堆就能玩。用本目录的 GcRepro.java:
bash
# 练习 1:基础版日志,感受 gc 与 gc* 的差别
java -Xms64m -Xmx64m -Xlog:gc -cp . GcRepro materialize 10 8 30 2
java -Xms64m -Xmx64m -Xlog:gc* -cp . GcRepro materialize 10 8 30 2
# 练习 2:落文件 + 轮转
java -Xms64m -Xmx64m -Xlog:gc*:file=mygc.log:time,uptime,tags:filecount=3,filesize=1m \
-cp . GcRepro materialize 10 8 30 2
# 练习 3:两个参数随便改改,观察 Full GC 何时出现(比如把 30 改成 36、40)
做练习 1 时你会直观看到:第一条命令的输出里永远找不到 Humongous regions 行,第二条满屏都是------这就是为什么生产必须用 gc*。
7. 常用标签速查(-Xlog:help 可看全表)
| 写法 | 内容 |
|---|---|
-Xlog:gc |
GC 摘要行(每次一条) |
-Xlog:gc* |
全量:+ 堆 region 变化、各阶段耗时、并发标记细节 |
-Xlog:gc,heap |
只要堆布局变化(看 Humongous regions) |
-Xlog:safepoint |
STW 停顿点(查"神秘卡顿"神器) |
-Xlog:gc+heap=debug |
更细的堆信息(debug 级别) |