一句话先说结论:
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 可达。 - 流和连接不关,
Statement、ResultSet、CloseableHttpClient忘了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 space、GC Overhead limit exceeded、Metaspace 这三段 detail message,能少走很多弯路。三者的治疗方向完全不同:第一种增堆或查泄漏,第二种重写热路径或查是否在堆里囤垃圾,第三种清类和元数据。
定位方法论就一句:堆快照是你唯一可信的物证,MAT 的 Path to GC Roots 是把你领到泄漏点的那根线。
小结
OutOfMemoryError: Java heap space就两个根因,堆太小,或者对象泄漏。- 线上一定先配
-XX:+HeapDumpOnOutOfMemoryError,没有快照就没法破案。 - MAT 看
Dominator Tree加Path to GC Roots,是定位泄漏的标准组合。 -Xmx调大和System.gc()都治标不治本,别当药吃。