1. OOM 概述:你真的了解内存溢出吗?
先问大家一个问题:你线上遇到过 OutOfMemoryError 没?我猜十有八九的朋友都点头了。
我记得我刚工作的第二年,凌晨三点被电话叫醒,说用户反馈商品详情页打不开了。我登录服务器一看,控制台赫然飘着 java.lang.OutOfMemoryError: Java heap space。当时我手忙脚乱,重启了事,结果第二天又崩了。后来花了两天一夜才找到真正的元凶——一个批量查询的接口把整个数据库表都加载到了堆里。
从那以后,我养成了一个习惯:每次发布前,都会先跑一遍压力测试,盯着内存图看半小时。你想想看,如果连 OOM 是怎么来的都不清楚,怎么指望能治住它?
1.1 什么是 OOM?
OOM 全称 OutOfMemoryError。它是 JVM 抛出的一类错误,表示虚拟机没有足够的内存分配给新对象。注意,它是 Error 而不是 Exception——意味着 JVM 已经尽力了,但活不下去了。
说白了,就是内存不够了。但这里的「不够」有两种情况:一是真的物理内存不够了;二是 JVM 堆、栈等区域被用满了,但垃圾回收(GC)又回收不掉那些对象,导致死锁式的阻塞。
我见过不少新手以为 OOM 只是堆空间的问题。其实不是,请看下面这张图:
从上图你能看到,OOM 其实有四个主要的“战场”。我当年最先翻车的是堆溢出,后来做微服务框架时又踩过元空间溢出的坑。下面我逐个跟你聊聊。
1.2 常见的 OOM 类型
1.2.1 堆溢出(Java heap space)
这是最常见的一种,占了 OOM 的 80% 以上。堆里存的是对象实例和数组。当对象不断被创建,且 GC 无法回收时,堆空间就爆炸了。
我在项目中遇到过一个经典场景:一个定时任务用 List 缓存查询结果,每次都往里面 add,但从来没清过。跑了三天,堆就满了。排查时用 jmap -histo 一看,Object[] 占了几十 G。
避坑指南:我曾经把一个循环中的字符串拼接写成 str += "xxx",在循环里生成大量中间 String 对象,导致堆溢出。改用 StringBuilder 后立刻解决。你说坑不坑?
1.2.2 栈溢出(StackOverflowError)
栈溢出和堆溢出不同,它通常不是内存不够,而是方法调用太深,把栈帧给撑爆了。每个线程有独立的栈空间,默认在 Linux 上一般是 1MB。
最常见的元凶是递归。我记得有个同学写斐波那契数列时没写递归终止条件,直接让 Fibonacci(100) 把自己炸了。也见过 Web 服务因为拦截器链调用回调函数导致无限循环,栈溢出。
// 一个经典的“炸弹”
public void foo() {
foo(); // 没有终止条件,一秒后 StackOverflowError
}
小技巧:调优时可以用 -Xss256k 缩小栈空间,节省内存;但如果方法调用层数真的很多,建议加大到 2MB 或直接用循环代替递归。
1.2.3 元空间溢出(Metaspace)
Java 8 之后,永久代(PermGen)被元空间(Metaspace)取代。元空间里存的是类的元数据(比如类名、方法名、字段等)。如果应用动态生成大量类,比如频繁热部署、用 CGLIB/ASM 做动态代理,就会把元空间塞满。
我之前维护过一个老项目,用 Spring Boot + DevTools 热重启,每次重启都会加载新的类加载器,老的类加载器又没被回收。一星期后元空间涨到 2G,直接 OOM。解决办法是加 -XX:MaxMetaspaceSize=256m 限制上限,然后排查类加载泄漏。
| OOM 类型 | 错误信息 | 典型原因 | 检测手段 |
|---|---|---|---|
| 堆溢出 | Java heap space | 内存泄漏、大对象、缓存爆炸 | jmap -histo / MAT / JProfiler |
| 栈溢出 | StackOverflowError | 递归无终结、线程数过多 | jstack / 分析调用栈 |
| 元空间溢出 | Metaspace | 动态类过多、热部署泄漏 | -XX:MaxMetaspaceSize / 类加载统计 |
| 直接内存溢出 | Direct buffer memory | NIO Buffer 未释放、配置过小 | -XX:MaxDirectMemorySize / pmap |
1.2.4 直接内存溢出(Direct buffer memory)
直接内存是 JVM 堆外的一块区域,使用 ByteBuffer.allocateDirect() 分配。它不受堆大小限制,但受 -XX:MaxDirectMemorySize 参数约束。很多 NIO 框架(Netty、gRPC)都会用到它。
我记得有一次上线 Netty 服务,压测时老是报 OutOfMemoryError: Direct buffer memory。一开始我以为是堆问题,排查半天才发现是 ByteBuffer 没有及时 release,导致堆外内存被占满。用 pmap -x pid 看到一堆 64MB 的匿名映射,真相大白。
警告:直接内存的回收依赖 Cleaner 和 GC,如果 ByteBuffer 对象被长期引用导致无法被 GC,直接内存就会泄漏。我曾经因为这个导致服务每周重启一次,后来用 Netty 的 PooledByteBufAllocator 配合监控才彻底解决。
1.3 为什么会出现 OOM?
你仔细想想,JVM 已经尽力在做 GC 了,为什么还是爆了?原因可以归纳成三类:
- 内存泄漏:对象不再使用,但被 GCRoots 引用,GC 无法回收。这是最常见的元凶。
- 内存分配太快:如果对象创建速度超过 GC 回收速度,就算没有泄漏,也会 OOM。比如高并发下反复创建大数组。
- 内存配置太小:应用本身需要 2G 堆,你只给了 256M,那无论如何都会爆。我见过有人把 -Xms 和 -Xmx 设成一样的极小值,完全没给 GC 留缓冲空间。
以我个人的经验,排查 OOM 时首选 jmap -dump:format=b,file=heap.hprof <pid> 生成堆转储,然后用 MAT 分析。如果是栈溢出,直接用 jstack 看线程堆栈。元空间和直接内存溢出则需要借助 JVM 参数和操作系统命令。
一句话总结:OOM 不是 Bug,是症状。你得学会听懂 JVM 用错误信息告诉你的话——“这里内存不够用了,快去查原因。”
好了,这一章我们先把 OOM 的全貌看清楚了。四种类型各自怎么触发、有什么特征,你现在心里应该有个底。嗯,别急着往下翻——理解了这张图,后面的调试和优化方法你才能用得顺手。