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 内存溢出核心知识体系

下面这张图梳理了本章的核心结构,帮你建立整体认知。

内存溢出 (OOM) 核心知识体系 OOM 定义与本质 堆溢出 Java heap space 内存泄漏 / 大对象 栈溢出 StackOverflowError 线程超出限制 方法区溢出 Metaspace 动态生成类过多 直接内存溢出 Direct buffer NIO / Unsafe 诊断工具准备 jmap / jstat / jstack MAT / VisualVM NMT (Native Memory Tracking) JVM 参数调优 -Xmx / -Xms / -Xss -XX:MaxMetaspaceSize -XX:MaxDirectMemorySize 最佳实践 合理使用集合缓存 线程池代替 new Thread 控制动态代理生成 核心原则:预防 > 发现 > 修复 每个 OOM 背后都有一段应该被提前预防的代码

个人小习惯:每当我接手一个新项目的线上服务,第一件事就是检查它的 JVM 启动参数,特别是 -Xmx、-Xss 和 -XX:MaxMetaspaceSize。很多时候,OOM 的根源并不是代码写得烂,而是上线前根本没人配过 JVM 参数——全都用的默认值。

1.8 一句话总结

内存溢出说白了就三件事:内存太小(参数没调好)、对象不回收(内存泄漏)、对象来得太多太快(高并发 / 大对象)。

我们后续的章节会针对每一种情况给出实战排查步骤和修复方案。嗯,先把诊断工具备好,后面都用得上。