1. 内存溢出概述:什么是内存溢出,常见原因分类归纳总结
1.1 什么是内存溢出?
先别急着看定义。我们直接上代码,跑个现场。
public class OomDemo {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次分配 1MB
}
}
}
运行一下,大概几秒后你就会看到这个:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OomDemo.main(OomDemo.java:5)
这就是内存溢出。
定义:内存溢出(OutOfMemoryError,简称 OOM),是指应用程序向 JVM 申请内存时,堆、栈或元空间已经没有足够空间分配给新对象,JVM 无法继续执行,只能抛出这个致命错误。
说白了,就是程序向 JVM 要内存,但 JVM 说:「对不起,我的仓库已经堆满了,再也塞不下了。」
我习惯用一个简单的比喻来理解:你把 JVM 的堆内存想象成一个仓库,对象就是货物。正常运行时,货物进进出出,仓库容量够用。但如果货物只进不出(内存泄漏),或者突然有一批超大货物涌进来(大对象分配),仓库的货架就会爆掉。
核心点:OOM 不是异常(Exception),是错误(Error)。异常你还能 try-catch 抢救一下,Error 意味着 JVM 已经处于严重状态,通常无法恢复。
1.2 常见原因分类归纳
我在项目中排查过的 OOM 不下几十次,总结下来,99% 的原因都能归到下面四类。
| 类型 | 触发区域 | 典型错误信息 |
|---|---|---|
| 堆溢出 | Java 堆 | Java heap space |
| 栈溢出 | 虚拟机栈 | unable to create new native thread |
| 方法区溢出 | 元空间/永久代 | Metaspace / PermGen space |
| 直接内存溢出 | 直接内存 | Direct buffer memory |
1.3 堆内存溢出
这是最常见的一种,我估计占到了实际生产事故的 80% 以上。
原因一:内存泄漏
对象被创建后,GC 无法回收。比如长生命周期的集合(静态 List、缓存 Map)不断添加对象,不再使用的对象却一直被引用着。我记得有一次线上服务每次请求都往一个 HashMap 里塞数据,key 是用户 ID…… 你想想看,一个百万用户的系统,不出三天堆就满了。
原因二:大对象直接进入老年代
超过 -XX:PretenureSizeThreshold 设置的大对象,不会走 Eden 区,直接分配到老年代。如果这种大对象很多,老年代很快被占满,触发 Full GC,但 Full GC 又回收不掉大对象,最后就 OOM 了。
原因三:对象晋升过快
年轻代空间设置太小,或者 Minor GC 过于频繁,对象来不及在 Survivor 区周转,直接挪到了老年代。老年代扛不住,就溢出了。
1.4 栈内存溢出
栈溢出分两种,我刚开始学 Java 时老搞混。
- StackOverflowError:线程请求的栈深度超过 JVM 允许的最大深度。最常见的场景就是递归调用没有终止条件。比如:
public void infiniteRecursion() {
infiniteRecursion(); // 一直递归,永不返回
}
我也犯过这种低级错误——一次写递归快排,少写了 return 条件,控制台瞬间刷出几千行 StackOverflowError。
- OutOfMemoryError: unable to create new native thread:创建太多线程,导致操作系统无法再分配新的线程栈空间。这种情况我在高并发接口服务器上碰到过——每个请求都新建线程,又不使用线程池,QPS 一上来,线程数直接飙到几千,系统就崩了。
注意:服务器创建线程的上限受限于操作系统总内存。即使 JVM 堆内存只有 256MB,如果系统物理内存不足,也无法创建新线程。我曾经因为这个问题排查了三天,最后发现是 ulimit 设置过小。
1.5 方法区溢出
在 Java 8 之前是永久代(PermGen),之后是元空间(Metaspace)。
常见场景:
- CGLIB 动态代理生成大量类:Spring AOP、MyBatis 等框架在使用时会动态生成代理类,如果循环生成类且不控制数量,很容易撑爆方法区。
- 大量 JSP 文件:JSP 第一次被访问时需要编译成 Servlet 类,如果 JSP 页面太多,也会占用方法区空间。
- 热部署频繁:每次重新部署都会重新加载类加载器,老的类加载器如果没被回收,类信息就会不断累积。
我记得有一次公司的运营后台用了一个特别老的框架,里面大量动态生成代理类。上线两个月后就开始频繁报 Metaspace OOM,调整 MetaspaceSize 也没用,最后把框架升级才解决。
1.6 直接内存溢出
NIO 的 ByteBuffer.allocateDirect() 分配的是堆外内存。这部分内存不受 JVM 堆大小限制,直接受操作系统物理内存限制。
典型场景:
- 使用 Netty、Mina 等 NIO 框架时,分配了太多 DirectBuffer 但没有释放。
- 使用了大量 Unsafe.allocateMemory()。
坑点:直接内存溢出时,堆内存通常还很健康,你去看 dump 文件可能根本看不出问题。这时候必须借助 NMT(Native Memory Tracking)才能定位到。我以前遇到过一个案例,堆只用了 2GB,但服务器 64GB 内存全被吃完了——就是直接内存泄漏。
1.7 内存溢出核心知识体系
下面这张图梳理了本章的核心结构,帮你建立整体认知。
个人小习惯:每当我接手一个新项目的线上服务,第一件事就是检查它的 JVM 启动参数,特别是 -Xmx、-Xss 和 -XX:MaxMetaspaceSize。很多时候,OOM 的根源并不是代码写得烂,而是上线前根本没人配过 JVM 参数——全都用的默认值。
1.8 一句话总结
内存溢出说白了就三件事:内存太小(参数没调好)、对象不回收(内存泄漏)、对象来得太多太快(高并发 / 大对象)。
我们后续的章节会针对每一种情况给出实战排查步骤和修复方案。嗯,先把诊断工具备好,后面都用得上。