第三章:GC 日志深度分析(上)——多版本、多收集器的统一解析

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 可消费的信息"问题。

相关推荐
xingxiliang1 小时前
ReliableAgent:类似工程级可用的agent示例
java·开发语言·agent
ArixBit1 小时前
用 Go 手撸 Agent 框架 #9:把最小 Agent 抽成 Runtime
go·agent
Luhui Dev1 小时前
用 AI 生成一道几何题配图:三角形四心作图案例
人工智能·数学·agent·luhuidev
格安1 小时前
大一暑假,我从零搭建了一个Multi-Agent辩论系统——项目全流程复盘
agent
亦暖筑序1 小时前
AgentScope-Java 入门:保存评审状态并生成 Markdown 报告
java·人工智能·后端
当你一无所知1 小时前
Agent琐碎研究
agent
架构师Leon1 小时前
极简智能体(一):Hello LLM
agent
invicinble1 小时前
关于微服务基本框架搭建--fegin最小使用demo
java·spring boot·微服务
梦想的旅途21 小时前
企业微信API 实现企业微信消息的自动发送?
java·服务器·前端