3. 堆内存溢出(Java heap space)详解

堆内存溢出,说白了就是JVM的堆(Heap)装不下新对象了。

我做了十几年性能调优,遇到最多的OOM就是它。很多人以为只要加大堆内存就能解决,结果呢?照旧崩。为啥?因为你根本不知道到底谁把堆撑爆了。

这节课,我带你把堆内存溢出的根因刨清楚,再手把手演示怎么用工具定位。嗯,咱们直接上干货。


3.1 堆内存溢出到底是怎么发生的?

堆是Java对象的大本营。new出来的对象、数组,全扔这里。GC(垃圾回收)一直在清理没用的对象。但当你创建对象的速度 > GC回收的速度,堆就慢慢涨上去。涨到设置的最大值(-Xmx),GC已经拼了命但还腾不出空间,JVM就会抛出:

java.lang.OutOfMemoryError: Java heap space

我遇到过一位同事,跑一个批处理程序,把整个数据库的表都读到内存里做聚合。堆只有2GB,数据量却几十GB,结果你猜怎么着?跑了3小时,最后OOM,前功尽弃。这就是典型的数据量远超堆容量。

关键认知: 堆内存溢出 ≠ 内存泄漏。泄漏是对象一直被引用无法回收;溢出也可能是瞬时流量太大,GC来不及回收。

常见的推手有这么几类:

  • 对象创建太猛——比如高并发下单,每个请求都new大对象。
  • 集合积累无度——往List里塞数据,塞到内存爆炸。
  • 大对象直接进老年代——比如几百兆的byte[]。
  • 内存泄漏——对象被无意中引用,GC无法回收。
  • 堆配置太小——默认值往往只有物理内存的1/4,对于现在的应用经常不够。

3.2 实战复现:写一段必溢出代码

光说不练假把式。我来写个小Demo,你能直接跑出Java heap space:

public class HeapOOMDemo {
    public static void main(String[] args) {
        // 我故意留一个静态引用,防止GC回收
        List<byte[]> list = new ArrayList<>();
        while (true) {
            // 每次分配10MB
            list.add(new byte[10 * 1024 * 1024]);
            System.out.println("已分配: " + list.size() + " 个10MB对象");
        }
    }
}

运行前设置JVM参数:-Xms128m -Xmx128m。控制台会打印“已分配: 1”、“已分配: 2”……直到某个数字后抛出OOM。你猜能分配几个?大约12个左右(128MB / 10MB ≈ 12.8,再算上JVM自身开销)。

注意: 千万别在生产环境乱跑这种代码。我曾经在一个压测集群上做过一次误操作,把整个组的服务全打挂了……嗯,从那以后我都先确认参数再执行。

3.3 用jmap + dump精准定位

光知道溢出不够,你得找到哪个对象占最多。我个人最常用的工具就是jmap。

3.4.1 生成堆转储文件(Heap Dump)

在OOM发生时,加一句JVM参数让它自动dump:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof

或者手动用jmap抓(进程未挂时):

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

:live参数会先触发Full GC,只保留存活对象。你想看看搞事的是谁,建议先用这个,因为死对象在GC后基本没了,留下来的才是占用内存的真正元凶。

3.4.2 用Eclipse MAT分析dump文件

拿到.hprof文件后用MAT(Memory Analyzer Tool)打开。我习惯先看Leak Suspects,它自动识别溢出嫌疑。通常你会看到类似这样的结论:“The thread main creates 12 instances of byte[]. The total consumption is 120 MB.”——这就是上面Demo的真实写照。

再看Dominator Tree,它能告诉你对象间的引用链,谁是真正撑起树的根。

MAT关键视图 用途
Leak Suspects 自动检测最可能的泄漏源
Dominator Tree 按保留堆大小排序,展示引用结构
Histogram 按类统计对象数量和占用内存
Thread Overview 查看每个线程的栈和本地变量
顶级技巧: 分析的时候先看Biggest Objects。如果最大的是一个HashMap或ArrayList,十有八九是集合泄漏。我曾经帮一个电商团队找出线上OOM,就是用MAT看到com.sun.jmx.mbeanserver.JmxMBeanServer持有了所有JMX对象……最后发现是监控框架写错了注册逻辑。

3.5 什么时候该加堆,什么时候该改代码?

我见过不少开发兄弟,一看到OOM就加-Xmx。加完短期不崩了,过两周又崩。你看,这其实是个误区。我们需要分场景:

  • 流量高峰期临时溢出:适当加大堆内存,同时检查是否需要限流或扩容。
  • 内存泄漏:加再多堆也白搭,早晚耗光。必须修代码。
  • 单次数据量极大:比如读取超大文件,考虑流式处理,别全塞内存。
  • 缓存太多:用Guava Cache或Caffeine设置最大条目和过期策略,别让它无限增长。
一个实用的定位思路: 先看dump里哪个对象占最大,再顺着引用链找到代码调用处。如果是自己写的类,问自己“这个对象为什么一直没被回收?”如果是第三方库,查一下它是否注册了监听器、观察者没取消订阅。

本章知识脑图

堆内存溢出(Java heap space)核心逻辑 堆内存溢出 OOM 对象创建速度 > GC速度 大对象直入老年代 内存泄漏 / 集合积累 高并发请求 无限循环添加 String/byte[]超大 缓存没有淘汰 流式读取未分段 jmap dump 分析 MAT Leak Suspects GC日志 + -Xlog:gc* 记住:堆溢出不一定要加堆,先查谁在吃内存!

好了,这是堆内存溢出最核心的内容。你只要掌握:为什么会溢 → 怎么写代码复现 → 怎么用dump定位,以后线上再遇到Java heap space,就知道从哪里下手了。

顺手总结一下定位流程:

  1. 加JVM参数 -XX:+HeapDumpOnOutOfMemoryError
  2. 等OOM后取得java_pidxxx.hprof
  3. 用MAT打开,看Leak Suspects + Dominator Tree
  4. 找到最大对象,顺引用链定位到代码
  5. 问自己:这个对象该不该长期持有?能不能弱引用?能不能分批处理?
重要提醒: 不要在压测环境或生产环境手动用jmap dump正在运行的进程,尤其是堆很大时,full GC + dump会引发STW停顿,可能导致服务超时。我曾经在高峰期做过一次,结果那一秒全站接口都卡住了……后来我只在低峰期或使用-XX:+HeapDumpOnOutOfMemoryError自动方式。