物理内存耗尽(OOM)排查实战记录
作为一个经常跟 Linux 服务器打交道的开发者,我最怕的就是半夜被电话叫醒,说业务挂了。而业务挂掉的大部分原因,排在第一位的往往不是代码 bug,而是内存问题。尤其是那种内存缓慢增长,一直到某个临界点突然 OOM 的情况,排查起来极其折磨人——进程在,服务没报错,日志里只有一行冰冷的 "Out of memory: Killed process"。
这一篇笔记,就是我踩了无数坑之后整理出来的内存排查思路和实操记录。不聊虚的,全是命令、参数、思路和真实场景。不管你是刚入门 Linux 的运维小白,还是写后端服务的开发同学,只要你需要跟服务器内存打交道,这篇内容都能帮你少走几周弯路。
1. 从一次线上 OOM 开始说起
事情是这样的。某天下午,一个内部业务系统突然出现大量超时报警,登录服务器一看,dmesg里躺着一行致命日志:
[892341.221045] Out of memory: Killed process 22341 (java) total-vm:8423012kB, anon-rss:6120332kB, file-rss:0kB, shmem-rss:0kB当时的第一反应是 Java 堆内存配置太大了?马上看了下启动参数,-Xmx4g,配置本身没问题。然后 free -h 一看,有点不对劲:
total used free shared buff/cache available Mem: 15G 11G 1.2G 1.8G 3.5G 3.2G内存确实快没了,但总量 15G,Java 堆才 4G,还有 7G 被谁吃掉了?也就是说,真正的问题根本不在于 Java 堆配置,而是有什么东西在疯狂占用堆外内存。后来逐步排查发现,是在一个第三方 SDK 里,底层用 JNI 调用了本地方法,每次请求都分配一块 C 层的内存缓冲区,用完之后没有及时释放,日积月累,堆外内存越涨越高,最终把整台机器的内存耗尽了。
这个案例特别典型,因为它反映了一个非常常见的认知误区:说到内存问题,很多人的第一反应就是堆内存、垃圾回收,实际上服务器内存是一个整体,除了堆,还有堆外、内核、页缓存、共享内存等各种区域。如果你只盯着某一层去查,往往会陷入死胡同。
所以,这篇文章我不会只讲某一个工具或某一条命令,而是把我自己在排查内存问题时的一套完整思路、工具组合和判断方法分享出来,尽量还原真实场景。
1.1 内存问题为什么这么难查
内存问题难,主要有三个原因。
第一,现象有滞后性。CPU 问题往往是一个请求突然变慢、一个线程突然飙高,能被立刻感知到。但内存泄漏是缓慢的,今天内存用量涨了 2%,明天涨了 1.5%,你要是不盯着监控曲线,很难察觉到异常。等到 OOM 发生的时候,现场已经被破坏了一大半——进程被杀、日志可能没来得及落盘、缓存被清空,很多线索都没了。
第二,内存的构成太复杂。一台 Linux 服务器的内存,由进程的用户态内存、内核内存、页缓存、临时文件(tmpfs)、共享内存等组成。每个进程内部又分堆、栈、代码段、数据段、各种映射区域。你用 free 看到的是整体,用 top 看到的是进程级,用 jstat 看到的是 JVM 级,每一层看到的东西都不一样。如果没有一个整体框架,很容易被某一层的数据误导。
第三,工具输出容易让人误判。举个例子,top 里的 RES 列,很多人以为这就是进程真正使用的物理内存,实际上 RES 包含了共享库、页缓存映射等,不完全等于"这个进程独占的物理内存"。同样,free 里的 buff/cache 看起来"被用了",实际上它是可以回收的,真正需要关注的是 available。
1.2 一个可复用的排查框架
我自己在实战中,把内存排查拆成四个阶段:
- 现象确认:确认是不是真的内存不足,判断是系统级还是进程级问题。
- 全局定位:看整机的内存分布,搞清楚内存都被谁消耗了。
- 进程深挖:锁定嫌疑进程,深入分析它内部的内存结构。
- 根因定位:结合代码、日志、分配器行为,找到最终原因并验证。
这四个阶段不是绝对的,有时需要反复横跳。比如你深挖进程时发现这个进程内存正常,但整机内存还是不够,那就得回到全局重新分析。下面我按这个框架,把每个阶段用到的命令、判断方法和注意事项,一个个说清楚。
2. 现象确认阶段:别急着背锅
很多人一上来就free -h+top,然后就开始猜。我的建议是,先花两分钟把现象弄清楚,尤其是把 OOM 日志、监控曲线、最近变更这三样东西凑齐。
2.1 第一步:查 OOM 日志
Linux 的 OOM Killer 在杀进程时会打印详细的日志,这些日志藏在dmesg或者/var/log/messages里。用下面的命令快速定位:
dmesg -T | grep -i "out of memory" | tail -20如果dmesg需要 root 权限看不到,可以试试:
journalctl -k --since "2024-01-01 00:00:00" | grep -i "out of memory"拿到日志后,重点看两样东西:一是被杀的进程是谁,二是当时系统的内存压力指标。注意,OOM 日志里经常出现被杀进程看起来不是内存占用最大者的情况,这是因为 OOM Killer 选择目标时会综合考虑进程的占用、运行时长、优先级(oom_score_adj)等,不一定是占用最高的那个,这个细节后面会展开。
2.2 第二步:看监控曲线
如果没有监控系统,至少也得把/var/log/sysstat/里的 sar 历史数据翻出来看看:
sar -r -f /var/log/sysstat/sa$(date +%d) | more重点看内存使用趋势是平稳的还是稳步上升的。如果是锯齿状的一升一降,大概率是正常业务波动;如果是持续上升、而且每次上升的底部都在抬高,那基本就是内存泄漏的形状。
还有一种情况是"陡增型",内存瞬间被打满,这往往是流量突增、缓存被大量填充、或者出现了类似一次性加载大量数据到内存的操作。
2.3 第三步:确认最近变更
排查内存问题有一条血泪教训:先问变更,再查代码。很多内存问题并不是逐渐积累的,而是某次发布、某个配置调整之后突然出现的。比如线程池调大、连接池参数改了一个数字、上线了新功能,都可能让内存水位明显变化。
我的习惯是,把以下信息列成一个清单:
- 最近一次发布是什么时候,代码变更涉及哪些模块
- 是否有配置变更,比如 JVM 参数、内核参数(vm.overcommit_memory、vm.swappiness)
- 是否有流量变化,比如大促、爬虫高峰期、新渠道接入
这三样信息凑齐之后,再进入下一阶段。
3. 全局定位阶段:看清整机内存地图
这个阶段的目标,是回答一个问题:16G 的机器,内存到底被谁拿走了?
3.1 free 命令的正确读法
先纠正一个常见误区。很多人看 free 只看 used 一列,发现 used 很高就慌了。其实 free 命令最有价值的参数是free -h,而且重点要看 available 这一列:
total used free shared buff/cache available Mem: 15G 11G 1.2G 1.8G 3.5G 3.2Gavailable 是"在不触发 swap 的情况下,预计可以分配给新进程的内存大小"。它已经扣除了内核估算的不可回收部分,同时也把可以回收的 page cache 计算在内了。如果 available 长期很低,比如只有几百 MB,即使 free 显示还有 1G,也应该警惕。
再解释一下 buff/cache。这部分内存是 Linux 用空闲内存来缓存磁盘数据、加速 IO 的,属于"用掉但可以吐出来"的内存。正常情况下不用管它,但如果你的应用对 IO 有极致要求,或者机器本身内存就小,可以适当调整vm.dirty_ratio、vm.dirty_background_ratio等参数,但千万不要在没搞清楚问题之前就去 echo 3 > /proc/sys/vm/drop_caches,这只能让下次 IO 变慢,解决不了内存泄漏。
3.2 用 /proc/meminfo 查看内核视角
free 命令的数据其实来自 /proc/meminfo,想看更细的信息可以直接看这个文件:
cat /proc/meminfo | head -30重点关注几个指标:
- MemTotal、MemFree、MemAvailable
- Buffers、Cached、SwapCached
- AnonPages:匿名页,通常是进程的堆、栈、匿名 mmap,这是内存的大头
- Mapped:文件映射到内存的大小
- Slab、SReclaimable、SUnreclaim:内核对象占用的内存,SUnreclaim 如果异常增长,往往是内核态内存泄漏的信号
- Shmem:共享内存,包括 tmpfs 文件占用的内存
有一个场景我印象特别深:某次排查发现 free 显示的 used 不高,但系统总是频繁 OOM,后来发现 tmpfs 挂载目录下有人往里面写了大量临时文件,Shmem 占到 4G 多,而这些内存无法被回收,最终导致了内存耗尽。这种问题用 top 是看不出来的,必须查 /proc/meminfo。
3.3 用 ps 快速揪出内存大头
拿到整体视图后,下一步就是看哪些进程占用最多:
ps aux --sort=-%mem | head -20或者更精确一点,直接看 RSS(Resident Set Size,常驻内存):
ps -eo pid,ppid,rss,vsz,comm --sort=-rss | head -20这个命令的输出里,RSS 单位是 KB,看的时候心里要换算一下。如果头部的几个进程加起来离整机内存还有很大距离,说明内存消耗在进程之外(比如 Slab、Shmem、内核占用),这时候就别在进程列表里继续浪费时间了,回到 /proc/meminfo 去找线索。
4. 进程深挖阶段:揪出进程里的内存黑洞
如果已经锁定某个进程,接下来要弄清楚这个进程内部的内存是怎么分布的。不同语言写的程序,分析方法完全不同。我以最常见的 Java 服务、C/C++ 服务和 Python 服务为例分别讲。
4.1 看进程内存布局:/proc/PID/status 和 smaps
先看进程的整体情况:
cat /proc/12345/status | grep -E "VmPeak|VmSize|VmRSS|VmData|VmStk|VmExe|VmLib"几个指标的含义:
- VmSize:进程虚拟内存大小
- VmRSS:常驻物理内存大小
- VmData:堆和匿名映射占用的虚拟内存
- VmStk:栈大小
- VmLib:共享库占用的虚拟内存
这里有个很重要的经验:如果 VmSize 很大但 VmRSS 很小,说明进程只是虚拟内存分配得多,实际上没有真正占用物理内存,这种情况一般不需要太紧张。真正危险的是 VmRSS 持续走高。
如果要看更细粒度的内存分布,可以看 smaps:
grep -E "Rss:|Pss:|Size:" /proc/12345/smaps | head -60smaps 里每一段映射都有详细信息。PSS(Proportional Set Size)是个好东西,它把共享库占用的内存按比例分摊到每个进程上,比 RSS 更能反映"这个进程真正分摊了多少物理内存"。
4.2 Java 进程:别只看堆
Java 服务的内存排查有个经典误区:只盯着 Xmx 和堆内存。实际上,一个 Java 进程的完整内存 = 堆内存 + 线程栈 + Metaspace + 直接内存(Direct Buffer)+ JIT 编译器代码缓存 + JNI/本地内存 + GC 数据结构 + 各种 native 库。
我的排查步骤是:
第一步,看 JVM 堆内使用情况:
jstat -gcutil 12345 1000 10重点关注 E(Eden)、O(Old)、M(Metaspace),如果 Old 区持续占满且 FGC 频繁,说明堆内有问题;如果堆内一切正常但进程 RSS 还是很高,问题大概率在堆外。
第二步,看堆外内存的大头——线程栈和 Direct Buffer:
jcmd 12345 Thread.print | grep "java.lang.Thread.State" | wc -l jcmd 12345 VM.native_memory summaryVM.native_memory summary输出里会有 Java Heap、Thread、Code Cache、GC、Compiler、Internal、Symbol 等各个区域的 native 内存占用,方便定位是不是线程数异常,或者某个 native 区域在疯涨。注意这个命令需要启动参数里加上-XX:NativeMemoryTracking=summary,否则输出有限。
第三步,如果怀疑是 Direct Buffer,可以手动触发几次 GC 然后观察进程 RSS 是否下降:
jcmd 12345 GC.run如果 GC 之后 RSS 明显下降,说明有堆外内存中可以被 GC 回收的部分在作祟;如果 GC 后 RSS 纹丝不动,那大概率是纯 native 泄漏,GC 帮不上忙。
4.3 C/C++ 程序:用 pmap 和 malloc 调试点
C/C++ 程序的内存问题相对更直接一些,pmap 可以看进程的内存映射:
pmap -x 12345 | tail -30关注几类映射:heap(堆)、anon(匿名映射)、libs(共享库)。如果 anon 区域很大且持续增长,说明有动态分配的内存在积累。
另外,编译期加上 AddressSanitizer 或者 Valgrind 可以帮助定位具体是哪一行代码泄漏的:
valgrind --leak-check=full --show-leak-kinds=all ./your_program不过 Valgrind 会让程序慢上几十倍,生产环境不好直接跑,通常是在测试环境复现问题后再用。
4.4 Python 进程:tracemalloc 和 objgraph
Python 的内存泄漏排查,最常用的就是内置的 tracemalloc 模块:
import tracemalloc tracemalloc.start() # 业务代码跑一段时间后快照对比 snapshot1 = tracemalloc.take_snapshot() # 运行一段时间... snapshot2 = tracemalloc.take_snapshot() top_stats = snapshot2.compare_to(snapshot1, 'lineno') for stat in top_stats[:10]: print(stat)它能定位到泄漏对象是哪个文件哪一行代码创建的,排查业务代码非常有效。如果泄漏是发生在 C 扩展层,tracemalloc 就无能为力了,这时候可以借助 objgraph 分析对象引用关系,看看是哪些对象互相持有引用导致无法被回收。
5. 根因定位:从 "内存高" 到 "谁导致的"
前面几层分析可以定位到"进程的哪个区域内存高",但还不够,必须定位到"哪一行代码、哪一个逻辑导致的高"。这一阶段最依赖经验,也是最难的部分。
5.1 常见根因模式
根据我自己的经验,内存泄漏和高内存占用大多逃不出这几类:
- 集合类未清理:全局 Map、List 不断添加元素,尤其是用静态集合做缓存又没设上限的。
- 连接和会话对象未关闭:数据库连接、网络连接、文件流用完没关,底层对应的缓冲区内存一直挂着。
- 本地缓存无限增长:各种 Guava Cache、Caffeine Cache 如果没设置 maximumSize 和过期策略,就会一直膨胀。
- 线程数量异常:线程栈默认 1MB,1000 个线程就是 1G 虚拟内存,再加上每个线程的内部缓冲区,数量失控立刻反映到 RSS 上。
- 第三方 SDK 的 native 泄漏:回顾开头提到的 JNI 场景,进程 RSS 高但堆内、线程数都正常时,要想想是不是有 native 库在背景里分配内存。
- JVM 元空间泄漏:动态创建类、频繁生成代理类,Metaspace 会不断增长。
5.2 一个真实案例:缓存导致的内存膨胀
某次排查一个报表服务,发现 RSS 从 2G 逐步涨到 6G。堆内存 jstat 看下来,Old 区一直 60% 上下,FGC 也不算频繁,显然问题不在堆内。于是用jcmd 12345 GC.class_histogram看对象分布,发现有个com.xxx.ReportCacheNode对象数量异常多,有几十万个。
再配合jmap -histo:live 12345 | head -30,确认就是它。最后定位到代码里用了一个静态HashMap做报表结果缓存,每次查询结果都不一样,导致 key 永远匹配不上,旧数据永远无法淘汰。修复方案就是引入 Caffeine Cache,设置 maximumSize 和 expireAfterWrite,内存立刻稳住了。
5.3 内核态内存泄漏的排查
如果进程层排查完都没问题,整机内存还是被吃,要考虑内核态的问题。SUnreclaim(不可回收的 Slab)持续增长是一个典型信号:
cat /proc/meminfo | grep -E "Slab|SReclaimable|SUnreclaim"如果 SUnreclaim 持续增长,可以用 slabtop 看看是哪类内核对象在膨胀:
slabtop -o | head -20常见的可疑对象包括:dentry(文件目录项缓存)、inode_cache、filp(打开的文件对象)、kmalloc-*(内核小对象分配)。dentry 和 inode 异常增长,通常和某个目录下文件数量过多或频繁创建删除文件有关;kmalloc 对象无故增长,则要警惕内核模块或者驱动泄漏。遇到这类问题,通常需要结合内核版本的已知 bug 列表来排查,必要时升级内核。
6. 常用工具清单与核心参数速查
工具多而不精等于没有工具。我在实际工作中长期使用的就是下面这张表,按场景分类,可以直接抄作业。
6.1 系统级工具
| 工具 | 用途 | 关键参数/命令 |
|---|---|---|
| free | 查看整机内存 | free -h,重点看 available |
| top/htop | 动态查看进程内存与 CPU | top -o %MEM,按内存排序 |
| vmstat | 查看内存、swap、IO 的整体状态 | vmstat 1 5,关注 si/so |
| sar | 历史内存记录 | sar -r -f 某天的sa文件 |
| /proc/meminfo | 内核视角的内存分布 | 关注 AnonPages、Slab、Shmem |
| dmesg | 查看 OOM 日志 | dmesg -T | grep -i "out of memory" |
6.2 进程级工具
| 工具 | 用途 | 关键参数/命令 |
|---|---|---|
| ps | 进程占用排序 | ps aux --sort=-%mem |
| pmap | 进程内存映射细节 | pmap -x PID |
| /proc/PID/smaps | 每一段映射的内存明细 | 查看 Pss、Rss |
| jstat | JVM 堆内统计 | jstat -gcutil PID 1000 |
| jmap | JVM 堆转储与对象统计 | jmap -histo:live PID |
| jcmd | JVM 综合诊断 | jcmd PID VM.native_memory summary |
| valgrind | C/C++ 内存检测 | valgrind --leak-check=full |
| tracemalloc | Python 内存追踪 | 内置模块,快照对比 |
| slabtop | 内核 Slab 对象 | slabtop -o |
6.3 swap 参数:什么时候该改
vm.swappiness控制内核优先使用 swap 还是回收页缓存的倾向,默认通常是 60。对于延迟敏感的服务,我一般会调到 1~10,让内核尽量少用 swap;对于批处理任务,反而不用刻意调低,因为偶尔的 swap 能缓解瞬时内存压力。
修改方式:
sysctl -w vm.swappiness=10 echo "vm.swappiness = 10" >> /etc/sysctl.conf注意,swappiness 调到 0 不代表完全不用 swap,在紧急内存压力下内核仍可能 swap 出去。真正想完全禁用 swap 要在启动参数里设置swapaccount=1或修改 grub 参数,但这个操作会影响系统行为,不建议在生产环境随意搞。
另一个关键参数是vm.overcommit_memory。默认 0 表示内核做启发式过度分配;如果改成 1,就允许进程申请超过物理内存的虚拟内存,这会让 OOM 的风险大幅上升;改成 2 则严格执行 overcommit 限制。除非你明确知道自己在做什么,否则不要动这个参数。我之前遇到过有人把 overcommit 改成 1 之后,Java 进程虚拟内存申请不受限,实际物理内存不够时 OOM 频率翻倍,最后又改回来了。
7. 一次完整排查的演示:从日志到结论
为了让你把前面所有的工具串联起来,我用一个虚构但非常典型的场景,把完整流程走一遍。
7.1 场景描述
假设你是一个中间件平台的负责人,某个网关服务最近几天频繁出现告警:响应超时率上升,部分节点 OOM。服务是 Java 写的,部署在 8C16G 的容器里,JVM 参数是-Xmx4g -Xms4g -XX:+UseG1GC。
7.2 阶段一:现象确认
登录一台还在运行的节点,先看 dmesg:
dmesg -T | grep -i "out of memory" | tail -5没有 OOM 记录,说明容器层面可能已经把进程 kill 了而宿主机的 dmesg 看不到,需要看容器侧日志。再看监控曲线,发现这个服务的内存在每天凌晨 3 点到 4 点之间开始爬坡,早上 8 点回落到正常水位。但最近一周,回落的底部越来越高,从 3G 涨到 5G。
7.3 阶段二:全局定位
free -h输出显示:total 16G,used 13G,available 只有 1.6G。再看看 /proc/meminfo,发现 AnonPages 有 10G,Shmem 只有几百 M,Slab 也正常。基本可以确认:内存大头在进程的匿名页上,跟内核缓存无关。
7.4 阶段三:进程深挖
用 ps 排序,看到最前面的是一个 Java 进程 RSS 6G,另一个 Java 进程 RSS 4.5G。于是把嫌疑集中在占用最高的网关进程上。
进入进程内部,用 jstat 看堆:
jstat -gcutil 12345 1000 5输出显示 Old 区使用率 70%,FGC 每分钟 2 次,但 RSS 还在增长。再用 jcmd 看 native memory:
jcmd 12345 VM.native_memory summary结果中 Thread 区域占用 1.5G,说明线程数量非常多。用jcmd 12345 Thread.print | grep "java.lang.Thread.State" | wc -l数了下,居然有 1800 个线程,而合理值应该在 400 左右。
7.5 阶段四:根因定位
线程数量异常,马上查代码,发现某个 HTTP 调用没有设置连接超时和读超时,部分上游服务响应变慢时,请求线程全部阻塞在 IO 上,线程池不断创建新线程,每个新线程 1MB 栈加上内部缓冲区,内存立刻飙升。修复方案是给 HttpClient 设置连接超时和读超时,并给线程池设置一个合理上限和拒绝策略。上线后内存曲线很快就平了。
这个案例想说明的是:内存问题往往是另一个问题的"果"。线程数飙升是果,IO 阻塞才是因。所以当内存异常时,不要只盯着内存,要顺着调用链路把所有不正常的东西串起来看。
8. 这几个月踩过的坑和值得记住的经验
最后集中写一些我踩过坑之后最想告诉你的经验,这些内容在很多书和文档里不太会写。
8.1 生产环境千万别乱执行这 3 个命令
- kill -9 一个你还没搞清楚的进程:除非你想快速止血,否则在 dump 或收集完现场信息之前,不要轻易 kill。进程一没,很多证据就没了。
- drop_caches 清缓存:
echo 3 > /proc/sys/vm/drop_caches虽然能清理 page cache,但它只是让你"感觉"内存变多了,并不能定位根因,反而可能让下一次 IO 变慢。 - 生产环境直接开 NMT(Native Memory Tracking):
-XX:NativeMemoryTracking=summary本身会带来 5%~10% 的性能开销,如果是在高并发大流量环境,要评估好再开,最好先在压测环境验证。
8.2 观察内存不要只看一个点
内存问题是需要"趋势视角"的。单次 top 只能告诉你现在有多紧张,但无法告诉你它是一路涨上来的,还是刚刚才涨的。所以我强烈建议在服务器上常驻一套监控,哪怕只是最简单的sar -r定时采集,配合一个脚本每 5 分钟把/proc/meminfo和进程 RSS Top10 追加到日志文件里,等出问题的时候有数据可看。
8.3 堆内堆外要联合看
Java 服务的内存问题,90% 不是简单的-Xmx调大调小能解决的。堆内满了,可能是对象太多;堆外长了,可能是线程、DirectBuffer、native 库。我见过太多人一遇到 Java OOM 就调大 Xmx,结果把整机内存调爆。正确做法是先搞清楚到底是堆内还是堆外,再对症下药。
8.4 把 OOM 变成可观测的
工具再强,也不如提前预防。我给自己定了几条硬规矩:
- 所有 Java 服务启动参数里都加上
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/data/logs/heapdump.hprof,确保 OOM 时能留下堆转储。 - 容器启动时给 JVM 预留足够的非堆内存空间,计算公式通常是最小堆 + 线程数×1MB + Metaspace + 直接内存 + 200MB 余量。
- 监控上设置内存水位的分级告警,比如使用率超过 70% 时 P3 提醒,超过 85% 时 P1 告警,避免每次都是 OOM 了才被机器"通知"。
8.5 最后一句话
内存排查这行当,最值钱的不是命令记得有多熟,而是脑子里有没有一张内存地图。先从 /proc/meminfo 建立全局认知,再一层层往下钻,用工具验证假设,最后在代码里找根因。这套动作多练几次,你会发现自己越来越能从一串冷冰冰的数字里读出真正的问题。
最后再分享一个小技巧:排查内存问题时,记得把每次排查的命令、输出和结论记下来,哪怕是简单地贴在笔记本里。时间久了,你会建立起自己的"内存问题模式库",下次遇到类似现象时,直接就能从历史案例里找到方向。这比任何工具都管用。