DevFix
Java已验证

Java 内存溢出排查:OutOfMemoryError: Java heap space 的定位与解决

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:OutOfMemoryError: Java heap space 不是单纯的「内存不够」,是 GC 反复回收后仍腾不出空间。八成是对象被意外强引用,堆里塞满了本该死掉的东西。先加 -XX:+HeapDumpOnOutOfMemoryError 留堆快照,再用 MAT 的 Dominator Tree 顺着 GC Roots 找到最大内存占用者。-Xmx 调大只是缓兵之计,泄漏根源在引用关系没断。

背景

JVM 把对象放在堆里,垃圾回收器(Garbage Collector,简称 GC)负责清理没人引用的对象。正常情况下,「活对象」数量稳定,GC 一跑就有空间让出来。

当某个对象本该死掉、却一直被强引用拽着,GC 就收不走它。日积月累,堆被填满,想分配新对象时 JVM 只能抛 OutOfMemoryError

GC 能正常运行,也收不回足够空间,这是堆溢出的典型特征。它和「堆外内存不足」「元空间不足」是两回事,别一看到 OOM 就下结论。

现象

报错原文通常长这样,关键在后面那串 detail message:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.example.service.ReportService.export(ReportService.java:42)
    at com.example.controller.ReportController.download(ReportController.java:28)

Java heap space 这几个字说明:对象在 Java 堆里分配不出来。

同一个 OutOfMemoryError 还有别的变体,含义完全不同:

java.lang.OutOfMemoryError: GC Overhead limit exceeded

这一句的意思是 GC 几乎全程在跑,程序却没怎么前进,回收释放的内存极少。它不是堆里塞满大对象,而是「回收成本已经高到不划算」。

java.lang.OutOfMemoryError: Metaspace

这个和堆没关系,是元空间满。里面装的是类定义和方法元数据。

复现堆溢出最典型的一条路:程序启动正常,跑着跑着越来越慢,GC 频率越来越高,最后某个请求直接甩出上面那段堆栈。它带有「随时间恶化」的曲线,而不是瞬间崩。

根因分析

堆溢出只有两个根因,先分清是哪个再动手。

第一,堆确实太小。-Xmx 配的容量撑不起业务的峰值,或者干脆用了默认值。这种情况不是泄漏,是配置问题,调大就好。

第二,内存泄漏。对象本该被回收,却因为某个强引用被长期按住。这是长跑应用里最常见的诱因,Oracle 官方文档把它称作「Java 语言意义上的内存泄漏」。常见模式有这几种:

  • 静态集合当缓存,只进不出。static Map 一直往里塞,没有淘汰策略。
  • ThreadLocal 用线程池后忘了 remove(),value 跟着线程一直活着。
  • 监听器、回调、@EventListener 注册后不解绑,把对象拖成 GC Root 可达。
  • 流和连接不关,StatementResultSetCloseableHttpClient 忘了 close()
  • Spring Bean 作用域配错,把 prototype 或 request 级对象塞进单例,生命周期被无限拉长。

第三类比较隐蔽,和 finalize 有关。对象有 finalize 方法时,GC 不会当场回收,而是先丢进终结队列等守护线程处理。如果终结队列堆积速度超过处理速度,堆照样被填满。

解决方案

先留证据:让 JVM 崩的时候自己 dump

这一步必须在线上提前配好。崩溃时自动留堆快照,事后才能离线分析:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/heap.hprof

跑在容器里的话,把 HeapDumpPath 指向挂载卷,别让快照跟着容器一起没了。

没有提前配,也可以手动抓快照:

jmap -dump:format=b,file=heap.hprof <pid>

离线分析:MAT 顺着 GC Roots 找元凶

拿到 .hprof 后用 Eclipse MAT 打开,三个视图轮流看:

  • Dominator Tree:按「谁支配最多内存」排序,一眼看到最大内存占用者。
  • Histogram:按类统计实例数,某个类实例数量异常地多,就是嫌疑。
  • Path to GC Roots:选中一个大对象,看它被谁强引用着,这条链的源头往往就是泄漏点。

MAT 是免费开源的,如果不是内存特别吃紧,首选它。JProfiler 能力更强,但是商业软件。

代码层修复:断引用

堆快照告诉你泄漏点在哪个类,接下来是从代码上断掉引用:

静态缓存要有淘汰机制,换成 Caffeine 或包一层 WeakReference

// 之前:只进不出,早晚爆
static Map<String, byte[]> cache = new HashMap<>();

// 之后:交给 Caffeine 管理容量和过期
Cache<String, byte[]> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build();

ThreadLocal 用完必须 remove(),标准写法是放进 finally

ThreadLocal<SimpleDateFormat> tl = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

try {
    String s = tl.get().format(new Date());
} finally {
    tl.remove(); // 线程池复用下,不 remove 就泄漏
}

流和连接统一走 try-with-resources,编译器帮你 close()

try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {
    // ...
}

观察趋势:别只会看崩溃那一刻

处理完一层后,用 jstat 盯着老年代会不会稳定回落:

jstat -gcutil <pid> 5s

重点看 O(老年代占用率)和 FGC(Full GC 次数)。修复前 O 一路爬升、FGC 越来越频繁;修复后 O 应该在 Full GC 后明显掉下来。

思考过程

排查时最容易踩的两个坑。

一个是盲目 System.gc()。它既不保证执行,也断不了根上的强引用链,等于给症状打止痛针。

另一个是盲目调大 -Xmx。泄漏不除,堆再大也是慢性自愈,只是把崩溃的时间往后拖。堆越大,Full GC 卡顿越久,反而更难受。

分清楚 Java heap spaceGC Overhead limit exceededMetaspace 这三段 detail message,能少走很多弯路。三者的治疗方向完全不同:第一种增堆或查泄漏,第二种重写热路径或查是否在堆里囤垃圾,第三种清类和元数据。

定位方法论就一句:堆快照是你唯一可信的物证,MAT 的 Path to GC Roots 是把你领到泄漏点的那根线。

小结

  • OutOfMemoryError: Java heap space 就两个根因,堆太小,或者对象泄漏。
  • 线上一定先配 -XX:+HeapDumpOnOutOfMemoryError,没有快照就没法破案。
  • MAT 看 Dominator TreePath to GC Roots,是定位泄漏的标准组合。
  • -Xmx 调大和 System.gc() 都治标不治本,别当药吃。

来源

最后更新于 2026-08-23

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法