如何快速定位线上OOM

线上服务挂了,日志里翻到一行 java.lang.OutOfMemoryError: Java heap space,进程跟着就没了。最常见的反应是把 -Xmx 调大一倍,但如果根因是内存泄漏,调大只是把爆炸时间往后推,泄漏的对象还在涨。

OOM 是最后的表现,排查真正要回答的是两件事:是哪种内存不够 ,以及什么东西一直在涨、为什么没被回收。

下面按排查的先后顺序来:先分辨是哪种 OOM,再讲怎么保住现场,然后是怎么把 dump 读出来,最后是不方便 dump 的时候用什么顶。

第一步:先看是哪种 OOM

OutOfMemoryError 只是个大类,冒号后面的 message 才决定往哪个方向查,不同的 message 根因差得很远。

message 缺的是哪块内存 典型原因
Java heap space 堆 内存泄漏,或者堆真的设小了
GC overhead limit exceeded 堆(另一种报法) GC 花了大量时间却几乎收不到东西
Metaspace 元空间 动态生成的类太多,或者类加载器泄漏
Compressed class space 压缩类空间 默认只有 1G,类元数据撑爆了
Direct buffer memory 直接内存(堆外) NIO 的 ByteBuffer.allocateDirect
unable to create new native thread 线程栈 线程数暴增,或者 ulimit 卡住
Requested array size exceeds VM limit ------ 数组长度超过 JVM 上限

Java heap space 和 GC overhead limit exceeded 是一回事

先看 GC overhead limit exceeded,很多人以为它和堆没关系,其实它就是堆快满了的另一种说法。

这个错误由 -XX:+UseGCOverheadLimit 控制,默认开启,触发条件是:GC 花掉的时间超过 98%,而回收出来的堆内存不到 2%。这两个数字说明 GC 在做无用功,绝大多数对象都是活的,收不动。

抛这个错误其实是一种"快速失败",比让程序在那儿空转几十分钟强,堆真满只是早晚的事。所以 -XX:-UseGCOverheadLimit 关掉它没什么意义,只是让报错晚一点,从 GC overhead limit exceeded 变成 Java heap space。这两个都指向同一个方向:堆里的东西收不掉。

堆外的几种要单独区分

Metaspace 是 JDK 8 移除永久代之后的类元数据区,在本地内存里,默认不设上限(受物理内存限制),可以靠 -XX:MaxMetaspaceSize 卡住。

出现 Metaspace OOM 基本都是动态生成类造成的,正常写业务代码,类的数量是固定的。几个典型来源:

  • CGLIB 动态代理,Spring AOP 给每个被代理的类生成一个子类。正常有缓存不会涨,每次都新建 Enhancer 才会出问题
  • Groovy / JRuby / JS 这类脚本引擎,每跑一段脚本可能新建一个类加载器
  • 反射、MethodHandles,以及部分 JSON / 序列化框架

排查用 jcmd <pid> VM.metaspace,或者 -verbose:class 看类加载。还有一种情况是类加载器泄漏 :类本身没问题,但老的 ClassLoader 被强引用住,连带它加载的所有类都卸载不了。热部署和脚本引擎最容易出这个。

Direct buffer memory 是直接内存,-XX:MaxDirectMemorySize 控制,不设的话默认等于 -Xmx。它的坑在于堆 dump 里看不到 ,堆里只有一个几十字节的 DirectByteBuffer 对象,真正的一大块内存在堆外,是它后面挂着的。这块内存靠 Cleaner(一个虚引用)在 DirectByteBuffer 自己被 GC 的时候释放。堆很宽裕的时候 JVM 懒得 GC,直接内存就只涨不降,一直到打满。

unable to create new native thread 是线程创建不出来。每个线程要占一块栈内存(-Xss,x64 上默认 1M),这些栈都在本地内存。撞到上限可能是几个原因:-Xss 设太大、物理内存不够、ulimit -u 限制进程/线程数、或者内核 vm.max_map_count(每个线程栈要占两个内存映射,默认 65530,算下来能开的线程大约三万个)。业务上最常见的是线程池没设上限,比如 Executors.newCachedThreadPool()。

