周五下午四点半,离下班还有一个半小时。钉钉群里,运营小姐姐发来一句温柔的话:“杰哥,后台的‘导出全部订单’是不是坏了?点完页面转圈,转着转着就报错了。”我说:“可能网络抖了,你再试一下。”她说:“好的。”
然后告警群里“叮叮叮”连弹三条:服务进程重启、实例不可用、接口错误率 100%。我意识到,运营小姐姐那句“再试一下”,我大概是亲手把第二个实例也送走了。
这篇记录这次排查的全过程:怎么确认是堆 OOM、怎么在事故现场留住 heap dump、怎么用 MAT 在十分钟内看出“谁吃了内存”,以及最后怎么把“一次性查全量再写 Excel”改成分页流式导出。读完你应该能照着抄作业。
一、现象:进程隔几分钟就“自杀”一次
先看日志。应用日志里最先出现的是下面这段,注意时间点和运营点击导出的时间是对得上的:
2026-09-25 16:31:08.412 ERROR [http-nio-8080-exec-7] c.d.order.web.ExportController - export failed
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.add(ArrayList.java:462)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:397)
at com.demo.order.service.OrderExportService.exportAll(OrderExportService.java:58)java.lang.OutOfMemoryError: Java heap space 的含义很明确:堆里分配对象失败了。Oracle 官方排障文档对它的解释是:对象分配失败,可能是堆不够大,也可能是无意中一直持有着对象。而堆栈里的 ArrayList.grow + MyBatis 的 handleRowValues 给了第一个线索:有人在往一个 ArrayList 里一直塞行数据。
别急着下结论。这里说一个新手最容易犯的错:看到 OOM 就把 -Xmx 从 4g 改成 8g。这叫用钱治病,而且大概率只是把死亡时间从 3 分钟推迟到 6 分钟。先保留现场,再动手。顺手看看 GC 是不是已经在垂死挣扎:
$ jstat -gcutil 8231 2000 5
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 100.00 99.87 95.12 91.30 412 18.231 37 96.482 114.713
0.00 0.00 100.00 99.91 95.12 91.30 412 18.231 38 99.105 117.336
0.00 0.00 100.00 99.93 95.12 91.30 412 18.231 39 101.774 120.005
0.00 0.00 100.00 99.95 95.12 91.30 412 18.231 40 104.386 122.617读法:E(Eden)100%、O(老年代)99.9%,而且 FGC 每 2 秒涨 1 次、FGCT 每次涨约 2.6 秒——JVM 在连续 Full GC,但每次都回收不了多少,老年代一直是满的。这是典型的“对象都还活着”的症状:不是垃圾回收得慢,是根本没有垃圾可收。
二、排查路线图
堆 OOM 的排查,我固定走下面这条路:先保证“下次死的时候自动留 dump”,再抓一份在线 dump,最后用 MAT 看谁占内存。
三、动手:抓 dump、开 MAT、找凶手
第 1 步:给 JVM 装上“黑匣子”
这一步应该在事故之前就做好,飞机都有黑匣子,JVM 也该有。以下两个参数是官方文档里的标准写法:
# 启动参数(写进 Dockerfile / 启动脚本)
java -Xms4g -Xmx4g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps/ \
-jar order-service.jar-XX:+HeapDumpOnOutOfMemoryError 表示抛 OOM 时自动 dump;-XX:HeapDumpPath 指定目录或文件,不指定则落在进程工作目录。两个坑我踩过:
- 磁盘要够:dump 大小约等于存活对象的大小,4g 堆可能写出 3–4g 文件。写满盘比 OOM 更难看;容器里要把该目录挂到有空间的卷上,否则容器一重启,dump 跟着灰飞烟灭。
- dump 期间进程会停顿,写几个 G 需要几十秒,此时健康检查失败会被编排系统杀掉。可以适当放宽存活探针,或者接受“dump 可能写不完”的现实。
我们那天没有配(对,就是没有配),所以只能等下一次。好在运营小姐姐的“再试一下”很稳定。先在活着的实例上手动抓:
$ jcmd 8231 GC.heap_dump /data/dumps/order-20260925.hprof
8231:
Heap dump file created [3187452910 bytes in 41.873 secs]
# 等价的老写法(官方文档同样提供)
$ jmap -dump:format=b,file=/data/dumps/order-20260925.hprof 8231
Dumping heap to /data/dumps/order-20260925.hprof ...
Heap dump file createdOracle 文档里把 jcmd <pid> GC.heap_dump 标为更推荐的方式。注意两个细节:一是执行用户要和目标进程是同一个用户(否则 jcmd 报 “Unable to open socket file”);二是默认只 dump 存活对象,会先触发一次 Full GC;想保留不可达对象可以用 jmap -dump:all,format=b,...,但通常没必要。
拿到文件后,gzip 一下(hprof 压缩率很高),scp 回本地。不要在生产机上装 MAT 图形界面;如果 dump 很大,本地机器内存不够,用 MAT 自带的 ParseHeapDump.sh 在服务器上无头解析,把生成的索引和报告拉回来看。
# 服务器上无头生成 Leak Suspects 报告(MAT 发行包里的脚本,需要给足 JVM 内存)
$ ./ParseHeapDump.sh order-20260925.hprof org.eclipse.mat.api:suspects
# 生成 order-20260925_Leak_Suspects.zip,里面是 HTML 报告,拉回本地用浏览器看第 2 步:Leak Suspects,先看“嫌疑人画像”
MAT(Eclipse Memory Analyzer)打开 hprof 后,首页会问要不要跑 Leak Suspects Report,选是。官方文档说明它会分析支配树,找出占用堆超过一定比例(默认 10%)的对象,并给出“累积点”和到 GC Root 的最短路径。我们的报告开头是这样:
Problem Suspect 1
One instance of "java.util.ArrayList" loaded by "org.springframework.boot.loader.LaunchedURLClassLoader @ 0x6c0021f50"
occupies 2,491,038,216 (81.63%) bytes. The memory is accumulated in one instance of
"java.lang.Object[]" loaded by "<system class loader>".
Keywords
java.lang.Object[]
java.util.ArrayList
com.demo.order.entity.OrderExportVO一个 ArrayList 占了 81.63% 的堆——约 2.4 GB,报告都替你把凶手指出来了。这就是“一个对象吃掉大半个堆”的典型画像,和泄漏(很多小对象缓慢累积)的画像完全不同。
第 3 步:Dominator Tree,验证并量化
Leak Suspects 是摘要,我习惯再打开 Dominator Tree 自己看一眼。先说两个概念,MAT 文档里的定义:Shallow Heap 是对象自己占的内存;Retained Heap 是“这个对象被回收后一共能释放多少”,即它支配的整棵子树的总和。支配树按 Retained Heap 排序,第一行就是“最大的内存债主”。
Class Name | Shallow Heap | Retained Heap | Percentage
-------------------------------------------------------------------------------------------------
java.util.ArrayList @ 0x7a1200000 | 24 | 2,491,038,216 | 81.63%
+- java.lang.Object[3145728] @ 0x7a1200018 | 12,582,928 | 2,491,038,192 | 81.63%
+- com.demo.order.entity.OrderExportVO @ 0x7a2f01a20 | 56 | 792 | 0.00%
+- com.demo.order.entity.OrderExportVO @ 0x7a2f01d40 | 56 | 792 | 0.00%
... (共 3,145,000 余个)
java.lang.Thread @ 0x6c1a8f2b0 http-nio-8080-exec-7 | 120 | 41,240 | 0.00%解读:数组 Object[] 自己的 Shallow Heap 才 12 MB(只放了 300 多万个引用),但它的 Retained Heap 是 2.4 GB,因为每个 OrderExportVO 连带的字符串、BigDecimal、日期对象合计约 792 字节。一行订单在数据库里也就几百字节,但变成 Java 对象后,对象头、指针、String 的 char/byte 数组、装箱类型统统上来,膨胀 3–8 倍是常态。这就是为什么“50 万行数据库记录”能轻松变成 GB 级的堆。
随后右键 ArrayList → Merge Shortest Paths to GC Roots → exclude all phantom/weak/soft references,看是谁在持有它:
java.util.ArrayList @ 0x7a1200000
'- local variable: list in com.demo.order.service.OrderExportService.exportAll(...)
'- java.lang.Thread @ 0x6c1a8f2b0 http-nio-8080-exec-7 (thread stack, Java Local)持有者是一个线程栈上的局部变量:请求线程 exec-7 的 exportAll 方法里的 list。这不是“泄漏”(请求结束后它会被回收),而是“瞬时峰值超过堆容量”——业务上叫大对象一次性加载。区分这两种,决定了解法完全不同:泄漏要找“谁一直没放手”,峰值要找“为什么一次拿这么多”。
四、根因:代码里那句朴素的 selectAll
翻到 OrderExportService.java:58,代码如下(已脱敏改写):
public void exportAll(OrderQuery query, HttpServletResponse resp) throws IOException {
// 一次性把符合条件的订单全部查出来
List<OrderExportVO> list = orderMapper.selectExportList(query); // 第 58 行,本次的“案发现场”
// 再整个丢给 POI 写成 xlsx(XSSFWorkbook 全量在内存里)
XSSFWorkbook wb = new XSSFWorkbook();
Sheet sheet = wb.createSheet("orders");
int r = 0;
for (OrderExportVO vo : list) {
Row row = sheet.createRow(r++);
row.createCell(0).setCellValue(vo.getOrderNo());
// ... 二十几列
}
wb.write(resp.getOutputStream());
}问题不止一处,而且是叠加的:
- 全量查询:
selectExportList没有分页,运营选“全部”时命中 50 多万行。MyBatis 默认把结果集全部读入List,前面说了,对象膨胀数倍。 - XSSFWorkbook:POI 的 XSSF 会把整个工作簿作为 DOM 对象放在内存里,每个单元格是一个
XSSFCell,开销比 VO 还大。也就是说同一份数据在内存里被复制了两遍:一遍是 VO 列表,一遍是 Workbook。 - 没有并发和大小限制:接口没有任何限流,运营点一次转圈了没反应,再点两次——三个线程同时各持 2GB 的数据,别说 4g 堆,40g 也挂。我们的“两个实例陆续死亡”就是这么发生的。
- 没有兜底:没有
HeapDumpOnOutOfMemoryError,出事只能靠猜。
下面这张图是修复前后的内存形态对比。左边是“一次性全量”,堆使用量随行数线性上涨直到撞顶;右边是“分页流式”,每页处理完就成为垃圾,堆使用量基本是一条水平线。
五、止血与修复
止血:先让业务活过来
- 屏蔽入口:网关或 Nginx 上临时把
/api/export/all返回 503(或者在前端把按钮置灰),防止再有人点导致复发; - 重启存活实例:OOM 后的 JVM 处于半残状态(其他线程也可能因分配失败而随机抛错),重启比“看它能不能自己好”靠谱;
- 补上黑匣子参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/,并且把/data/dumps挂载到宿主机卷;再加一个可选的-XX:+ExitOnOutOfMemoryError(JDK 8u92+),让进程在 OOM 后直接退出、交给守护进程拉起,避免僵尸状态; - 告知运营:这个功能暂时限制为按日期范围导出,并提前打个招呼,别再“再试一下”。
根治 1:分页(游标)查询,绝不 selectAll
导出场景不能用 LIMIT offset, size 深分页(offset 越大越慢,之前有一篇慢查询专门讲过),改用基于主键的游标分页:
<!-- Mapper:按主键游标,每次取 pageSize 条 -->
<select id="selectExportPage" resultType="com.demo.order.entity.OrderExportVO">
SELECT id, order_no, buyer_name, amount, status, created_at
FROM t_order
WHERE id > #{lastId}
AND created_at >= #{query.startTime} AND created_at < #{query.endTime}
ORDER BY id
LIMIT #{pageSize}
</select>根治 2:EasyExcel 流式写出,一页写完就丢
EasyExcel 官方文档里“重复多次写入”的模式,就是为这种场景准备的:ExcelWriter 保持打开,每查一页就 write 一次,不需要把全量数据放进内存。(EasyExcel 底层用 SAX 方式读、并在写入时不做整个 DOM,这就是它比 XSSFWorkbook 省内存的原因。)
public void exportAll(OrderQuery query, HttpServletResponse resp) throws IOException {
resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
resp.setCharacterEncoding("utf-8");
resp.setHeader("Content-disposition", "attachment;filename=orders.xlsx");
final int pageSize = 1000;
long lastId = 0L;
int rows = 0;
// try-with-resources:无论成功失败都会 finish,释放临时文件
try (ExcelWriter writer = EasyExcel.write(resp.getOutputStream(), OrderExportVO.class).build()) {
WriteSheet sheet = EasyExcel.writerSheet("orders").build();
while (true) {
List<OrderExportVO> page = orderMapper.selectExportPage(query, lastId, pageSize);
if (page.isEmpty()) break;
writer.write(page, sheet); // 写一页
lastId = page.get(page.size() - 1).getId(); // 游标前进
rows += page.size();
if (rows > MAX_EXPORT_ROWS) { // 兜底:超过上限直接拒绝,别让用户导 500 万行
throw new BizException("导出行数超过 " + MAX_EXPORT_ROWS + ",请缩小时间范围");
}
}
}
}还有几个值得一提的细节:
- xlsx 单 sheet 上限约 104 万行,超过要分 sheet;而且用户根本不会读 50 万行,业务上更好的方案是“异步导出 + 生成文件后通知下载”。
- 多线程改造:我们后来把大导出改成了异步任务:接口只提交任务,扔进有界线程池(核心 2、队列 20、拒绝策略返回“任务繁忙”),线程池里分页查询(可以按 id 区间拆成几段并行查,但写文件必须单线程顺序写,
ExcelWriter不是线程安全的),生成文件传到对象存储后通过站内信通知下载。这样内存峰值 = 线程池大小 × 单页大小,可以精确算出来,而不是取决于用户点了几次。 - JDBC 层面若想真正流式读,可以在 MyBatis 里使用
Cursor<T>或设置fetchSize(MySQL 需要Integer.MIN_VALUE或useCursorFetch=true),但要注意游标期间连接被长时间占用;我们选了对数据库更友好的主键分页。
改造后的验证
改完在预发环境用 60 万行数据压测,再看 jstat,老年代不再顶格:
$ jstat -gcutil 9102 2000 3
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 12.50 63.20 18.41 95.03 91.22 132 2.104 0 0.000 2.104
9.38 0.00 20.77 18.41 95.03 91.22 133 2.121 0 0.000 2.121
0.00 11.02 71.45 18.44 95.03 91.22 134 2.138 0 0.000 2.138Full GC 次数是 0,老年代稳定在 18% 左右——导出 60 万行,堆依旧岁月静好。
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
所有生产 JVM 必配 -XX:+HeapDumpOnOutOfMemoryError 与 -XX:HeapDumpPath,且挂载持久卷 | OOM 现场只有一次,没有 dump 就只能猜;容器重启会带走本地文件 |
| 导出、批量查询一律分页/游标,并限制最大行数 | 数据库里的 1 行到堆里会膨胀数倍,50 万行轻易上 GB |
| 大导出改成异步任务 + 有界线程池 + 拒绝策略 | 内存峰值可计算;用户重复点击不会叠加 |
| 用 EasyExcel 的重复写入,或 POI 的 SXSSFWorkbook(滑动窗口) | 避免整个 Workbook 常驻内存 |
| MAT 排查顺序:Leak Suspects → Dominator Tree → Path to GC Roots | 先看画像,再量化,最后找持有链;区分“瞬时峰值”与“持续泄漏” |
| 监控堆使用率与 Full GC 次数(老年代 > 85% 告警) | OOM 之前一定有一段“老年代下不去”的预兆,告警要早于进程死亡 |
| 接口加限流与幂等(同一用户同一条件短时间内只能提交一次) | “再试一下”是用户的本能,系统要替用户克制 |
一句话备忘:OOM 先分类型 → 事前配
HeapDumpOnOutOfMemoryError→ 事中jcmd GC.heap_dump→ MAT 看 Leak Suspects 与 Dominator Tree → 是“一个大对象”就改分页流式,是“持续累积”就查谁抱着不放。
后来运营小姐姐问我:“杰哥,现在导出怎么变成‘任务已提交,稍后通知’了?”我说:“因为它长大了,学会了不硬撑。”她说:“哦,那你什么时候也学会呢?”——好的,我去把上周的 TODO 清一清。
参考资料
- Oracle JDK 17 Troubleshooting Guide:内存泄漏与 OutOfMemoryError——各类 OOM 详细消息的含义,以及 HeapDumpOnOutOfMemoryError、HeapDumpPath、jmap/jcmd 生成堆转储的写法。
- Eclipse MAT 帮助:Dominator Tree——支配树、Shallow Heap 与 Retained Heap 的定义。
- Eclipse MAT 帮助:Leak Suspects Report——泄漏嫌疑报告包含的内容、10% 默认阈值与累积点概念。
- EasyExcel 官方文档:读 Excel——基于监听器的逐行读取与分批入库、清理集合以利于回收。
- EasyExcel 官方文档:写 Excel——ExcelWriter 重复多次写入,配合分页查询避免全量加载。
一起聊聊