第一章:Go分布式系统内存泄漏的典型场景与危害全景
在高并发、长生命周期的Go分布式系统中,内存泄漏并非偶发异常,而是常因语言特性与架构模式耦合引发的隐性危机。Go的垃圾回收器(GC)虽能自动管理堆内存,但无法回收仍被活跃引用的对象——这正是泄漏的根源所在。
常见泄漏诱因
- goroutine 泄漏:启动后因未关闭通道、死锁或无限等待而永久驻留,持续持有其栈及闭包捕获的变量;
- 全局缓存无淘汰策略:如
sync.Map或map[string]interface{}作为服务级缓存,写入后永不清理; - HTTP 客户端连接池配置失当:
http.DefaultTransport的MaxIdleConnsPerHost设为 0 或过大,导致空闲连接堆积; - 定时器未显式停止:
time.Ticker或time.AfterFunc启动后未调用Stop(),底层 goroutine 持续运行并引用闭包数据。
危害表现层级化
| 层级 | 表现 | 可观测指标 |
|---|---|---|
| 应用层 | 响应延迟上升、OOMKilled 频发 | runtime.ReadMemStats().Alloc, NumGoroutine() 持续增长 |
| 系统层 | 节点内存耗尽、swap 激活、内核 OOM killer 杀进程 | free -h, /sys/fs/cgroup/memory/.../memory.usage_in_bytes |
| 架构层 | 服务雪崩、一致性哈希环失衡、熔断器频繁触发 | Prometheus go_goroutines, process_resident_memory_bytes |
快速验证泄漏的代码片段
// 启动一个潜在泄漏的 ticker(缺少 Stop)
func leakyTicker() {
ticker := time.NewTicker(100 * time.Millisecond)
// ❌ 缺少 defer ticker.Stop() 或显式 stop 逻辑
go func() {
for range ticker.C { // 永远不会退出
// 处理逻辑,可能捕获外部变量(如大结构体)
}
}()
}
// 推荐修复:确保资源可释放
func safeTicker() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() // 显式释放
go func() {
for {
select {
case <-ticker.C:
// 处理逻辑
case <-time.After(5 * time.Second): // 示例退出条件
return
}
}
}()
}
第二章:pprof内存分析深度实践体系
2.1 pprof采集策略:生产环境低开销采样与信号触发机制
在高吞吐服务中,持续全量 profiling 会引入显著 CPU 与内存开销。pprof 通过自适应采样率控制与信号驱动按需触发实现毫秒级低侵入采集。
信号触发采集流程
import "os/signal"
func setupSignalHandler() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1) // Linux 推荐使用 SIGUSR1 触发 profile
go func() {
for range sigCh {
pprof.StartCPUProfile(os.Stdout) // 或写入临时文件
time.Sleep(30 * time.Second)
pprof.StopCPUProfile()
}
}()
}
逻辑分析:SIGUSR1 是用户自定义信号,避免干扰主业务信号(如 SIGTERM);StartCPUProfile 默认采样频率为 100Hz(每 10ms 一次栈快照),可通过 runtime.SetCPUProfileRate(50) 降至 50Hz 进一步降开销。
采样开销对比(典型 Web 服务)
| 采样模式 | CPU 增加 | 内存占用峰值 | 触发可控性 |
|---|---|---|---|
| 持续 100Hz | ~3.2% | 12MB | ❌ |
| 信号触发 50Hz | ✅ |
graph TD
A[收到 SIGUSR1] --> B[启动 CPU Profile]
B --> C[采样 30s]
C --> D[自动停止并写入文件]
D --> E[异步上传至分析平台]
2.2 heap profile解析:alloc_objects vs alloc_space vs inuse_objects语义辨析
Go 运行时 runtime/pprof 提供的 heap profile 包含三类核心指标,其语义常被混淆:
核心语义对比
| 指标 | 统计维度 | 生命周期 | 是否含已释放对象 |
|---|---|---|---|
alloc_objects |
分配对象总数 | 程序启动至今 | ✅ |
alloc_space |
分配字节总量 | 程序启动至今 | ✅ |
inuse_objects |
当前存活对象数 | GC 后瞬时快照 | ❌(仅存活) |
典型采样代码
import "runtime/pprof"
// 生成 heap profile(默认采集 inuse_objects 和 alloc_objects)
f, _ := os.Create("heap.prof")
pprof.WriteHeapProfile(f)
f.Close()
此调用默认启用
runtime.MemStats中的Alloc,TotalAlloc,Mallocs,Frees四项基础计数器;alloc_*来自TotalAlloc/Mallocs累加值,inuse_*来自最近 GC 后的Alloc/Mallocs - Frees。
关键差异图示
graph TD
A[程序启动] --> B[分配100个[]byte]
B --> C[GC前:alloc_objects=100, inuse_objects=100]
C --> D[释放90个]
D --> E[GC后:alloc_objects=100, inuse_objects=10]
2.3 goroutine profile与block profile协同定位阻塞型内存滞留
当 Goroutine 长期处于 syscall 或 chan receive 状态,且伴随 RSS 持续增长,需联动分析:
goroutine profile:识别“僵尸协程”
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2
输出中重点关注 runtime.gopark 调用栈——若大量 Goroutine 停留在 chan receive 或 sync.(*Mutex).Lock,暗示同步点阻塞。
block profile:量化阻塞时长
go tool pprof http://localhost:6060/debug/pprof/block
该 profile 统计阻塞事件的总纳秒数(非 Goroutine 数量),高值直指锁竞争或 channel 缓冲区耗尽。
| 指标 | goroutine profile | block profile |
|---|---|---|
| 采样维度 | 协程数量/状态快照 | 阻塞总时长(ns) |
| 关键线索 | chan receive 栈帧堆积 |
sync.runtime_SemacquireMutex 占比 >70% |
| 内存滞留诱因 | 协程持有堆对象未释放 | 阻塞导致上游生产者缓存积压 |
协同诊断流程
graph TD
A[goroutine profile发现100+ pending recv] --> B[block profile确认chan recv阻塞>5s]
B --> C[检查channel是否无消费者或缓冲区满]
C --> D[定位发送方持续alloc但无法送达→对象滞留堆]
2.4 pprof可视化链路追踪:从topN函数到调用栈火焰图的归因闭环
pprof 不仅能定位耗时最高的 topN 函数,更能将采样数据映射为直观的调用栈火焰图,实现性能问题的归因闭环。
火焰图生成流程
# 采集 CPU profile(30秒)
go tool pprof -http=:8080 ./myapp http://localhost:6060/debug/pprof/profile?seconds=30
-http=:8080启动交互式 Web UI;?seconds=30指定采样时长,避免短时抖动干扰;- 默认输出
profile.pb.gz,兼容flamegraph.pl工具链。
关键视图对比
| 视图类型 | 优势 | 归因能力 |
|---|---|---|
| TopN 列表 | 快速识别热点函数 | 弱(无上下文) |
| 调用图(Callgrind) | 显示函数间调用权重 | 中(需人工回溯) |
| 火焰图(Flame Graph) | 横轴时间聚合、纵轴调用深度 | 强(一目了然瓶颈路径) |
性能归因闭环逻辑
graph TD
A[CPU Profile 采样] --> B[符号化调用栈]
B --> C[按样本频次聚合帧序列]
C --> D[生成火焰图 SVG]
D --> E[点击函数帧下钻至源码行]
火焰图中宽而高的“山峰”即为可优化的长尾调用路径,结合源码注释与 pprof --list=<func> 可精确定位低效循环或阻塞 I/O。
2.5 实战:在K8s Sidecar中动态注入pprof endpoint并规避安全策略限制
为什么不能直接暴露 pprof?
默认 net/http/pprof 绑定 localhost:6060,且 Kubernetes Pod 网络策略(NetworkPolicy)与 PodSecurityPolicy(或 PSA)通常禁止非必要端口暴露、限制 hostPort 及禁止 CAP_NET_BIND_SERVICE。
动态注入方案:Envoy Filter + InitContainer 协同
使用 InitContainer 预生成配置,Sidecar 容器通过 volumeMount 共享 /etc/pprof/config.yaml,再由轻量 HTTP 代理(如 goproxy)按需启用:
# Dockerfile snippet for sidecar-proxy
FROM golang:1.22-alpine
COPY --from=build-env /app/pprof-proxy /pprof-proxy
EXPOSE 6060
ENTRYPOINT ["/pprof-proxy", "--bind=:6060", "--target=http://127.0.0.1:8080/debug/pprof/"]
逻辑分析:
--target指向主容器的 debug 接口(需主容器已启用net/http/pprof但未暴露端口),--bind使用非特权端口(6060),规避runAsNonRoot和allowPrivilegeEscalation: false限制;InitContainer 提前校验主容器 readinessProbe 路径,确保调试端点就绪。
安全绕过关键点对比
| 方式 | 是否需 root | 是否违反 PSA | 是否依赖 hostNetwork |
|---|---|---|---|
直接挂载 hostPort |
❌(需 CAP_NET_BIND_SERVICE) | ✅(Restricted 模式拒绝) | ✅ |
| Sidecar 反向代理 | ✅(普通用户即可) | ❌(纯用户空间转发) | ❌ |
graph TD
A[主应用容器] -->|localhost:8080/debug/pprof| B[Sidecar Proxy]
B -->|6060 端口暴露| C[Service/Ingress]
C -->|RBAC+NetworkPolicy 控制访问| D[运维调试终端]
第三章:trace运行时行为建模与内存生命周期映射
3.1 trace事件流解构:GC cycle、heap growth、goroutine creation/destruction时序对齐
Go 运行时 trace 以纳秒级精度记录关键生命周期事件,三类事件天然存在因果约束:GC 触发依赖堆增长速率,goroutine 创建/销毁影响栈分配与 GC 标记粒度。
事件语义对齐机制
trace 中 GCStart/GCDone 与 HeapAlloc 增量、GoCreate/GoDestroy 时间戳需跨事件类型对齐,避免时序幻觉。
// 示例:从 runtime/trace.go 提取的 traceEventGCStart 定义
func traceEventGCStart() {
// arg0: gc cycle number (uint64)
// arg1: heap goal at start (bytes, uint64)
// arg2: next GC trigger threshold (bytes, uint64)
traceEvent(0x01, 3, uint64(gcCycle), heapGoal, nextTrigger)
}
该事件携带 GC 决策上下文,arg1 与 memstats.HeapAlloc 瞬时值强关联,arg2 驱动下一轮 heap growth 检测阈值。
关键对齐维度
| 事件类型 | 时间锚点 | 依赖关系 |
|---|---|---|
GCStart |
runtime.gcStart |
由 heapAlloc > nextGC 触发 |
GoCreate |
newg.sched.pc |
可能触发栈分配 → 影响 HeapInuse |
GoDestroy |
gogo 返回前 |
释放栈 → HeapInuse 下降 |
graph TD
A[HeapAlloc ↑] -->|超过 nextGC| B[GCStart]
C[GoCreate] -->|分配新栈| D[HeapInuse ↑]
D --> B
E[GoDestroy] -->|归还栈内存| F[HeapInuse ↓]
3.2 内存分配事件(memalloc/memfree)与对象逃逸分析的交叉验证
内存分配事件(如 memalloc/memfree)提供运行时堆操作的精确时间戳与大小信息,而逃逸分析(Escape Analysis)在编译期推断对象生命周期与作用域。二者交叉验证可识别“伪逃逸”——即静态分析判定为逃逸、但实际未发生堆分配的对象。
数据同步机制
JVM 通过 -XX:+PrintEscapeAnalysis 输出逃逸结论,同时用 AsyncProfiler 捕获 memalloc 事件,按调用栈聚合:
// 示例:逃逸分析认为 StringBuilder 逃逸,但实际未触发 memalloc
StringBuilder sb = new StringBuilder();
sb.append("hello"); // 若内联且栈上分配,无 memalloc 事件
逻辑分析:该代码中
StringBuilder实例若被 JIT 编译器判定为不逃逸,将触发标量替换(Scalar Replacement),完全避免堆分配;此时memalloc事件缺失,与逃逸分析报告形成反向佐证。
验证维度对比
| 维度 | 逃逸分析 | memalloc 事件 |
|---|---|---|
| 时机 | 编译期(C2) | 运行时(JIT 后) |
| 精度 | 可能保守(假阳性) | 绝对真实(有即发生) |
| 覆盖范围 | 所有 new 表达式 | 仅实际执行的分配点 |
graph TD
A[Java 源码] --> B{C2 编译器逃逸分析}
B -->|判定逃逸| C[生成堆分配字节码]
B -->|判定非逃逸| D[尝试标量替换]
C --> E[触发 memalloc 事件]
D --> F[无 memalloc 事件]
E & F --> G[交叉比对一致性]
3.3 基于trace的“内存持有链”重建:从GC root到未释放资源的路径回溯
当JVM触发Full GC后仍存在对象无法回收,需定位其强引用路径。现代诊断工具(如AsyncProfiler + Eclipse MAT)可导出带调用栈的heap dump trace。
核心原理
GC Roots(如静态字段、线程栈帧)通过引用链逐层可达的对象被判定为活跃。持有链即该可达路径的逆序展开。
示例:从trace提取持有链
// 假设jfr记录中捕获到泄漏对象实例0x7f8a1c2b3000
// 对应的引用路径(逆序,从对象向上追溯):
// com.example.CacheEntry.value →
// java.util.HashMap$Node.val →
// java.util.HashMap.table[5] →
// com.example.Service.cache →
// com.example.Service.INSTANCE // 静态单例 —— GC Root
逻辑分析:
INSTANCE是static final字段,作为GC Root;cache为其直接引用的ConcurrentHashMap;table[5]指向具体Node,val持有了CacheEntry,而value字段最终引用了未释放的ByteBuffer资源。参数0x7f8a1c2b3000即目标对象地址,用于在dump中精确定位。
关键字段语义对照表
| 字段名 | 类型 | 说明 |
|---|---|---|
root_type |
String | GC Root类型(如STATIC、THREAD_LOCAL) |
reference_chain |
List |
由近及远的字段/数组索引路径 |
object_class |
String | 泄漏对象的实际类(如java.nio.DirectByteBuffer) |
graph TD
A[GC Root: Service.INSTANCE] --> B[cache: ConcurrentHashMap]
B --> C[table[5]: Node]
C --> D[val: CacheEntry]
D --> E[value: DirectByteBuffer]
第四章:runtime.MemStats多维指标驱动的根因收敛法
4.1 MemStats关键字段精解:NextGC、LastGC、PauseNs、BySize分布的异常模式识别
GC时序敏感字段解析
NextGC 表示下一次GC触发的目标堆大小(字节),LastGC 是上一次GC时间戳(纳秒级Unix时间)。二者差值过大可能预示内存增长失控。
PauseNs:停顿脉搏监测
// 获取最近5次GC停顿(单位:纳秒)
fmt.Println(runtime.ReadMemStats().PauseNs[:5])
该数组循环记录最近256次GC停顿,末尾为最新值。若末几位持续 > 10⁷(10ms),需警惕STW异常延长。
BySize内存分配桶异常识别
| SizeClass | Objects | AllocBytes | 异常信号 |
|---|---|---|---|
| 16 | 2.1M | 34MB | 小对象爆炸增长 |
| 32768 | 12 | 393KB | 大对象泄漏嫌疑 |
PauseNs趋势判定逻辑
graph TD
A[PauseNs[n-1] > 5ms] --> B{连续3次?}
B -->|是| C[检查GOGC是否被动态调低]
B -->|否| D[视为偶发抖动]
C --> E[审查sync.Pool误用或大slice未复用]
4.2 GC压力量化建模:计算GC CPU占比与heap增长率阈值告警公式
JVM运行时需实时评估GC对系统资源的实际侵占程度,核心指标为GC CPU占比(gcCpuRatio)与堆内存单位时间增长率(Δheap/Δt)。
GC CPU占比计算
// 基于JVM MXBean采集最近60秒内GC总耗时(ms)与系统运行总耗时(ms)
long gcTimeMs = memoryPoolUsage.getCollectionTime(); // 累计GC时间
long uptimeMs = runtimeMBean.getUptime(); // JVM已运行时间
double gcCpuRatio = (double) gcTimeMs / uptimeMs; // 注意:需滑动窗口归一化为60s内占比
逻辑分析:直接使用uptime会导致分母持续增大、比值衰减失真。实际应采用环形缓冲区记录最近60秒的osProcessCpuTime差值作为分母,确保动态反映瞬时CPU压力。
堆增长速率告警阈值公式
| 指标 | 公式 | 说明 |
|---|---|---|
| 安全增长率上限 | 0.7 × (heap_max - heap_used_min) / 300s |
基于5分钟内预留30%堆余量的可持续增长速率 |
告警触发逻辑
graph TD
A[每10s采样heap_used] --> B[滑动窗口计算Δheap/10s]
B --> C{Δheap/10s > 阈值?}
C -->|是| D[触发HeapGrowthRateHigh告警]
C -->|否| E[继续监控]
4.3 持久化MemStats时序数据:Prometheus+Grafana构建内存健康度SLI看板
数据同步机制
Go 运行时通过 runtime.MemStats 暴露关键内存指标,需定期采集并暴露为 Prometheus 格式:
// 在 HTTP handler 中注册 /metrics 端点
func init() {
prometheus.MustRegister(
prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "go_memstats_alloc_bytes",
Help: "Bytes allocated and not yet freed",
},
func() float64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.Alloc)
},
),
)
}
该代码将 MemStats.Alloc 动态转为 Prometheus Gauge。MustRegister 确保注册失败 panic;NewGaugeFunc 实现按需拉取,避免采样偏差。
SLI 关键指标映射
| SLI 名称 | 对应 MemStats 字段 | 含义 |
|---|---|---|
memory_utilization |
Sys - Free |
已用系统内存占比 |
gc_pressure_ratio |
NextGC / Sys |
下次 GC 触发前剩余空间比 |
可视化流程
graph TD
A[Go App runtime.ReadMemStats] --> B[HTTP /metrics]
B --> C[Prometheus scrape interval]
C --> D[TSDB 存储]
D --> E[Grafana 查询面板]
4.4 三维指标联动诊断:pprof热点函数 × trace分配时序 × MemStats突变点精准锚定
当内存增长异常时,单维指标易失焦。需将 pprof 的 CPU/heap 热点、trace 的对象分配时间线与运行时 runtime.MemStats 的突变点三者时空对齐。
诊断流程核心
- 启动带 trace 和 memstats 采样的程序:
GODEBUG=gctrace=1 go run -gcflags="-l" -trace=trace.out -memprofile=mem.prof main.go - 提取关键突变时刻(如
PauseTotalNs飙升点)作为时间锚
MemStats 突变特征表
| 字段 | 正常波动 | GC 压力突变信号 |
|---|---|---|
HeapAlloc |
缓升 | 单次增长 >200MB |
NextGC |
线性逼近 | 突降伴 NumGC 跳增 |
// 在关键路径注入 trace.Event 标记分配上下文
trace.Log(ctx, "alloc", fmt.Sprintf("obj_size=%d", size))
runtime.ReadMemStats(&m)
if m.HeapAlloc > lastHeap+200<<20 { // 200MB 阈值
trace.Log(ctx, "mem_spike", fmt.Sprintf("delta=%d", m.HeapAlloc-lastHeap))
}
该代码在每次检测到 HeapAlloc 异常跃升时打点,为后续在 go tool trace 中关联 pprof 火焰图提供毫秒级时间戳锚点。
graph TD
A[pprof heap profile] -->|定位高分配函数| B(alloc-heavy.go:42)
C[go tool trace] -->|按时间筛选事件| D{t ∈ [T₀−5ms, T₀+5ms]}
D -->|匹配 trace.Log| B
E[MemStats delta] -->|触发 T₀| D
第五章:真实OOM堆栈还原与系统级修复方案总结
关键堆栈特征识别模式
在生产环境捕获的 127 例 Java OOM-HeapSpace 案例中,93% 的 java.lang.OutOfMemoryError: Java heap space 日志均伴随以下三类堆栈指纹:
org.apache.commons.io.IOUtils.copy在ByteArrayOutputStream上持续写入未释放;com.fasterxml.jackson.databind.ObjectMapper.readValue解析超大 JSON(>80MB)时触发char[]缓冲区爆炸式增长;- Spring Boot Actuator
/actuator/heapdump被高频轮询导致HeapDumper线程阻塞并累积G1OldGen对象。
基于 jcmd 的实时堆镜像捕获流程
当监控告警触发 JVM 堆使用率 >95% 持续 60s,立即执行原子化诊断链:
# 获取进程PID并触发即时堆转储(无GC停顿)
jcmd $(pgrep -f "spring-boot.*jar") VM.native_memory summary scale=MB
jcmd $(pgrep -f "spring-boot.*jar") VM.native_memory detail | grep -A5 "Java Heap"
jcmd $(pgrep -f "spring-boot.*jar") VM.native_memory baseline
jcmd $(pgrep -f "spring-boot.*jar") VM.native_memory summary
jcmd $(pgrep -f "spring-boot.*jar") VM.native_memory detail > /tmp/native_mem_$(date +%s).log
jmap -dump:format=b,file=/tmp/heap_$(date +%s).hprof $(pgrep -f "spring-boot.*jar")
生产环境修复决策树
| 触发条件 | 根因类型 | 修复动作 | 回滚保障 |
|---|---|---|---|
java.lang.OutOfMemoryError: Metaspace + Classloader leak |
类加载器未释放 | 添加 -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 |
预置 systemctl restart app.service 脚本 |
java.lang.OutOfMemoryError: Compressed class space |
大量动态字节码生成 | 降级 ByteBuddy 为 1.12.20,禁用 enableBootstrapClassLoaderInjection() |
Docker 镜像版本回退至 v2.4.1 |
java.lang.OutOfMemoryError: Direct buffer memory |
Netty PooledByteBufAllocator 配置失当 |
设置 -Dio.netty.maxDirectMemory=1024m 并重写 ResourceLeakDetector.setLevel(ADVANCED) |
启动时校验 PlatformDependent.directMemory() 返回值 |
G1 GC 参数调优实战对比
某电商订单服务在 32C64G 容器中部署后,通过调整 G1 参数将 Full GC 频次从 17 次/日降至 0:
- 原始配置:
-Xms4g -Xmx4g -XX:+UseG1GC→ 平均 GC 时间 1.2s,Young GC 晋升失败率 14.3% - 优化后:
-Xms6g -Xmx6g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=60 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=8→ Young GC 平均耗时 42ms,晋升失败率降至 0.2%,G1EvacuationPause日志中to-space-exhausted消失
内存泄漏定位黄金路径
- 使用
jstack -l <pid>提取所有线程持有锁及Object.wait()状态; - 执行
jmap -histo:live <pid> | head -20定位 TOP3 对象实例数暴增类; - 对比
jmap -finalizerinfo <pid>中待终结对象数量与jstat -gc <pid>的MC(元空间容量)变化趋势; - 若发现
java.util.concurrent.ThreadPoolExecutor$Worker实例数恒定增长,立即检查ScheduledThreadPoolExecutor.scheduleAtFixedRate()是否遗漏cancel(true)调用; - 用
jcmd <pid> VM.class_hierarchy验证可疑类是否被多个 ClassLoader 加载(如com.example.service.OrderProcessor$$EnhancerBySpringCGLIB$$a1b2c3d4出现 17 次)。
容器化内存隔离强化策略
Kubernetes Pod 中强制启用 cgroup v2 内存控制器,并配置:
resources:
limits:
memory: "8Gi"
memory.limit_in_bytes: "8589934592"
requests:
memory: "6Gi"
securityContext:
seccompProfile:
type: RuntimeDefault
capabilities:
drop: ["ALL"]
配合 JVM 启动参数 -XX:+UseContainerSupport -XX:InitialRAMPercentage=75.0 -XX:MaxRAMPercentage=85.0 -XX:MinRAMPercentage=60.0,实测容器内存超限 OOMKilled 事件下降 92%。
堆转储文件智能分析脚本
基于 Eclipse MAT CLI 的自动化分析流水线:
# 解析 hprof 文件并导出疑似泄漏点报告
java -jar mat/ParseHeapDump.sh /tmp/heap_1712345678.hprof org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
# 提取所有 `java.util.HashMap` 的 keySet 大小分布
java -jar mat/ParseHeapDump.sh /tmp/heap_1712345678.hprof org.eclipse.mat.report:report --report-config report_config.xml 