2024 年的一个早上,我到公司,运维群里有人问:那个订单服务昨晚是不是挂过?我一查,果然——我的 Java 服务在凌晨 3 点多没了,进程不在了,然后被守护脚本在 3 点零几分又拉了起来。我第一反应是程序自己崩了,于是去翻日志:应用日志,最后一行停在 3 点零一分,一条普普通通的业务日志,后面什么都没有,没有异常、没有堆栈、没有 OOM 的报错;我又去看 nohup.out、看 GC 日志,统统正常,干干净净,看不出任何"它要死了"的征兆。这就很诡异了:一个程序要是自己崩,总会留下点东西——一个段错误、一个未捕获异常、一句 JVM 自己打的 OutOfMemoryError。可我这个进程,像是被人从背后一枪打死的,自己连哼都没来得及哼一声。我盯着这个"干净的死亡"想了很久,最后才反应过来:它不是自杀,是他杀。杀它的,不是它自己的代码,而是 Linux 内核——当整台机器的内存被耗尽时,内核会启动一个叫 OOM Killer 的机制,从所有进程里挑一个出来杀掉,好让系统自己活下去。我的进程,就是那个被挑中的。这件事逼着我把 Linux 的内存管理、OOM Killer、oom_score、内存 overcommit 这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,8G 内存的服务器,上面跑着多个 Java 服务
事故现象:
- 订单服务进程凌晨 3 点多突然消失,被守护脚本重新拉起
- ★ 应用日志最后一行停在 3:01,一条正常业务日志,之后什么都没有
- 没有异常堆栈、没有 OutOfMemoryError、没有 core dump
- 像是被"干净利落"地杀掉了
现场排查:
# 1. 看服务状态,systemd 管理的话能看到死因
$ systemctl status order.service
Active: active (running) since ... # 已被守护拉起
... order.service: Main process exited, code=killed, signal=KILL
# ^^^^^^^^^^^^^^^^^^^^^^^ ★ 被 KILL 信号杀的
# 2. ★ 翻内核日志 —— OOM 的证据在这里
$ dmesg -T | grep -i 'killed process'
[Wed Apr 10 03:02:11 2024] Out of memory: Killed process 9802 (java)
total-vm:7400000kB, anon-rss:5100000kB, file-rss:0kB
# ^^^^^^^^^^^^^ ★ 内核明说了:内存耗尽,杀了 9802 这个 java 进程
# 3. ★ 看完整的 OOM 现场:杀之前内核打的"账单"
$ dmesg -T | grep -A20 'invoked oom-killer'
... java invoked oom-killer: gfp_mask=..., order=0, oom_score_adj=0
... Tasks state (memory values in pages):
... pid rss name
... 9802 1275000 java # ★ 这个 java 占了最多内存
... 9805 480000 java
... 3201 21000 mysqld
... Out of memory: Killed process 9802 (java) ...
根因(后来想清楚的):
1. ★ 我的进程不是自己崩的,是被内核的 OOM Killer 杀的。
它收到的是 SIGKILL(信号 9)—— 这个信号【无法被
捕获、无法被处理】,所以进程没机会打任何日志、
没机会留遗言,就是那种"干净的死亡"。
2. 这台 8G 的机器上挤了好几个 Java 服务,每个 -Xmx
都开得不小,加起来【远超物理内存】。
3. ★ 凌晨有个批量任务,内存需求突然涨上去,整台机器
的内存被吃干,内核没有内存可分配了。
4. 内核为了自救,启动 OOM Killer,按一套评分规则
挑出"最该死"的进程 —— 我那个 rss 最大的 java
进程,分最高,于是被选中。
5. ★ 它死在应用日志里"没有任何征兆",正是因为
凶手是内核、用的是 SIGKILL —— 答案不在应用日志里,
在【内核日志 dmesg】里。
进程"无声消失" = 被 OOM Killer 用 SIGKILL 杀了,看 dmesg。
修复 1:进程"无声消失",第一时间看 dmesg
# === ★ 纠正第一个误区:不是所有"进程没了"都是它自己崩的 ===
# === 我以为的 vs 真实的 ===
# 我以为:进程不在了,一定是程序自己出了问题 ——
# 崩溃、异常、退出。证据应该在【应用日志】里。
# ★ 真相:进程消失有两大类原因 ——
# (A) 它自己退出/崩溃 -> 证据在应用日志、core dump。
# (B) ★ 它被别人杀了 -> 证据【不在】应用日志里,
# 在内核日志、在杀它那个人那里。
# OOM Killer 杀进程,就是典型的 (B)。
# === ★ 为什么 OOM 杀的进程"一句遗言都没有" ===
# OOM Killer 杀进程,用的是 SIGKILL(信号 9)。
# ★ SIGKILL 这个信号的特殊之处:它【不能被捕获、
# 不能被忽略、不能被处理】。进程收到它,内核
# 直接把进程干掉,不给它任何执行代码的机会 ——
# 所以它【打不出任何日志】,连"我要死了"都说不出。
# 这就是为什么应用日志里干干净净、毫无征兆。
# === 第一步:看 systemd 记录的死因 ===
$ systemctl status order.service
... Main process exited, code=killed, signal=KILL
# ★ signal=KILL = 被 SIGKILL 杀的,极可能是 OOM。
$ journalctl -u order.service --since '03:00' --until '03:10'
# systemd 系统里,journalctl 也常能直接看到 OOM 记录。
# === ★ 第二步:dmesg 找 OOM 的铁证 ===
$ dmesg -T | grep -i 'out of memory'
$ dmesg -T | grep -i 'killed process'
[Wed Apr 10 03:02:11 2024] Out of memory: Killed process 9802 (java)
# ★ -T 把内核时间戳转成人能读的时间。
# 只要有 "Out of memory: Killed process",
# 就实锤了:这是一次 OOM 事件。
# === 第三步:看历史(机器重启过就用 journalctl)===
$ journalctl -k | grep -i 'out of memory'
$ journalctl -k --since '2024-04-10' | grep -i oom
# ★ dmesg 的缓冲区会被冲掉,journalctl -k 看的是
# 持久化的内核日志,翻历史 OOM 更可靠。
# === 认知 ===
# ★ 进程"无声无息地消失",应用日志又干净 ——
# 第一反应就该是 dmesg 看 OOM,而不是死磕应用日志。
修复 2:看懂 OOM Killer——它到底按什么挑人
# === ★ OOM Killer 不是随机杀,它有一套打分规则 ===
# === 内存耗尽时,内核做的事 ===
# 当内核需要内存、却怎么也分配不出来时,它面临一个
# 选择:要么整个系统卡死/崩溃,要么【牺牲一个进程】
# 把内存腾出来。内核选择后者 —— 这就是 OOM Killer。
# ★ 它的目标是:用【最小的代价】,腾出【最多的内存】。
# === ★ 每个进程都有一个 oom_score ===
# 内核给每个进程算一个分:oom_score。分越高,
# 越"该死",越容易在 OOM 时被选中。
$ cat /proc/9802/oom_score
834 # 看某个进程当前的 oom 得分
# 这个分,核心由进程【占用的内存(rss)】决定 ——
# ★ 占内存越多的进程,分越高。逻辑很直白:
# 杀一个占 5G 的,比杀十个占 100M 的,划算得多。
# === ★ 看一眼:当前哪个进程最危险 ===
$ for p in /proc/[0-9]*; do
printf '%s %s\n' "$(cat $p/oom_score 2>/dev/null)" \
"$(cat $p/comm 2>/dev/null)"
done | sort -rn | head
1820 java # ★ 分最高,OOM 真来了它第一个死
1450 java
210 mysqld
# ★ 这条命令能让你提前知道:万一 OOM,谁会被祭天。
# === ★ oom_score_adj:人为给进程"加减分" ===
# 你可以调一个进程的 oom_score_adj,范围 -1000 ~ +1000:
# - 调成负数 -> 降低它的分 -> ★ 更不容易被杀(保护它)
# - -1000 -> 几乎【豁免】,内核基本不会杀它
# - 调成正数 -> 提高它的分 -> 更容易被杀(让它先死)
$ cat /proc/9802/oom_score_adj # 看当前调整值,默认 0
0
# 把关键进程保护起来(降低被杀概率):
$ echo -500 > /proc/9802/oom_score_adj
# ★ 注意:这是【治标】。真正该做的是别让内存耗尽。
# === 读懂 OOM 时内核打的那张"账单" ===
$ dmesg -T | grep -A30 'invoked oom-killer'
# ★ 里面有一张表,列出 OOM 那一刻所有进程的 rss。
# 谁 rss 最大,基本就是被杀的那个 —— 看这张表,
# 能立刻知道"内存到底被谁吃掉了"。
修复 3:内存为什么会被吃光——两类根因
# === ★ OOM 只是结果,要查的是:内存为什么不够了 ===
# === 根因一:单个进程内存泄漏 / 配置过大 ===
# 某一个进程,自己的内存【持续、无止境地增长】,
# 最后把整台机器吃穷。
# ★ 怎么认:盯着这个进程的 rss 看一段时间 ——
$ while true; do
ps -o rss= -p 9802 | awk '{print strftime("%T"), $1/1024" MB"}'
sleep 60
done
14:00:00 2100 MB
15:00:00 2600 MB # ★ 只涨不跌 -> 高度怀疑内存泄漏
16:00:00 3100 MB
# - Java 进程:还要分清是【堆内泄漏】(看 -Xmx、用
# jmap/MAT 分析堆),还是【堆外泄漏】(rss 远大于
# -Xmx,可能是 Netty 直接内存、JNI、线程过多)。
# - ★ 一个常见坑:-Xmx 设了 4G,但你以为进程就只占
# 4G —— 错。JVM 实际 rss = 堆 + 元空间 + 线程栈 +
# 直接内存 + JVM 自身,通常比 -Xmx 大不少。
# === ★ 根因二:机器整体"超卖",内存被几个进程挤爆 ===
# 没有哪个进程在泄漏,但这台机器上塞了太多服务,
# 每个都"还行",加起来就【超过物理内存】了。
# 我这次就是:8G 的机器,塞了 3 个 Java 服务,
# 光 -Xmx 加起来就 9G 多了 —— 平时没事,是因为
# 它们没同时把堆用满;一旦凌晨批量任务来了,
# 内存需求一冲高,机器立刻就崩。
$ free -h
total used free shared buff/cache available
Mem: 7.6G 7.1G 180M 20M 300M 150M
# ^^^^ ^^^^
# ★ free 和 available 都极低 = 内存已绷到极限
# ★ available 比 free 更有参考意义:它估算的是
# "还能给新程序用多少"(含可回收的 buff/cache)。
# === 一眼定位:内存被谁吃了 ===
$ ps aux --sort=-rss | head
USER PID %MEM RSS COMMAND
app 9802 31.0 2400000 java -Xmx... order
app 9805 18.0 1400000 java -Xmx... pay
# ★ 按 rss 排序,排前面的就是内存大户。
# === ★ 区分这两类的意义 ===
# - 是泄漏:治【那一个进程】(修代码 / 调参数)。
# - 是超卖:治【这台机器的部署】(减服务 / 加内存 /
# 给每个服务设硬上限)。
# 搞反了方向,查一晚上也白查。
修复 4:overcommit——为什么"内存不够"还能申请成功
# === ★ 一个反直觉的点:Linux 允许"超额承诺"内存 ===
# === 为什么会出现 OOM 这种"事后才杀"的机制 ===
# 你可能会问:内存不够,申请的时候直接失败不就行了,
# 为什么要等到真不够用了,再回头杀进程?
# ★ 因为 Linux 默认开启【内存 overcommit(超额承诺)】:
# 进程申请内存(malloc)时,内核【几乎总是答应】,
# 哪怕加起来已经超过物理内存 + swap。
# 内核赌的是:进程申请的内存,通常【不会全部真用到】。
# 只有当进程真去【写】某一页内存时,才真正分配物理页。
# === ★ overcommit 的代价:可能"赌输" ===
# 大多数时候这个赌注是对的,内存利用率更高。
# 但万一某一刻,所有进程都真的去用它们申请到的内存,
# 内核就【兑现不了】当初的承诺了 —— 物理内存真的
# 没有了。这时它没法"反悔"之前的 malloc,只能
# ★ 现杀一个进程来腾地方。这就是 OOM Killer。
# === 看 / 调 overcommit 策略 ===
$ cat /proc/sys/vm/overcommit_memory
0
# 三个取值:
# 0 = ★ 默认。启发式判断,大多答应,明显离谱的拒绝。
# 1 = 永远答应,从不拒绝(危险,容器/特殊场景才用)。
# 2 = ★ 严格模式。承诺总量不超过 swap + 物理内存 *
# overcommit_ratio。超了,malloc 当场失败 ——
# 程序会更早拿到"内存不足"的错,而不是被偷袭。
# === swap:OOM 的缓冲垫 ===
$ free -h | grep Swap
Swap: 0B 0B 0B # ★ 这台机器没有 swap!
# ★ 没有 swap,内存一满,内核【没有任何缓冲】,
# OOM Killer 来得又快又狠。
# 加一点 swap,能在内存吃紧时把冷数据换出去,
# 给你争取到喘息和告警的时间(代价:换页时变慢)。
$ dd if=/dev/zero of=/swapfile bs=1M count=4096
$ chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
# 永久生效:写进 /etc/fstab
# /swapfile swap swap defaults 0 0
# === ★ 认知 ===
# OOM 不是 bug,是 overcommit 这个"赌博机制"赌输了的
# 兜底手段。理解了 overcommit,才理解为什么内存
# "申请时好好的,用着用着就被杀了"。
修复 5:正确解法——给内存定好"硬边界"
# === ★ 根治思路:别让机器内存被无序地用到耗尽 ===
# === ★ 解法 1:给每个 Java 服务的 -Xmx 算总账 ===
# 一台机器上所有 Java 服务的内存,要这样估:
# 单进程实际占用 ≈ -Xmx + 元空间 + 线程栈 + 直接内存
# + JVM 自身 ≈ -Xmx 再上浮 25%~50%
# ★ 把所有进程这样算出来的总和,必须【明显小于】
# 物理内存,要给系统和 buff/cache 留出余量。
# 我这次的错:8G 机器,三个服务 -Xmx 加起来就 9G+ ——
# 账从一开始就是错的。
$ ps -ef | grep java | grep -o 'Xmx[0-9]*[mg]' # 核对各服务 Xmx
# === ★ 解法 2:用 cgroup / systemd 给服务设内存硬上限 ===
# 与其等 OOM Killer 来杀整台机器上"最大的",不如
# 给每个服务【画一个牢笼】:它自己超了,只杀它自己,
# 不连累别人。systemd 服务直接加一行:
$ systemctl edit order.service
[Service]
MemoryMax=3G
# ★ 这个服务用超 3G,内核只在【它这个 cgroup 内部】
# 触发 OOM,杀的是它自己 —— 其他服务安然无恙。
# 故障被【隔离】在一个服务里,不再是全机器抽奖。
$ systemctl daemon-reload && systemctl restart order.service
# === ★ 解法 3:保护关键进程的 oom_score_adj ===
# 像数据库这种"绝不能被杀"的,给它降分:
$ systemctl edit mysqld.service
[Service]
OOMScoreAdjust=-800
# ★ 这样即便真 OOM,内核也会优先杀别的,绕开 mysqld。
# === 解法 4:加 swap,留缓冲和告警时间 ===
# 见修复 4。哪怕加 2~4G swap,也比"零缓冲"强很多。
# === ★ 解法 5:配监控告警,别等被杀了才知道 ===
# 真正的治本,是在内存【还没满】时就收到告警。
# 监控两个值:
# - available 内存低于阈值 -> 告警
# - dmesg / journalctl -k 里出现 oom-killer -> 立刻告警
$ journalctl -kf | grep --line-buffered -i 'oom' # 实时盯 OOM
# === 解法 6:是泄漏就治泄漏 ===
# 如果根因是某进程内存泄漏(修复 3 判断出来的),
# 上面都是缓解 —— 真正要做的是 dump 下来分析:
$ jmap -dump:format=b,file=/tmp/heap.bin # Java 堆快照,拿去 MAT 分析
# === 验证 ===
$ systemctl show order.service -p MemoryMax # 确认限额生效
$ free -h # 确认有余量
$ journalctl -k --since today | grep -i oom # 确认不再有新 OOM
# ★ 三个一起看,才算这次 OOM 真的根治了。
修复 6:OOM 排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 进程"无声消失"+应用日志干净 = 先查他杀 ===
$ systemctl status 服务名 | grep -i signal
$ dmesg -T | grep -i 'killed process'
# signal=KILL / Out of memory,就是 OOM。
# === 2. ★ OOM 的证据在内核日志,不在应用日志 ===
# SIGKILL 无法被捕获,进程没机会打任何遗言。
# === 3. dmesg 看不到历史就用 journalctl -k ===
$ journalctl -k --since '2024-04-10' | grep -i oom
# === 4. ★ OOM 看那张进程内存表,谁 rss 最大谁被杀 ===
$ dmesg -T | grep -A30 'invoked oom-killer'
# === 5. ★ 分清根因:单进程泄漏,还是整机超卖 ===
# 泄漏治进程,超卖治部署 —— 别搞反方向。
# === 6. Java 实际 rss 远大于 -Xmx,算总账要上浮 ===
# === 7. ★ 给服务设 MemoryMax,把故障隔离在单个服务 ===
# === 8. 关键进程用 OOMScoreAdjust 降分保护;加 swap 留缓冲 ===
# === 9. 排查"进程被 OOM 杀掉"的步骤链 ===
$ systemctl status 服务名 # ① signal=KILL?
$ dmesg -T | grep -i 'out of memory' # ② 确认是 OOM
$ dmesg -T | grep -A30 oom-killer # ③ 看谁吃了内存
$ ps aux --sort=-rss | head # ④ 现在内存被谁占
$ 判断泄漏 or 超卖 -> 对症处理 # ⑤ 设限额/修泄漏/加内存
# 按这个顺序,OOM 事故基本能定位、能根治。
命令速查
需求 命令
=============================================================
看服务死因(systemd) systemctl status 服务名
找 OOM 铁证 dmesg -T | grep -i 'killed process'
看 OOM 完整现场 dmesg -T | grep -A30 'invoked oom-killer'
看历史/持久化的内核日志 journalctl -k --since 日期 | grep -i oom
实时盯 OOM 事件 journalctl -kf | grep -i oom
看进程 oom 得分 cat /proc/PID/oom_score
看/调进程 oom 调整值 cat/echo /proc/PID/oom_score_adj
按内存占用排序看进程 ps aux --sort=-rss | head
看整机内存余量 free -h
看 overcommit 策略 cat /proc/sys/vm/overcommit_memory
给服务设内存硬上限 systemctl edit 服务名 -> MemoryMax=
保护关键进程不被 OOM 杀 systemctl edit -> OOMScoreAdjust=-800
Java 堆快照(查泄漏) jmap -dump:format=b,file=heap.bin PID
口诀:进程无声消失应用日志又干净,先 dmesg 查 OOM 而不是死磕应用日志
OOM 用 SIGKILL 杀没有遗言,看进程内存表谁 rss 大谁死,泄漏治进程超卖治部署
避坑清单
- 进程消失分自己崩和被他杀两类,被杀的证据不在应用日志里要去看内核日志
- OOM Killer 用 SIGKILL 杀进程,这个信号无法被捕获,所以进程没机会打任何日志和遗言
- 应用日志干净到毫无征兆地停在某一行,恰恰是被 SIGKILL 偷袭的典型特征
- dmesg -T 看 killed process 是 OOM 铁证,缓冲区被冲掉就用 journalctl -k 看持久化日志
- OOM Killer 不随机杀,按 oom_score 挑,核心看 rss,占内存最多的进程分最高最先死
- oom_score_adj 可人为加减分,负数保护进程不被杀,关键进程如数据库应设成负值
- 内存耗尽分两类根因,单进程 rss 只涨不跌是泄漏,多服务加起来超物理内存是整机超卖
- Java 进程实际 rss 远大于 -Xmx,还有元空间线程栈直接内存,算内存总账要上浮 25% 以上
- Linux 默认 overcommit 超额承诺内存,malloc 几乎总成功,真用爆了才靠 OOM Killer 兜底
- 给每个服务设 MemoryMax 把 OOM 隔离在单个服务,比让内核在全机器范围抽奖式杀进程安全
总结
这次"进程在凌晨无声无息地消失"的事故,纠正了我一个关于"进程死亡"的根深蒂固的预设。在我的脑子里,一个程序的死,长久以来只有一种剧本:它是【自己】死的。它要么是代码里有个 bug,踩到了某个非法的内存地址,段错误;要么是抛出了一个没人接住的异常,一路冒泡到顶,把自己掀翻;要么是它自己的运行时——比如 JVM——发现堆实在装不下了,郑重其事地打出一行 OutOfMemoryError,然后体面地退场。这三种死法,尽管惨烈程度不同,却有一个共同点:死亡是进程【自己】这出戏的最后一幕,所以进程一定会、也一定有机会,在落幕前留下点什么——一句异常、一段堆栈、一个 core dump。正因为我心里只有这一种剧本,所以当我翻遍了应用日志、GC 日志、nohup.out,却发现它干净得可怕——最后一行停在一句再普通不过的业务日志上,之后是绝对的、毫无征兆的空白——我整个人是懵的。一个会留下遗言的死亡,我能查;一个连遗言都没有的死亡,我连从哪里下手都不知道。我甚至一度怀疑是不是日志被截断了、是不是磁盘满了写不进去了——我宁可怀疑日志系统出了问题,也没有怀疑过我那"进程都是自己死的"的预设本身。复盘到根上,我才明白,我漏掉了一整类剧本:进程不是自己死的,是【被杀】的。而且杀它的,是这台机器上权力最大的那个角色——内核。Linux 的内存管理里藏着一个我从来没有正眼看过的机制:overcommit,超额承诺。内核在分配内存这件事上,其实是个乐观的赌徒——进程开口要内存,它几乎从不拒绝,哪怕所有进程要的加起来早就超过了物理内存,它也照应不误,赌的是大家不会同时把要来的内存都用满。这个赌注大多数时候都赢,机器因此能塞下更多服务、内存利用率更高。可一旦赌输了——某一刻所有进程真的都来兑现它们的内存——内核就陷入了一个无法用"拒绝"来解决的绝境:那些内存它【早就答应】出去了,现在反悔不了。它唯一的出路,就是从已经活着的进程里,挑一个出来杀掉,把内存抢回来,让整个系统不至于一起陪葬。这个挑人、动手的角色,就是 OOM Killer。而它挑中我那个进程的逻辑,冷静得近乎残酷:它要用最小的代价换最多的内存,所以它扫一眼所有进程的内存占用,谁的 rss 最大,谁的"性价比"最高,就杀谁——我那个 -Xmx 开得最大、堆也用得最满的 Java 服务,毫无悬念地中选。它对我的进程执行死刑用的是 SIGKILL,信号 9,这个信号的设计本意就是"不容反抗"——它无法被捕获、无法被处理、无法被忽略,进程收到它的那一刻,连执行一行代码的机会都没有。所以我的进程死得那么干净、那么安静,不是因为它隐瞒了什么,而是因为它根本没有被给予开口的权利。这次最大的收获,是我意识到,排查问题时最危险的,不是我不知道答案,而是我【在错误的地方笃定地寻找答案】。我对着应用日志一遍遍地翻,翻得越仔细、越笃定,就越是把自己锁死在"它是自己死的"这个错误的前提里——我所有的努力,都是在一栋根本没有真相的房子里精装修。真正的答案,从一开始就在另一栋房子里:内核日志 dmesg。一个进程的命运,从来不只由它自己的代码决定,它还活在一个有内核、有邻居、有资源争抢的系统里;它的死,既可能是自己的剧本写到了头,也可能是这个系统在某个绝境时刻替它做的了断。所以下一次,当一个进程又这样无声无息地消失、而它该留下的遗言却一个字都没有时,我不会再对着应用日志干瞪眼了。这种"干净的死亡"本身,就是一条最响亮的线索——它在提醒我:这一次,要去问的不是它自己,而是那个有权力让它闭嘴的内核。
—— 别看了 · 2026