前言如果你有三年以上 Java 后端经验,那你大概率见过线上 OOM。最离谱的是,我们组某次凌晨三点报警,服务 OOM 了三次,业务同学说:“平时都正常,今晚也没啥流量,为啥突然 OOM?”我当时迷迷糊糊地盯着日志,看到 java.lang.OutOfMemoryError: Java heap space 那一瞬间,困意立刻没了——因为我知道,这种“偶尔性 OOM”八成不会是简单问题。

结论先说:Java 的内存问题,从来不是“平时没事,今天突然坏了”,而是某个业务或对象长期积累到一定程度才爆。就像水杯一直往里滴水,快满的时候轻轻放个针就能溢。

这篇文章就是把这些年踩的 Java 内存坑,全部摊开讲。

线上 OOM 的现象到底长什么样?线上 OOM 不会给你“我要 OOM 了”的通知,它的表现非常随机,非常恶心。

常见几个表现:

RT 突然飙升,比如接口从 20ms 跳到 1sFull GC 次数暴增,CPU 拉满 400%GC 日志出现 promotion failed 或 to-space exhausted日志里突然打出 OOM,业务线程全部挂掉容器(k8s)直接把 JVM 杀掉,无日志(最痛)我记得一次服务 QPS 正常,CPU 也正常,但 Full GC 时间从 200ms 飙到 4s,平均 RT 被拖到 600ms,我当时就知道肯定要出事。三分钟后,OOM 真来了。

OOM 不是突然发生的,它是内存给你“我顶不住了”的最后尖叫。

Java 的内存结构,没搞懂排查会超级痛苦别问为什么要看这些区域,线上定位问题时不知道堆、栈、元空间的区别,基本就等于在黑屋里找黑猫。

我自己的经验是,Java 内存问题 80% 都不是栈的问题,剩下 20% 在堆和元空间,还有少量在 Direct Memory。

几个对排查特别关键的点:

堆(Heap):大部分对象都在这里。这里爆了最常见。年轻代(Young):大量短命对象频繁创建,会让 YGC 飙升。老年代(Old):对象晋升上去后清不掉,这里会慢慢被塞满。元空间(Metaspace):类加载太多、热部署次数多会炸。线程栈(Stack):线程太多,很容易 OOM。直接内存(Direct Memory):Netty、nio Buffer 爱搞事。不用背,排查的时候每一处都能对应一种 OOM。

OOM 本身也有很多种,不同类型含义完全不同我遇到过至少七种常见 OOM,区别很大。

代码语言:java复制java.lang.OutOfMemoryError: Java heap space

java.lang.OutOfMemoryError: GC overhead limit exceeded

java.lang.OutOfMemoryError: Metaspace

java.lang.OutOfMemoryError: Unable to create new native thread

java.lang.OutOfMemoryError: Direct buffer memory

java.lang.OutOfMemoryError: PermGen space(老版本)几个关键点:

Java heap space:堆满了。大概率对象泄漏或大对象。GC overhead limit:GC 已经忙到 98% 时间都在回收,但回收效果很差,基本是泄漏。Metaspace:类加载太多,比如某次我们用了一个脚本引擎,每次执行都会加载类,半小时就爆。Unable to create new native thread:线程太多,系统不给新线程栈空间。Direct buffer memory:Netty 写 buffer 不归还。不同的 OOM 对应不同方向,搞错方向就会查到怀疑人生。

线上定位 OOM,我一般会走这几个步骤说流程前先吐槽一句:线上 dump 文件真的巨大,一次服务 OOM,dump 出来一个 7GB 的 hprof 文件,我 scp 下来花了 9 分钟,MAT 打开又花了 8 分钟……你能体会这种绝望。

排查流程大概是:

1. jmap dump 出堆快照代码语言:shell复制jmap -dump:format=b,file=oom.hprof 如果容器 kill,你只能在 k8s events 或 docker logs 里看是否 OOMKilled。

2. 用 MAT 打开,看 dominator tree找“谁占了最多空间”。真正泄漏的对象大部分会挂在一个引用链下,比如某个 Map。

3. jstat 看 GC 行为代码语言:shell复制jstat -gcutil 1000看 Old 区是否一直增长,看 Full GC 是否频繁。

4. 分析 allocation stack(最关键)MAT 有一个很逆天的功能,能看到对象创建的位置栈。

只有定位到“对象在哪里被创建的”才能真正修复,而不是盲目扩容。

为什么大量创建对象会导致堆爆?很多人以为对象创建没啥成本,但如果大量短期对象挤爆了年轻代,会触发频繁 YGC,当晋升阈值被打满,就会导致大量对象升入 Old 区,Old 区慢慢被撑满,最终 OOM。