被杀掉的进程根本不是 OOM

还有一种情况连 OOM 堆栈都没有,日志里就一个 Killed,进程直接没了。

这不是 JVM 抛的,是操作系统或容器把进程杀了 。容器里叫 OOMKilled,退出码 137(128 + 9,9 是 SIGKILL)。

shell 复制代码
# 容器
kubectl describe pod <pod> | grep -A3 "Last State"
# Last State:  Terminated
#   Reason:    OOMKilled
#   Exit Code: 137

# 宿主机上,看内核的日志
dmesg -T | grep -i -E "oom|killed process"

根因和 JVM 的 OOM 完全不一样:JVM 是自己申请内存失败,OOMKilled 是整个容器的内存超过 cgroup 上限被内核杀。内核不管里面是什么结构,只看 RSS 总量。

一个容器的内存不止堆:

text 复制代码
容器 RSS ≈ 堆(-Xmx) + Metaspace + 线程栈 + 直接内存 + CodeCache + GC 自身结构 + JVM 自身开销

G1 的 Remembered Set 能占到堆的百分之十几,线程多的话线程栈也是大头(500 个线程 × 1M 就是 500M)。所以把 -Xmx 设成跟容器 limit 一样,必炸。经验值是堆不超过 limit 的 75%。

这里还有个很老的坑:JDK 8u191 之前,JVM 读的是宿主机的物理内存,不认 cgroup 限制。宿主机 128G、容器 limit 4G,JVM 默认 -Xmx 算出来是 32G,容器一启动就被杀。8u191 之后有了 -XX:+UseContainerSupport(默认开),才会按容器的 limit 算,默认比例是 -XX:MaxRAMPercentage=25.0。

排查这种问题先确认一件事:Runtime.getRuntime().maxMemory() 是不是和容器 limit 差得离谱。

第二步:先保住现场

让 JVM 自己 dump

为了保留OOM的现场,我们需要提前配置好三个参数:

shell 复制代码
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-XX:+ExitOnOutOfMemoryError

第一个是发生 OOM 时自动 dump 堆。第二个指定路径,不写的话默认是工作目录下的 java_pid<pid>.hprof。第三个是 dump 完直接退出,交给容器编排系统重新拉起一个。

-XX:+ExitOnOutOfMemoryError 和 dump 不冲突,JVM 是先把 dump 写完再退。

容器里的两个坑

-XX:HeapDumpPath 一定要挂到持久卷或者宿主目录上。容器重启之后容器内的文件系统是全新的,dump 写在容器里等于没写。

dump 文件大小和堆是一个量级的,几十 G 的堆就是几十 G 的文件。挂载卷的空间不够,dump 会写到一半失败,前面白等。这两点都得提前确认。

第三步:把 dump 读出来

工具用 MAT(Memory Analyzer Tool),Eclipse 出的,有独立版本,直接拖 hprof 进去。

MAT 里先看这四个地方

text 复制代码
1. Leak Suspects       自动分析,直接给出"疑似泄漏"和它被谁持有(先看这个)
2. Dominator Tree      支配树,按 retained size 排序,从大到小就是内存大头
3. Histogram           按类聚合,看哪个类的实例数/占用异常
4. Top Consumers       按类、包、类加载器看占用排行

正常路径是先看 Leak Suspects,它给一个方向,然后到 Dominator Tree 里顺着往下钻。

Histogram 最容易看:比如看到 byte[] 有 20 万个,或者 HashMap$Node 有三千万个,那就不对劲,直接双击看它的实例。

shallow size 和 retained size

这是 MAT 里最容易看错的一个点,直接决定你找得到找不到泄漏。

text 复制代码
shallow size   对象自己占的内存(对象头 + 字段),不含它引用的对象
retained size  把这个对象回收掉之后,能连带回收的内存总量

