在 Linux 服务器上第一回遇见 OOM(Out of Memory),往往是从一行 "Killed" 开始的——你明明没手动杀进程,Java 服务却凭空消失,SSH 偶尔还能卡到连命令都敲不动。我当年第一次碰到这情况,第一反应是系统被人入侵了,后来翻内核日志才明白,这是 Linux 在内存耗尽时启动的自杀式保护机制。
这篇指南不会停留在"OOM 就是内存不够"这种正确废话上。我会从新手最容易困惑的几个点讲起:OOM 到底怎么触发、日志去哪看、内核为什么专杀"好人"、线上出问题怎么快速定位,最后给出从应急到治本的一整套处置顺序。每个环节都会附带我实际踩过坑之后的判断逻辑,尽量让零基础的人也能照着手动一遍。
1. 从"进程突然消失"说起:OOM 到底是什么
1.1 那句让新手慌乱的 "Killed" 是怎么来的
Linux 内核里有个模块叫 OOM Killer,全称 Out Of Memory Killer。它的职责是:当系统物理内存和 swap 加在一起都不够用,内核又没法再回收出新的内存页时,它会主动挑选一个进程,直接发送 SIGKILL 信号把它杀掉,从而释放内存,保住整个系统还能运转。
这个机制可以类比成宿舍电路跳闸——宿管阿姨没法让所有人都留下,只能挑一个用电大户请出去,其他人才能正常开灯。问题在于,宿管阿姨挑人的标准不一定符合你的预期,她看的是"谁用电多",而不是"谁更重要"。
关键点在于:被杀进程不会收到任何"优雅退出"的机会,SIGKILL 信号也没法被捕获和拦截。所以你在应用日志里往往什么都看不到,只会在系统日志里留下一条类似这样的痕迹:
code复制Out of memory: Killed process 31800 (java) total-vm:2457600kB, anon-rss:1536000kB, file-rss:0kB, shmem-rss:0kB
看到这行日志,基本就可以确认是 OOM 了,跟什么病毒、黑客、数据库崩溃都没关系。
1.2 内存明明没用满,为什么还是 OOM
新手最容易懵的地方在于:我明明跑着 free -h,内存还剩好几 G,怎么系统就 OOM 了?
这里有一个重要的概念区分:Linux 使用是"乐观分配"的内存策略(overcommit)。一个进程调用 malloc() 申请虚拟内存时,内核通常会直接批准,并不立即分配真实的物理内存页。真正物理页的占用发生在进程实际写入数据的那一刻。
所以会出现这种情况:系统里所有进程累计声明的虚拟内存(Committed_AS)已经大大超过物理内存 + swap 的总和,但物理内存表面上还有余量。当某次写入请求需要一个真实物理页,而内核回收了一圈发现实在挤不出来,OOM 就会触发。
打开 /proc/meminfo 能看到两个关键字段:
CommitLimit:内核允许的累计内存提交上限,通常等于swap + 物理内存 × overcommit_ratioCommitted_AS:当前系统已经"承诺"出去的虚拟内存总量
如果 Committed_AS 长期接近甚至超过 CommitLimit,说明系统已经处于随时可能 OOM 的紧绷状态。另外我强烈建议新手养成看 MemAvailable 而不是 MemFree 的习惯。MemFree 只是完全空闲的物理页,而 MemAvailable 还包括可回收的 page cache、slab 等,它才是系统真正"还能再分配多少"的底线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一现场:OOM 日志去哪找、怎么看
2.1 三条日志路径,总有一条能看到
OOM 发生时,内核会在环形缓冲区写下日志记录。查看方式有三种,我按通用程度排序:
bash复制# 方式一:dmesg 查看内核环形缓冲区(推荐,注意需要 root)
sudo dmesg -T | grep -iE "out of memory|oom-killer|killed process"
# 方式二:journalctl 查看本次或上次启动的内核日志
sudo journalctl -k -b | grep -iE "out of memory|oom-killer|killed process"
sudo journalctl -k -b -1 | grep -iE "out of memory|oom-killer|killed process"
# 方式三:查看传统日志文件
# CentOS/RHEL 用 /var/log/messages
sudo grep -iE "out of memory|oom-killer|killed process" /var/log/messages
# Ubuntu/Debian 用 /var/log/kern.log
sudo grep -iE "out of memory|oom-killer|killed process" /var/log/kern.log
journalctl -k -b -1 这条命令我单独强调一下。很多线上服务器 OOM 之后会发生严重卡顿甚至直接重启,等你第二天登录上去,本次启动的内核日志里已经没有案发记录了。用 -b -1 查看上一次启动的内核日志,才能找到真正的元凶。我在处理线上问题时不只一次靠这个参数抢救回关键证据。
2.2 看懂一段真实的 oom_kill 日志
拿一段典型日志来拆解:
code复制[ 251.834567] java invoked oom-killer: gfp_mask=0x14000c0(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[ 251.834768] java cpuset=/ mems_allowed=0
[ 251.845678] Out of memory: Killed process 31800 (java) total-vm:2457600kB, anon-rss:1536000kB, file-rss:0kB, shmem-rss:0kB
第一行里 java invoked oom-killer 的含义要特别注意:它表示是名为 java 的进程触发了内核进入 OOM 处理流程,不代表它一定就是被杀的那个。真正干活的在后面那行 Killed process 31800 (java)。
被杀死进程那几个字段对新手来说也值得花一分钟理解:
total-vm:进程虚拟内存总量,这个数字往往很大,Java 进程轻松几个 Ganon-rss:匿名页占用的物理内存,这是进程真正"私藏"的内存,不能被 page cache 回收file-rss:文件映射页,通常是可执行文件、共享库的映射,可以被回收shmem-rss:tmpfs/shm 所占的内存,这部分往往被很多人忽略
在完整的 OOM 日志里,内核还会打印一张所有进程内存占用清单,列出每个进程的 pid、名字、rss、堆栈等。排查时直接看 Killed process 行,然后反向对照清单里那个 pid,基本就能确认谁才是被内核选中牺牲的对象。
3. 为什么被杀的总是"好人":OOM Killer 的评分与误杀
3.1 内核给每个进程都打了一份"黑名单分数"
OOM Killer 选进程不是随机的,它会给系统里每个进程打分,分高的先杀。这个分数就记录在 /proc/[pid]/oom_score 里,范围从 0 到 1000,数值越高被杀概率越大。
影响分数的主要因素有这些:
- 进程实际占用的物理内存(RSS + swap + 页表大小),占得越多分越高
- 进程的
oom_score_adj调整值,范围 -1000 到 1000,人为干预的入口 - 进程运行时长,内核倾向保留跑得久的进程
- 是否 root 进程,root 进程有微弱的加权保护
有人可能会问:为什么我的 MySQL 服务每次都被杀?答案很简单:它占内存最多。OOM Killer 不管什么业务优先级,它只管"杀一个释放最多、影响最小的"。你眼里的核心数据库,在它看来就是内存大户。
这个过程可以在内核文档里的 oom_badness() 函数实现中看到,它先统计进程的 rss、swap 占用,再除以系统总内存,最后乘上一个基于时间衰减的因子。如果进程的 oom_score_adj 被设为 -1000,它就会直接退出候选名单。
3.2 用 oom_score_adj 保护关键进程
如果你提前知道哪个进程绝对不能被杀,可以手动调整它的分数。比如我通常会在生产环境把数据库进程设为 -1000,让内核完全忽略它:
bash复制# 查询 MySQL 的 pid
pgrep -f mysqld
# 把 MySQL 的 oom_score_adj 设为 -1000,i 表示进程不会被 OOM Killer 选中
echo -1000 > /proc/[pid]/oom_score_adj
反过来,如果有个定时脚本经常内存泄漏,你想让它成为"优先牺牲品",可以给它一个正数:
bash复制echo 500 > /proc/[pid]/oom_score_adj
需要说明的是,即使设了 -1000,也只是让 OOM Killer 跳过它,并不代表系统一定安全。如果被保护进程把内存吃到一点都不剩,内核还有其他兜底手段(比如直接 panic),不会无限容忍。
3.3 容器和 JVM 更容易被误杀
如果你跑的是 Docker 或 Kubernetes,情况会更微妙。容器里的进程不仅受宿主级 OOM Killer 威胁,还会被容器所属 memory cgroup 的 OOM Killer 盯着。cgroup 限制是另一个独立的内存边界:当容器内所有进程(包括 page cache)累计超过容器内存限制时,cgroup OOM Killer 会直接杀掉容器里最"重"的进程,宿主机的 /var/log/messages 里会留下 Memory cgroup out of memory 字样。
JVM(Java 虚拟机)在容器里更容易踩雷。老版本 JVM 默认不感知 cgroup 限制,启动时根据宿主机物理内存自动计算堆大小——在 64G 宿主机上默认堆可能取到 16G,而容器只给 2G 的话,JVM 一启动就可能触发 OOM。这不是代码问题,是 JVM 对容器限制的感知能力落后造成的。JDK 8u191 之后默认开启了 UseContainerSupport,才真正解决了这个问题。
4. 线上 OOM 快速定位:从系统到进程的排查链路
4.1 两分钟系统级体检:free 和 meminfo 的正确读法
线上出现疑似 OOM,第一步不是翻应用日志,而是快速判断系统整体内存水位。我会用一个两分钟的组合命令:
bash复制free -h
cat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvailable|SwapTotal|SwapFree|CommitLimit|Committed_AS"
free -h 的输出里,新手最容易误解的是第一行 used。buffer/cache 那一列内存并不能直接算"被用光",它会在内存压力下被内核回收。真正要盯的是 available 这一列,它才是应用还能分配多少的参考。
第二行命令在定位 OOM 时价值很大。Committed_AS 超过 CommitLimit 意味着系统 overcommit 严重,即使当下 free 指标不难看,也要警惕下一秒可能 OOM。SwapTotal 如果显示 0,说明你根本没配 swap,内存一紧张就直接进入 OOM 流程,没有任何缓冲余地。
4.2 揪出内存大户:ps、top 和 smem 的组合用法
系统级确认有内存压力之后,下一步就是找出哪些进程在大量吃内存:
bash复制# 按内存占用排序,一眼找出前 20 名
ps aux --sort=-%mem | head -20
# 更强的物理内存排序工具,需要安装 smem
smem -tk
# 看单个进程详细内存状态
cat /proc/[pid]/status | grep -E "VmRSS|VmSwap|VmSize|Threads"
top 进入交互界面后按 M 键也会按内存排序,但它默认显示的是 RES(常驻物理内存)。ps aux 里的 %MEM 也是基于 RSS 的,存在一定误差。smem 是我个人更喜欢的一个工具,它会统计进程的 PSS(按共享程度分摊后的物理内存),可以避免多个进程共享同一个动态库时重复计算的问题。
定位到内存大户后再用 cat /proc/[pid]/status 看细节。VmRSS 是它当前占用的物理内存,VmSwap 是它已经换到 swap 里的内存,Threads 显示线程数。如果 VmSwap 数值很大,说明这个进程的内存已经在 swap 和物理内存之间频繁倒腾,系统性能大概率已经崩了。
4.3 Java 进程内存膨胀需要多看两个地方
如果内存大户是 Java 进程,只看系统层面一个维度不够。Java 进程的内存由堆内存、元空间、线程栈、直接内存、JIT 代码缓存等多块构成,其中最容易出问题的是两块:
bash复制# 看 Java 堆当前用量和配置
jmap -heap [pid]
# 看 JVM 里各内存区域实时情况
jstat -gc [pid] 5000 20
jmap 输出的 Heap Configuration 里能看到 MaxHeapSize,如果这个值接近你服务器的物理内存,那就是隐患。jstat 能看到 Eden、Survivor、Old 各区占用以及 Full GC 次数,如果 Old 区持续增长且 Full GC 频繁,通常是内存泄漏的征兆。
需要注意,系统级 OOM 发生时,进程已经被杀,jmap/jstat 全都连接不上了。所以线上 Java 服务最好提前加上 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 在自身报 OutOfMemoryError 时自动保存堆快照,系统级 OOM 虽然不一定能触发 JVM 的 dump,但至少多一条排查线索。
5. 处置 OOM 的正确顺序:应急手段与内核参数里的门道
5.1 应急三板斧:重启、清缓存、补 swap
线上 OOM 刚发生时,优先恢复业务是第一目标。我的处置顺序是这样:
第一步,如果还能 SSH 登录,先杀掉那个确认的内存大户,然后重启被 OOM 误杀的业务进程。注意重启前要留证据,dmesg 日志、pid 状态都要先保存。
第二步,确认不是业务高峰期后,再考虑清理 page cache:
bash复制# 清除 page cache(最温和,业务影响最小)
sync; echo 1 > /proc/sys/vm/drop_caches
# 彻底清理 dentry 和 inode 缓存(影响大,谨慎)
sync; echo 3 > /proc/sys/vm/drop_caches
清理缓存这个动作我在生产环境只建议做一次,而且要确认磁盘读 I/O 能承受后续的缓存重建。有一次我图省事在业务高峰直接 echo 3,结果缓存全部清空后,应用从热数据变冷数据,数据库查询延迟暴增,比 OOM 本身还难受。
第三步,检查系统 swap 是否为零。如果是,可以考虑加一个 swap 文件作为短期缓冲:
bash复制dd if=/dev/zero of=/swapfile bs=1G count=4
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
加 swap 只是给系统争取一点喘息时间,不能替代排查内存泄漏。而且底层是机械盘的话,swap 交换速度极慢,进程卡顿会更明显,这个操作只能算缓兵之计。
5.2 内核参数调优:overcommit、panic_on_oom 与 swappiness
长期来看,几个内核参数值得仔细权衡。第一个是 vm.overcommit_memory:
- 默认值 0:内核"启发式"判断,大多数情况下允许 overcommit
- 设为 2:禁止 overcommit,内存分配严格受
CommitLimit限制 - 设为 1:完全允许,永远不拒绝 malloc 申请
对于内存使用相对固定的服务(比如 MySQL 独占一台机器),我实测过 vm.overcommit_memory=2 可以有效杜绝"虚拟内存疯狂累积导致意外 OOM"的问题,代价是某些依赖大虚拟内存映射的应用可能直接 malloc 失败,需要逐一验证。
第二个参数是 vm.panic_on_oom=1,它会让内核在 OOM 时直接 panic 重启,而不是杀进程。这听起来很极端,但对部分高可用架构反而是首选:与其让某个核心进程被误杀,进入半死不活的状态,不如整体重启,让编排系统重新拉起所有服务。新手不要在生产环境瞎改这个参数,除非你已经确认所有业务都有快速恢复的能力。
第三个是 vm.swappiness,默认 60,值越大内核越积极地把不常用的内存页换到 swap。对数据库类应用,我一般调低到 10 左右,避免 MySQL 的 buffer pool 被内核频繁搬运到 swap,导致性能雪崩。
用 sysctl -w 修改的参数重启后失效,要持久化就写进 /etc/sysctl.conf 再执行 sysctl -p。
5.3 用 systemd 或 cgroup 给进程套上"内存笼子"
比调内核参数更精细的做法,是给单个服务设置内存上限。如果你用的是 systemd 启动服务,可以直接在 service 文件里加:
ini复制[Service]
MemoryMax=2G
MemoryHigh=1.5G
MemorySwapMax=1G
MemoryHigh 是第一道软限制,超过后内核会尽量回收该服务的内存,但不一定立即处理;MemoryMax 是硬限制,超过后 cgroup OOM Killer 会介入杀进程。这相当于给每个服务绑了一个独立的"内存银行卡",一个服务吃爆了,不至于拖着整个宿主机陪葬。
如果不用 systemd,也可以直接操作 cgroup。cgroup v2 的路径是 /sys/fs/cgroup/,创建子目录然后写入 memory.max,手动把进程 pid 加进去,同样能实现限额。不过这套手工流程对新手并不友好,我建议优先用 systemd 的声明式配置。
这个方案的缺点是:进程被杀后不会自动重启,需要配合服务本身的守护机制。所以 MemoryMax 不是设得越小越好,要基于实测内存峰值留出 20% 余量。
5.4 从应用侧压低内存峰值
搞完系统和内核,最后还得回到应用本身。内存问题如果是应用造成,参数调整只是延长发作周期,而不是根治。
针对 JVM,最常用的是合理设置堆大小。我见过太多人直接 -Xmx4G,却忘了 JVM 除了堆还有元空间、线程栈、直接内存。一个 4G 堆的 Java 进程,整体物理内存占用到 5G 是很正常的事。多线程高并发场景下,线程栈的总和也很可观——每个线程默认栈大小 1M,300 个线程就是 300M。
真正的做法是整体规划:先确定服务器总内存,给操作系统和文件缓存留足余量,再倒推应用可用内存。比如 8G 的服务器,分配给单个 Java 服务的物理内存最好不超过 5G,-Xmx 就设 3G 左右,另外预留元空间和线程栈的空间。
容器环境下更要时刻记住 JVM 的容器感知问题,JDK 8u191 以上的版本可以放心用:
bash复制-XX:+UseContainerSupport -XX:MaxRAMPercentage=75
对 MySQL 这类数据库,innodb_buffer_pool_size 是最大的内存口子,别按"物理内存越大越好"的思路设置。我见过一个 8G 服务器给 MySQL 分配 6G buffer pool,再来一个 Java 服务,把系统内存直接逼到死角。通常给操作系统和文件系统留出物理内存的 20% 以上,buffer pool 设置在物理内存的 50%~60% 是个安全区间。
6. 实战中容易踩的几个坑:碎片、容器边界与 JVM 的傲慢
6.1 一切正常却偶发 OOM?先看内存碎片和内核 slab
有段时间我遇到一个诡异场景:free 显示可用内存还有 3G 多,但系统照样报 OOM。后来发现问题是内核 slab 层占用了大量内存,主要是目录项缓存和 inode 缓存累积,这部分既不算进程的 RSS,也不能被常规的 page cache 回收机制立刻释放。
排查命令:
bash复制# 查看 slab 缓存占用
cat /proc/slabinfo | awk '{sum += $3 * $4} END {print sum / 1024 / 1024 " MB"}'
# 看看是哪些 slab 对象占大头
cat /proc/slabinfo | sort -k 3 -rn | head -20
如果确认是 dentry/inode 缓存堆积,用 echo 2 > /proc/sys/vm/drop_caches 清理。但更值得关注的是为什么堆积得这么快——通常是某个应用大量创建临时文件、频繁访问海量小文件,或者日志系统没做轮转。不解决根源,清完很快又会涨回来。
还有一个相关参数是 vm.min_free_kbytes,它保留一定量的空闲内存给内核紧急分配。有人觉得把它调高能让系统更安全,但调太高反而会减少可分配内存,造成"还没用完就 OOM"的假象。默认值在大多数场景下是合理的,不建议轻易动。
6.2 Docker 容器里的"假 OOM"
容器环境下有一种特殊情况:你在宿主机 free -h 看内存非常充足,但容器里的应用却报 OOM,或者进程被杀。这是 cgroup 限制在起作用,跟宿主机本身的物理内存是否耗尽无关。
排查方法:
bash复制# 查看容器实时内存占用
docker stats
# 查看容器 cgroup 内存限制(cgroup v1 路径)
cat /sys/fs/cgroup/memory/docker/[容器ID]/memory.limit_in_bytes
# cgroup v2 路径
cat /sys/fs/cgroup/docker/[容器ID]/memory.max
容器内吃的"内存"不只是应用进程本身,还包括它产生的 page cache。很多应用在容器里大量读写文件,page cache 会计入容器 cgroup 的用量,造成"应用明明只有 800M,限制 1G 还是被杀"的情况。这类问题要从两个方向解决:合理调高容器内存限制,或者在应用侧减少文件缓存依赖(比如 MySQL 的 innodb buffer pool 就不要和容器限制打架)。
6.3 JVM 不识别容器限制的经典翻车现场
最后讲一个我实际处理过的案例,完全符合"JVM 的傲慢"这个形容。
事情是这样的,有一套系统从物理机迁移到 Docker 容器,宿主机是 64G 物理内存,容器只分配了 2G。服务用的是老 JDK 8u144,这个版本还没有默认开启容器感知特性。JVM 启动后,自动检测到宿主机 64G 内存,于是把默认最大堆顶到了 16G(物理内存的 1/4)。结果容器 cgroup 限制 2G,JVM 一启动就把内存吃满,然后被 cgroup OOM Killer 杀掉,反反复复,应用完全起不来。
当时的修复也很简单,升级 JDK 到 8u191 以上版本,JVM 就能识别容器限制,并使用 -XX:MaxRAMPercentage=75 控制堆大小。这类问题最坑的地方在于:从应用日志看,服务什么都没干,进程就没了,新手很容易往代码方向排查,浪费大量时间。
所以要提醒手里还挂着老 JDK 的团队,迁移容器之前先确认版本和 JVM 参数,不要等线上出了 OOM 再研究。
最后再分享一个我自己的习惯:每台生产服务器上,我都会写个小脚本定期抓 MemAvailable、Committed_AS、SwapUsed 这几个指标,输出到监控系统。OOM 从来不是瞬间的事,它前面一定有一段内存水位持续走高的过程,只是很多人没留监控,只能等爆了才复盘。早一步看到曲线,很多故障根本轮不到 OOM Killer 出场。另外,给任何核心服务写 systemd unit 的时候,我顺手就会在启动脚本里加一句 echo -1000 > /proc/self/oom_score_adj,成本几乎为零,但能在关键时刻保住最重要的进程。这些细节,比临时学一百条排查命令更管用。
