G1 与 GC 日志入门课程(以本次离线菜单 Full GC 事故为教材)

G1 与 GC 日志入门课程(以本次离线菜单 Full GC 事故为教材)

配套文件:<fullgc-repro.md>(本地复现报告)、GcRepro.javagc-materialize.loggc-stream.logZstdParseBench.java

JDK 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 次

改动就两个文件:

  1. RedisZstdUtils.java --- 新增 getAndParse() 方法(拿压缩字节 → 流式解压 → 边解边解析)
  2. 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。数组必须保证这个算术成立 → 一整块连续内存。而对象图走的是"每个对象里存下一个对象的地址",地址指向哪里都行。

这些小对象怎么住进堆、怎么搬走:

  1. 解析时在 Eden 区的 TLAB(线程私有分配缓冲)里指针碰撞分配------JVM 里最快的分配方式,几乎零成本;
  2. 菜单是长命对象(Caffeine 缓存 ~30 分钟),熬过几轮 young GC 后晋升老年代------一个 region 一个 region 地填,哪有空填哪;
  3. 内存紧张时 G1 做 Mixed GC / 压实,可以把每个小对象单独搬走、顺手改一下引用地址------搬家是一本书一本书地搬;
  4. 而那个 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 无兼容性问题。

为什么几乎不慢:三层原因

  1. 解压总量不变。zstd 解压速度 ~1.5GB/s,73MB 无论整块还是分块都是 ~50ms 的活。流式只是把"一次干完"改成"分 128KB 块干完",每块一次 JNI 调用的开销摊在 6 万条数据上是噪声。
  2. protobuf 流式解析不吃亏 。解析的大头是构造 Java 对象(6 万条 entry 的对象图,~180ms),这在两条路径完全相同;读字节的差异(随机访问 vs 顺序缓冲读)实测互相抵消。
  3. 省掉的成本比新增的开销大
    • 旧路径要 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   # 生产版:落文件+轮转

关键区别gcgc* 差一个星号,但诊断 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 级别)
相关推荐
cfm_291417 分钟前
ReentrantLock 中 lock 与 tryLock 核心区别
java
.Hypocritical.23 分钟前
Tomcat本地部署+远程服务器部署超详细教程
java·服务器·tomcat
2601_9620629430 分钟前
Spring Boot入门——Spring Boot项目的创建
java·数据库·spring boot
一嘴一个橘子1 小时前
springmvc 全局异常处理【补充】
java
Wang's Blog1 小时前
Vibe Coding一人即团队系列35: 基于Claude Code的Spring Boot项目初始化实践
java·spring boot·后端
Dovis(誓平步青云)2 小时前
拍视频前先把镜头想清楚:做一个分镜取景辅助器
android·java·服务器·javascript·人工智能
AI人工智能+电脑小能手2 小时前
大白话说Java设计模式-45-解释器模式(源码剖析篇)
java·设计模式·解释器模式·源码分析·pattern·spel·javacc
吴声子夜歌2 小时前
Java——开发中通用的方法和准则(二)
java·开发语言·php
袁震2 小时前
HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
java·华为·性能优化·harmonyos