举个例子,一个 ArrayList 的 shallow size 很小,本体就一个数组引用加几个计数字段,但它的 retained size 是它自己 + 那个底层 Object[] + 数组里所有元素。

所以 Histogram 里按 shallow size 排,byte[] 这种会排在很前面,但那不是根因,真正的原因是某个 ArrayList 或者 HashMap 持有着这些数组。找泄漏要看 retained size,不是 shallow size。

Path to GC Roots:找到是谁没放手

Dominator Tree 告诉你"内存被谁占着",Path to GC Roots 告诉你"为什么它还没被回收"。

对着一个可疑对象右键 → Path to GC Roots,它会给出从 GC Roots 一路引用到这个对象的完整路径。谁在这条路径的末端一直抓着它,谁就是泄漏点。

这里有个必须做的动作:勾上 exclude weak/soft references 。不勾的话路径会被软引用、弱引用污染,出来一堆没意义的链(WeakHashMap、ThreadLocal 的 key、缓存框架的内部结构),真正那条强引用路径会被淹没在里面。

第四步:线上不方便 dump 的时候

dump 会 STW,几十 G 的堆能停几秒到几十秒,线上直接做风险很大,通常得挑低峰期或者先把流量摘掉。文件也巨大,下载和分析都慢。

轻一点的手段:

shell 复制代码
# 打印类直方图到终端,不落文件,最快的"先看一眼"
jmap -histo <pid> | head -n 30

# 加 :live 会先做一次 full GC,只剩存活对象,结果干净但有停顿
jmap -histo:live <pid> | head -n 30

# 每秒一次 GC 统计,重点看 O(老年代使用率)和 FGC(Full GC 次数)
jstat -gcutil <pid> 1000

# JVM 各部分的本地内存,直接内存 OOM 就靠它
# 需要启动时加 -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary

jmap -histo 和 jstat 组合起来基本能判断个七八成:直方图告诉你谁最多,jstat 告诉你 GC 收不收得动。GC 相关的细节可以回顾一下垃圾回收器 G1 和 CMS 有什么区别?

如果装了 Arthas,还有两条更顺手的:

shell 复制代码
# 实时面板,堆、GC、线程都有
dashboard

# 直接看某个类的所有实例对象,不用 dump
vmtool --action getInstances --className com.xxx.OrderDTO --limit 10

vmtool 这个功能很省事,线上不 dump 也能把某个类的实例捞出来看字段,很多时候看一眼就有答案。

常见的几类根因

上面是通用流程,实际线上翻来覆去就是下面这几种。

静态集合当缓存

最经典的一种:

java 复制代码
public class OrderCache {
    private static final Map<Long, OrderDTO> CACHE = new ConcurrentHashMap<>();

    public OrderDTO get(Long id) {
        return CACHE.computeIfAbsent(id, this::load);
    }
}

只进不出,key 又是用户 ID 这种近乎无界的值,跑几天就满了。

在 MAT 里这类泄漏的 Path to GC Roots 很好认,路径里会出现 java.lang.Class → static field,说明是静态字段一直抓着。

修法不是简单删缓存,是用带容量上限和过期策略的缓存:Caffeine、Guava Cache,或者自己写 LRU。顺便说下 WeakHashMap,它的 key 是弱引用,value 还是强引用,只有 key 在别处已经没有任何强引用时才回收得掉。这里的 key 是 Long 字面量,语义和缓存场景对不上,拿它当缓存用基本没用。

ThreadLocal 没 remove

ThreadLocal 的 ThreadLocalMap 里,Entry 的 key 是 WeakReference<ThreadLocal<?>>,value 是普通强引用。

text 复制代码
Thread → ThreadLocalMap → Entry[]
                            └─ Entry { WeakReference<ThreadLocal> key, Object value }