有次我们某个接口 QPS 从 500 涨到 6000,RT 从 30ms 涨到 200ms,日志里一堆 StringBuilder、JSON parse 的对象。我在 CPU 火焰图看到 40% 的 CPU 都在 JSON 反序列化。堆不是很大,但因为对象太多,一分钟 200 次 YGC,老年代直接被挤爆。

减少对象创建,尤其是大对象,是最快速降低堆压力的方式。

大对象为什么能“瞬间爆”堆?大对象(BigObject)有时候不会走年轻代,而是直接进入老年代,这就意味着你一次创建一个 20MB 的对象,老年代一下被吃掉 20MB。

我见过一次 OOM 是因为某人写了类似这样的错误代码:

代码语言:java复制byte[] body = new byte[50 * 1024 * 1024]; // 50MB只是为了临时拼装一个输出,然后没释放,结果整个系统都跪了。

线程开太多,也能导致 OOM?真的能这点网上讲得少,但线上很常见。

OOM 类型通常是:

代码语言:txt复制java.lang.OutOfMemoryError: Unable to create new native thread问题根源:

每个线程堆栈默认要 1MB 左右线程不是 JVM 在管理,是 OS 来分配栈空间线程数多到一定程度,OS 分配不了我遇到一个超离谱的:某服务用线程池 cachedThreadPool,在峰值时瞬间启动了 1500 多条线程,系统直接炸成 OOM。根本不是堆问题,是线程栈空间耗尽。

Netty 和 Direct Memory,是 OOM 的高发区做网关或 RPC 的人应该深有体会。

Netty 的 ByteBuf 分成 heap buffer 和 direct buffer,direct buffer 在堆外,用的是 Direct Memory。这个东西不会计算进 Java heap,所以你看到堆稳得很,但系统突然 OOM。

经典错误写法:

代码语言:java复制ByteBuf buf = Unpooled.directBuffer(1024 * 1024);如果你不 release,它就永远不回收。

我见过一个网关服务,每分钟分配几百个 direct buffer,但没有 release,运行两个小时直接被系统 OOM Killed,堆一点问题没有。

泄漏和堆满,是两个完全不同的方向泄漏(Leak)= 对象不该活这么久

堆满(Heap full)= 正常创建对象,只是量大

你用 MAT 很容易区分:

泄漏:一个大对象树挂一堆对象,比如某个 HashMap 正在累积堆满:没有明显占比最高的树,都是碎对象一个小技巧是看 GC 日志:

若 Full GC 回收率极低,就是 leak若 Full GC 回收率高,但增长速度快,就是 heap 压力JVM 参数怎么配我自己偏向稳妥配置,而不是一味堆大堆:

代码语言:txt复制-Xms4g

-Xmx4g

-XX:MaxDirectMemorySize=2g

-XX:MetaspaceSize=256m

-XX:MaxMetaspaceSize=512m

-Xss512k几个经验点:

堆不要给太大,8g 以上堆会让 Full GC 超长DirectMemory 一定要控制,不然 Netty 会乱来线程栈(Xss)别开太大,不然线程多时直接 OOM怎么避免内存泄漏?我常看见几个造成泄漏的源头:

静态集合 Map / List:永远不会释放缓存没 TTLListener 没移除线程池的队列无限制Netty buffer 没 release一个我自己遇到的笑不出来的案例:某次我们用 Guava Cache,本来 TTL 设置 10 分钟。结果有个同事 copy 代码时把 expireAfterWrite 删了,缓存里的对象越积越多,一个下午涨了 7GB,直接堆爆炸。

真实 OOM 案例分享:一个没人注意的 JSON 问题有一次线上 OOM,把我折磨了整整六小时。

表现:

Full GC 频率 20 次/分钟一小时后堆从 2GB 涨到 4GB,最终 OOMdump 打开 MAT 后看到一个 HashMap 占了整整 1.8GB 的对象。引用链最终指向一段代码:

代码语言:java复制Map cache = new HashMap<>();

cache.put(key, JSON.parseObject(body));问题是 body 是可变的,而且每次都是一个 200KB 的 JSON,服务又没做 LRU 和 TTL。结果这个 HashMap 存了两万个对象,总体积大概 4GB。

根本不是“偶尔性 OOM”,是对象吃满后爆发。

线上内存监控体系,我一般会配这几个指标光靠 JVM heap 监控不够用,我线上一般会监控这个:

heap used / committed old gen 占用趋势 YGC / FGC 次数和时间 metaspace used direct memory used thread count GC pause container memory usage(避免 k8s kill) 监控到位,能提前让你知道系统 10 分钟后可能要 OOM。

写在最后Java 内存问题真不是拍脑袋解决的,而是靠 dump、MAT、GC 日志、监控把它找出来。大部分线上 OOM 都不是突然事件,而是长时间积累出来的隐患。