OOM概述:什么是OutOfMemoryError,常见原因与分类

做Java开发这么多年,我遇到的第一个“硬核”报错就是OutOfMemoryError。那时候我还年轻,看到堆栈里一长串异常,第一反应是“服务器又挂了?”后来才知道,OOM是JVM在极度饥饿状态下发出的最后一声哀嚎。说白了,就是内存耗尽了,连新对象都分配不了。

为什么会这样?可能是堆太小,可能是代码有泄漏,也可能是某个数据结构膨胀到了失控的程度。今天我们就从根上捋一捋OOM的来龙去脉。

什么是OutOfMemoryError

OutOfMemoryError是Java的java.lang.OutOfMemoryError异常。它不同于普通的NullPointerException,它是一个严重错误,属于Error层级。一旦抛出,通常意味着JVM内存区域已经满了,而且垃圾回收也没能回收出可用空间。

你可能会问:JVM不是有GC吗?为什么还会内存不足?嗯,这里要注意:GC只能回收不再被引用的对象。如果对象一直被引用,或者引用的数量巨大,GC也是巧妇难为无米之炊。我有个项目曾经因为一个HashMap的key没重写equals方法,导致该放的放不进去,旧的对象永远无法被清除,最终堆空间爆了。

记住一个核心原则:OOM不是程序“崩了”,而是JVM无法继续为新的内存分配请求提供服务。这时候,你的应用已经处于不可用状态。

常见原因与分类

根据我的经验,OOM有五个最常见的“面孔”。我把它们整理成了下表,方便你对照排查:

错误消息 对应内存区域 典型原因
Java heap space 堆 对象泄漏、堆太小、一次性加载过多数据
Metaspace 元空间 类加载卸载不平衡、动态生成类过多
Unable to create new native thread 栈(线程) 线程创建过多、操作系统线程限制
Direct buffer memory 直接内存 NIO中ByteBuffer分配未释放
GC overhead limit exceeded 堆 GC时间占比超过98%且回收效果极差

光是记住这些消息还不够,你得明白背后的机制。举个例子,「Java heap space」是最常见的。它表明堆空间满了。但为什么堆会满?可能是你用了ArrayList撑着几千万条记录没释放,也可能你循环里不断new一个大数组却不扔掉引用。我在一个报表系统里就见过,每次查询都把全表数据拉到内存里,用户一多,堆直接撑爆。

而「Metaspace」则跟类元数据有关。JDK8以后,永久代被移除,换成了元空间,使用的是本地内存。如果你频繁加载类(比如动态代理、CGlib),而又没做好清理,元空间就会涨到操作系统限制上限。我记得有次线上服务每分钟加载几百个临时类,元空间飙升到2GB,最后OOM。

还有一个很多人忽略的——「Unable to create new native thread」。这其实是操作系统层面线程资源耗尽。JVM的每个线程在OS里对应一个原生线程,当线程数超过上限(比如ulimit或pthread数量限制),就会报这个错。我曾经遇到过一个连接池配置失误,每来一个请求就创建新线程,没有复用,几分钟内线程数破千,服务器直接挂掉。

「Direct buffer memory」和「GC overhead limit exceeded」也各有各的坑。前者常见于Netty、Mina等NIO框架,直接内存不够就要看代码里有没有及时回收DirectBuffer。后者更诡异:JVM花了98%的时间做GC,只收回2%的内存,干脆罢工了。这时你看到日志里大量Full GC,但堆就是降不下来。

我的一个小习惯:遇到OOM不要慌,先看异常堆栈的头三行。很多时候错误消息已经告诉你是哪块内存出了事。再结合jstat、jmap等工具,几分钟就能定位方向。

分类总结与整体脉络

为了更好地理解这些关系,我画了一张图:

OutOfMemoryError Java heap space Metaspace Unable to create native thread Direct buffer memory GC overhead limit exceeded 对象泄漏 / 堆太小 类加载过多 线程泄漏 / ulimit限制 NIO缓冲区未释放 GC效率极低 黄色框中列出了每种OOM最常见的直接原因 排查时先定位类型,再深挖该方向的泄漏点

这张图把五大OOM类型和常见的直接原因串联起来了。你看,大部分问题都源于资源管理失控。对于Java堆空间,往往是对象生命周期过长;对于元空间,则可能是类加载器没有回收。每个类型的排查方法略有不同,但核心思路一致:找到哪些对象在“吃”内存,以及为什么没被回收。

避坑指南与个人经验

警告:不要在生产环境直接使用 -Xmx 搭配无限大的堆。我见过有人把堆设成机器物理内存的80%,结果系统其他进程没内存可用,直接OOM kill。堆的最大值应该基于压测结果和预留余量来确定。

我曾经花了两天时间排查一个诡异的OOM:服务每隔5小时准时挂一次,重启后又能撑5小时。后来抓Heap Dump发现,Tomcat的ClassLoader里存了一个静态的HashSet,每次请求都往里面加一条数据,但永远不remove。日积月累,内存就成了堰塞湖。你看,有时候OOM不是瞬间爆发的,而是慢慢“养”出来的。

再分享一个技巧:如果你遇到了「GC overhead limit exceeded」,别急着加堆内存。先检查是不是代码里有System.gc()调用,或者使用了某些老框架频繁触发Full GC。加堆有时反而延迟了问题的暴露,治标不治本。

我建议:每接到一个OOM问题,先按以下三步走:
  1. 读错误消息,确定内存区域(堆/元空间/栈/直接内存)
  2. 用jstat查看GC情况,确认GC是否正常
  3. 如果涉及堆,立即抓一份Heap Dump(jmap -dump:format=b,file=heap.hprof \)
有了Dump,后续分析才有数据基础。

说到底,OOM不是洪水猛兽。只要你能理解JVM的内存模型,掌握基本的监控和Dump分析手段,大部分OOM都可在几分钟内定位。后面的章节,我会带大家一步步走通Heap Dump分析的完整流程,从生成到分析再到修复。嗯,今天就聊到这里,希望你心里对OOM已经有了一个清晰的轮廓。