ThreadLocal 对象本身没有被别的地方引用之后,key 会被 GC 回收变成 null,但 value 还挂在这个 Entry 上。这个 key 已经为 null 的 Entry,只能等下一次 set / get / remove 时被顺手清理掉。如果线程一直在跑(线程池里的核心线程),又不再碰这个 ThreadLocal,value 就永远卡在那儿。

所以线程池场景里,用完必须 remove():

java 复制代码
try {
    CONTEXT.set(ctx);
    doSomething();
} finally {
    CONTEXT.remove();
}

key 用 WeakReference 只是帮你在 ThreadLocal 实例没用了之后能回收 key,value 从来不会自动清,这是设计上的取舍,不是 bug。

直接内存

前面提过,DirectByteBuffer 的堆内对象很小,直接内存在堆外,堆 dump 完全看不到。有两种表现:

  • Direct buffer memory 的 OOM
  • 堆看着很健康,容器却老是 OOMKilled,因为直接内存也算在容器 RSS 里

靠 NMT 看:

shell 复制代码
java -XX:NativeMemoryTracking=summary ...
jcmd <pid> VM.native_memory summary

Netty 这类 NIO 框架会自己管直接内存(池化的 ByteBuf,或者主动释放),不依赖 GC,就是为了避开这个坑。

内部类持有外部类

非静态内部类(包括匿名类、lambda 捕获)会隐式持有外部类的引用。如果这个内部类被注册到了某个长生命周期的地方,比如监听器、回调、线程池的任务,外层对象就跟着一起泄漏了。

java 复制代码
// 这个 lambda 隐式持有 Outer.this,只要任务还活着,Outer 就回收不掉
executor.submit(() -> { /* 用了外面的字段 */ });

这种在 Path to GC Roots 里表现为多出一层 Outer$1.this$0 之类的引用。

不是泄漏,只是容量不够

最后一种:代码没有任何问题,是流量涨了或者堆设小了。

区分方法和上面完全不同,看 jstat 里每次 Full GC 之后老年代能降到多少:

text 复制代码
Full GC 后老年代降到 10%,几小时后又爬满  → 不是泄漏,是对象生命周期长,或者流量上来了
Full GC 后老年代还在 80%                 → 对象回收不掉,是泄漏

第一种该做的是扩容、拆分、优化对象大小;第二种才是去 dump 里找谁抓着不放。方向搞反了会很浪费时间。

一个排查顺序

text 复制代码
1. 看报错:JVM 抛的 OOM,还是容器/内核杀的(Killed / 137)
2. 是 OOMKilled  → dmesg / kubectl describe,先看 -Xmx 是不是接近容器 limit
3. 是 JVM OOM    → 看 message,heap / metaspace / direct / thread 各查各的
4. 有 dump       → MAT:Leak Suspects 定方向 → Dominator Tree 找大头 → Path to GC Roots 找持有者
5. 没 dump       → jmap -histo 看谁最多,jstat 看 GC 收不收得动,Arthas vmtool 看实例
6. 判断是泄漏还是容量问题:Full GC 后老年代降不降得下来
7. 定位到持有者 → 回代码找那个没释放的引用

Java heap space和OOMKilled` 是两件事,处理方式完全不一样,不能一上来就去 dump 里翻,很可能方向就是错的。

相关推荐
一帅1 小时前
大象无形:OTel Java Agent 的隐身哲学
后端
dd聊技术1 小时前
给项目接上动态线程池
后端
hsfxuebao1 小时前
常用开源项目github
后端·github
一帅1 小时前
VirtualField:给别人的类"缝口袋"的全过程
后端
小园子的小菜1 小时前
Python 网络编程详解:TCP 与 UDP 原理 + 完整实战示例
后端
后端LV1 小时前
把限流从「注解」做活:RateLimitKeyResolver 四种真实业务的键玩法
java·后端
一帅1 小时前
InDy Advice:用一行 invokedynamic,把 Agent 藏进"平行宇宙"
后端
inhere1 小时前
miglite v0.8.0:迁移文件可以嵌进二进制了
后端