首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >为什么生产环境偶尔会出现 OOM?Java 内存模型 + 排查全流程

为什么生产环境偶尔会出现 OOM?Java 内存模型 + 排查全流程

原创
作者头像
用魔法才能打败魔法
发布2025-11-23 23:30:01
发布2025-11-23 23:30:01
6850
举报

前言

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

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

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

线上 OOM 的现象到底长什么样?

线上 OOM 不会给你“我要 OOM 了”的通知,它的表现非常随机,非常恶心。

常见几个表现:

  • RT 突然飙升,比如接口从 20ms 跳到 1s
  • Full GC 次数暴增,CPU 拉满 400%
  • GC 日志出现 promotion failedto-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 <pid>

如果容器 kill,你只能在 k8s events 或 docker logs 里看是否 OOMKilled。

2. 用 MAT 打开,看 dominator tree

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

3. jstat 看 GC 行为

代码语言:shell
复制
jstat -gcutil <pid> 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

怎么避免内存泄漏?

我常看见几个造成泄漏的源头:

  1. 静态集合 Map / List:永远不会释放
  2. 缓存没 TTL
  3. Listener 没移除
  4. 线程池的队列无限制
  5. Netty buffer 没 release

一个我自己遇到的笑不出来的案例:某次我们用 Guava Cache,本来 TTL 设置 10 分钟。结果有个同事 copy 代码时把 expireAfterWrite 删了,缓存里的对象越积越多,一个下午涨了 7GB,直接堆爆炸。

真实 OOM 案例分享:一个没人注意的 JSON 问题

有一次线上 OOM,把我折磨了整整六小时。

表现:

  • Full GC 频率 20 次/分钟
  • 一小时后堆从 2GB 涨到 4GB,最终 OOM

dump 打开 MAT 后看到一个 HashMap 占了整整 1.8GB 的对象。引用链最终指向一段代码:

代码语言:java
复制
Map<String, Object> 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 都不是突然事件,而是长时间积累出来的隐患。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 线上 OOM 的现象到底长什么样?
  • Java 的内存结构,没搞懂排查会超级痛苦
  • OOM 本身也有很多种,不同类型含义完全不同
  • 线上定位 OOM,我一般会走这几个步骤
    • 1. jmap dump 出堆快照
    • 2. 用 MAT 打开,看 dominator tree
    • 3. jstat 看 GC 行为
    • 4. 分析 allocation stack(最关键)
  • 为什么大量创建对象会导致堆爆?
  • 大对象为什么能“瞬间爆”堆?
  • 线程开太多,也能导致 OOM?真的能
  • Netty 和 Direct Memory,是 OOM 的高发区
  • 泄漏和堆满,是两个完全不同的方向
  • JVM 参数怎么配
  • 怎么避免内存泄漏?
  • 真实 OOM 案例分享:一个没人注意的 JSON 问题
  • 线上内存监控体系,我一般会配这几个指标
  • 写在最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档