线上服务挂了,日志里翻到一行 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 里翻,很可能方向就是错的。