3.1 痛点:GC 日志是"半结构化"数据
GC 日志是最常用的 Java 诊断信息来源------每一行都是一个 GC 事件的记录:什么时候发生的、用了多长时间、堆从多少到多少。理论上这些信息足够分析绝大多数 GC 相关问题。但实际上,GC 日志的格式分裂已经到了"看一眼文件要猜它是哪个 JDK 哪个收集器"的程度。
两个维度的分裂:
JDK 版本分裂 。JDK 8 用 -XX:+PrintGCDetails 输出固定宽度表格,每一行的含义取决于收集器类型。JDK 9+ 用 -Xlog:gc* 统一日志框架,带时间戳前缀和 [gc,start]、[gc,phases] 等标签。同一个应用升了 JDK 版本之后,日志格式完全变了,旧的解析脚本全部作废。
收集器多样性。GC 事件名称因收集器而异:
| 收集器 | 典型事件 | 关注点 |
|---|---|---|
| G1 | Young / Mixed / Full / Concurrent Cycle | 暂停时间分布,Mixed GC 效率 |
| ZGC | Cycle Mark Start/End, Relocate Start/End | 并发吞吐量,暂停极短(<1ms) |
| Parallel | Young / Full | Full GC 串行压缩,暂停集中在 Full |
| Shenandoah | Init Mark / Final Mark / Init Update Ref / Final Update | 并发占比,退化模式 |
| CMS (JDK8) | Initial Mark / Concurrent Mark / Remark / Sweep | 并发阶段耗时,晋升失败 |
结果就是:一个能处理"生产环境所有 GC 日志"的解析器,需要覆盖 2 种 JDK 输出格式 × 6 种收集器 × 每种收集器 3-8 种事件类型。这是设计解析引擎时必须面对的复杂度。
3.2 自动格式检测
User 不需要告诉 Agent"这是 JDK 17 的 G1 GC 日志"。Agent 自己就能判断。
实现思路是分析日志的前几行:
sql
取前 10 行有效内容:
如果行不包含 [ 开头但有 [GC 或 [Full GC 特征 → JDK 8
如果行以 [GC 或 [Full GC 开头(且不是 Worker/Thread 子线程)→ JDK 8
否则 → JDK 9+ Unified Logging
之所以只需要前 10 行,是因为两种格式的"签名"差别很大。JDK 8 的 GC 事件行不会用方括号做模块前缀,而 JDK 9+ 的每一行都以 [时间][级别][标签] 开头。这种结构上的差异让格式检测极其可靠。
3.3 统一事件模型
解决了格式识别之后,下一个问题是:6 种收集器的输出各有各的关键字和字段排列,但分析需要的核心信息是一样的。统一事件模型就是从差异中找到"最小公约数":
markdown
每个 GC 事件统一为:
- 时间:JVM 启动后秒数(统一单位,不因为 JDK 版本不同而不同)
- 类别:Young / Full / Mixed / Concurrent / Remark / Cleanup / ZGC / Other
- 原因:Allocation Failure / System.gc() / G1 Evacuation Pause ...
- 堆变化:before → after(MB),total(MB)
- 耗时:毫秒
- 是否并发:用于区分 STW 暂停和后台并发阶段
有两个关键设计决策:
为什么用 is_concurrent 标志位而不是在类别里区分? 因为 ZGC 和 Shenandoah 的并发阶段中混有短暂的暂停阶段(如 ZGC 的 Pause Mark Start)。如果只看类别名来判断"是不是暂停",可能会把并发周期中的所有子阶段都当成 STW。用独立标志位更可靠,每个收集器解析器在自己的逻辑里设置这个标志。
为什么堆大小统一用 MB? 原始日志中的单位混乱不堪------B、K、M、G 混用,G1 用 3072K,Parallel 用 3.0M。统一换算到 MB 消除了 Agent 处理时做单位转换的必要。
3.4 解析器架构
解析器按 JDK 版本分两大路径,每条路径内按收集器拆分为独立模块:
arduino
输入文本
│
▼
格式检测
├─ JDK 8 → jdk8/ 解析器
│ ├─ g1.py(G1 收集器)
│ └─ generational.py(Parallel / Serial / CMS)
│
└─ JDK 9+ → jdk9/ 解析器
├─ g1.py
├─ parallel.py
├─ zgc.py
├─ shenandoah.py
└─ epsilon.py
│
▼
GCEvent[] ← 统一输出格式
│
▼
compute_stats.py(统计 + 采样 + 诊断)
为什么按 JDK 版本分路径? 因为 JDK 8 和 JDK 9+ 的日志结构从根本上不同。JDK 8 是"打印风格的固定宽度表格",每条事件的信息分布在多行上,需要联合解析。JDK 9+ 是"标签事件流",每条事件的信息集中在一行(或几行带相同 GC ID 的行)。把两种格式放在同一个解析器里会导致代码中到处都是 if-else。
为什么按收集器拆分模块? 每个收集器的事件名称、阶段结构、关键字段都不同。G1 有 Mixed GC 和 Concurrent Cycle,ZGC 有 Cycle 级别的阶段划分------这些细节放在一起只会让单个文件膨胀。拆分后,新增一个收集器的支持只需要新增一个模块,不需要改现有逻辑。
3.5 解析思路
JDK 8(PrintGCDetails) 的特点是信息密度高、分布散。一条事件的信息跨越多个缩进行:
less
2019-12-17T11:06:43.123+0800: 72.234: [GC pause (G1 Evacuation Pause) (young), 0.0086039 secs]
[Eden: 512.0M(512.0M)->0.0B(512.0M) Survivors: 8192.0K->8192.0K Heap: 3.1G(8.0G)->2.9G(8.0G)]
[Times: user=0.05 sys=0.00, real=0.01 secs]
解析策略:从事件头行([GC pause ...)提取 uptime 和持续时长,从堆变化行(Heap: X->Y(Z))提取 before/after/total。子阶段行(如 [Parallel Time: 8.5 ms])只记录,不生成独立事件。Generational 收集器(Parallel/Serial/CMS)的格式差异在于堆变化行按代分开报告(PSYoungGen + ParOldGen),需要合并。
JDK 9+(Unified Logging) 的特点是带标签、事件 ID 可追踪。每行以 [时间][级别][标签] 开头:
scss
[30.152s][info][gc,start] GC(43) Pause Young (G1 Evacuation Pause)
[30.160s][info][gc,phases] GC(43) Pre Evacuate Collection Set: 0.1ms
[30.161s][info][gc,heap] GC(43) Eden regions: 128->0
解析策略:[gc,start] 标签触发一个新事件,[gc,phases] 和 [gc,heap] 标签补充同一事件 ID 的细节。事件通过 GC(N) 编号关联。ZGC 和 Shenandoah 的解析器需要额外处理"并发阶段"的识别------它们的事件在 [gc,phases] 中通过不同的阶段名称区分是否暂停。
跨收集器的一致性处理:一个收集器的"Pause Young"在另一个收集器中可能叫"Evacuation Pause"。解析器需要把这些名称归一化到统一的类别上。归一化规则在各自的收集器模块中定义------G1 解析器知道它的哪些事件是 Young、哪些是 Mixed、哪些是 Full。
3.6 统计引擎的设计
事件解析完成之后,接下来的问题是:Agent 需要什么样的统计信息才能做诊断?
最直接的思路是把所有事件都喂给 LLM。但实际不可行------一份覆盖数小时的 GC 日志可能有数十万个事件,LLM 会被淹没。
统计引擎的核心职责是降维:把百万行事件压缩为 LLM 可以理解和消费的统计摘要。
分类汇总:按事件类别(Young / Full / Mixed / Concurrent)统计每种类型的事件数、暂停总时间、平均/最大/p95/p99 暂停时间、平均回收量。这回答了"GC 的总体情况是怎样的"。
原因分布:特别是 Full GC 的原因------是 Allocation Failure(分配压力大)还是 System.gc()(代码显式触发)?这个分布直接决定下一步建议方向。
吞吐率 :应用真正在"干活"的时间比例。1 - (暂停总时间 / 日志覆盖时长)。低于 99% 就值得关注。
分配率估算:两次 GC 之间堆的增长量除以时间间隔的平均值。虽然不是精确的分配率(因为只有 GC 到达时的快照),但可以给出一个数量级判断。
时间序列采样 :为了前端绘图和 LLM 的"趋势感",需要从事件流中采样。采样策略是均匀采样 + 强制包含异常值:
从数十万事件中抽取 ~200 个点
→ 保证时间轴上有足够的密度
→ 然后强制包含 10 个最长的暂停事件
→ 去重(同一个事件可能同时被采样和强制包含)
为什么这样做?均匀采样下,一个长达 2 秒的 Full GC 很容易被几百个几毫秒的 Young GC 稀释掉------在折线图上几乎看不见。但恰恰是这些"罕见的慢事件"最有诊断价值。强制包含最长暂停的意图就是"不要漏掉异常"。
3.7 事件级钻取
统计引擎回答"总体是什么情况",但 LLM 在推理过程中一定会追问"具体是哪几次 Full GC?在什么时间?什么原因触发的?"
事件级钻取工具提供了按条件筛选事件的能力:
ini
Agent 可以指定:
- 只看某类事件(category="Full")
- 按原因匹配(cause="allocation failure")
- 按时长过滤(duration_min=500,只看 >500ms 的)
- 按时段过滤(uptime 范围)
- 分页(limit/offset)
返回结果限制在 2500 字符以内,防止 Agent 被大量数据淹没。如果需要更多,Agent 可以调整条件或翻页继续。
这个钻取能力让 Agent 可以从"整体异常"精准定位到"具体事件"。看到 Full GC 频率过高后,它不会满足于一个数字,而是会追查"到底是哪些 Full GC 在反复触发?它们的堆占用趋势是什么?"
3.8 小结
GC 日志解析是整个诊断系统的数据基础。设计上解决的核心问题只有一个:如何在 2 种 JDK 格式 × 6 种收集器的差异中找到统一的抽象,让下游的统计和诊断逻辑不需要关心日志来源。
自动格式检测解决了"用户不需要告诉 Agent 日志类型"的问题。统一事件模型解决了"不同收集器的输出差异"问题。按 JDK 版本和收集器拆分的解析器架构解决了"新增收集器的维护成本"问题。统计引擎解决了"数十万事件如何降维为 LLM 可消费的信息"问题。