前言如果你有三年以上 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
2. 用 MAT 打开,看 dominator tree找“谁占了最多空间”。真正泄漏的对象大部分会挂在一个引用链下,比如某个 Map。
3. jstat 看 GC 行为代码语言:shell复制jstat -gcutil
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.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 都不是突然事件,而是长时间积累出来的隐患。