MAT(Memory Analyzer Tool)深度分析实录

将几个 G 大小的 .hprof 快照文件下载并导入 Eclipse MAT 工具进行排查。

[GC Roots (静态变量 / 活动线程)]
│ (Strong Reference 强引用链路未切断)
▼
[com.example.billing.service.ReportExportService]
│
▼
[java.util.concurrent.ConcurrentHashMap]
│ (持有 2,500,000+ 个 Entry)
▼
[com.example.billing.dto.FinancialRecordDTO] <-- 吞噬 3.2GB 内存

  1. 查看 Leak Suspects 报告
    MAT 打开后首先分析内存泄漏疑似点。报告首行给出明确警告:

“The class java.util.concurrent.ConcurrentHashMap, loaded by , occupies 3,421,123,072 bytes (78.34%) of memory. The memory is accumulated in one instance of com.example.billing.service.ReportExportService.”

  1. 借助 Dominator Tree(支配树)定位大对象

展开 Dominator Tree,按 Retained Heap(保留内存大小)降序排列。

发现单一集合实例占用近 3.4GB 内存。集合内部塞满了 250 万个 FinancialRecordDTO 对象,每个对象包含上百个明细字段。

  1. 检查 Path to GC Roots(排查无法释放的原因)

右键该对象 -> Path To GC Roots -> 选择 exclude all phantom/weak/soft references(排除虚/弱/软引用)。

追踪出的引用链清晰显示:该集合被一个声明为 @Service 的 Spring 单例 Bean 内部的 private final Map<String, List> cacheMap 强引用所持有。

四、代码根因分析与代码重构

【问题代码还原】
研发团队为了加速“月末多维度财务报表导出”的性能,编写了类似如下逻辑:

Java

@Service
public class ReportExportService {
// 危险:常驻单例内存的静态/属性级缓存,且无淘汰机制
private final Map<String, List> exportTaskCache = new ConcurrentHashMap<>();

public void processExport(String taskId, List<Long> recordIds) {
    // 每次导出拉取全量明细并无脑堆积在内存 Map 中
    List<FinancialRecordDTO> records = financialRecordMapper.batchSelect(recordIds);
    exportTaskCache.put(taskId, records);
    
    // 异步线程处理导出文件...
    // 关键缺陷:任务异常终止或导出成功后,未在 finally 块中调用 exportTaskCache.remove(taskId)!
}

}
【重构与优化方案】

杜绝使用无界 JVM 本地集合做缓存:若必须使用单机内存缓存,使用带 最大容量限制 和 TTL 自动驱逐策略 的 Caffeine 替代原生 Map。

大数据量流式导出:避免将百万级大集合一次性 Load 进内存,改用 MyBatis 的 ResultHandler 配合 EasyExcel 进行流式写出。

Java

// 正确做法:使用带上限与淘汰策略的 Caffeine Cache 兜底
private final Cache<String, List> safeTaskCache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();

// 大数据量流式导出(降低 JVM 峰值压力)
public void streamExport(HttpServletResponse response) throws IOException {
response.setContentType(“application/vnd.openxmlformats-officedocument.spreadsheetml.sheet”);

// 使用流式写出,内存中常驻仅数十条记录
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), FinancialRecordDTO.class).build();
WriteSheet writeSheet = EasyExcel.writerSheet("财务明细").build();

// 流式遍历游标查询,逐批次写入磁盘/网络 IO,避免一次性消耗堆内存
financialRecordMapper.selectWithStream(resultContext -> {
    FinancialRecordDTO record = resultContext.getResultObject();
    excelWriter.write(Collections.singletonList(record), writeSheet);
});

excelWriter.finish();

}
五、生产环境高频 OOM 避坑清单

ThreadLocal 忘记 remove():在线程池环境下,核心线程复用导致线程私有变量生命周期无限延长,其持有的上下文对象永远无法被 GC。

滥用堆外内存(DirectBuffer / Netty):未限制堆外内存大小(-XX:MaxDirectMemorySize),Native 内存耗尽触发操作系统直接杀死进程,且堆快照中看不到异常。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