凌晨 3 点 17 分,手机震了一下:“订单服务实例 2 健康检查失败”。我迷迷糊糊爬起来登上机器,ps -ef | grep java——空的。Java 进程不见了。
按常理,Java 挂掉总得留点遗言:要么应用日志里有 OutOfMemoryError,要么当前目录下有个 hs_err_pid12345.log,要么至少有个 core dump。我把日志翻了个底朝天,最后一行日志停在 03:16:58,之后什么都没有。像是一个人正在说话,突然被人捂住了嘴,然后消失了。
这种“无声无息、不留遗言”的死法,我后来学会了先怀疑一个人:Linux 内核里的 OOM Killer。
一、现象:没有遗言的死亡
先梳理“没有的东西”,这些缺失本身就是证据:
- 应用日志:正常打印到 03:16:58,没有
java.lang.OutOfMemoryError、没有 shutdown hook 的日志(Spring 的优雅停机会打印 “Shutting down”)——说明进程没有机会执行任何 Java 代码; - 没有
hs_err_pid*.log——JVM 自己崩溃(SIGSEGV 等)时会写这个文件,没有就说明不是 JVM 崩了; - 进程直接消失,没有留下 exit 信息。
能让一个进程“没机会执行任何代码就消失”的,只有信号 SIGKILL(kill -9):它不能被捕获、不能被忽略,JVM 连 shutdown hook 都来不及跑。而谁会发 SIGKILL?要么是人(或脚本、运维平台),要么是内核 OOM Killer。先排除人:查 last、history、发布平台记录,那晚没人动过。于是目标锁定内核日志。
二、排查路线图
三、动手:从内核日志里找“行凶记录”
第 1 步:dmesg 一查,真凶现形
$ dmesg -T | grep -i 'killed process'
[Tue Sep 29 03:17:01 2026] Out of memory: Killed process 8231 (java) total-vm:9812344kB, anon-rss:7340120kB, file-rss:0kB, shmem-rss:0kB, UID:1001 pgtables:15420kB oom_score_adj:0
$ dmesg -T | grep -i -B2 -A12 'invoked oom-killer'
[Tue Sep 29 03:17:01 2026] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
...
[Tue Sep 29 03:17:01 2026] Mem-Info:
[Tue Sep 29 03:17:01 2026] Tasks state (memory values in pages):
[Tue Sep 29 03:17:01 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Tue Sep 29 03:17:01 2026] [ 8231] 1001 8231 2453086 1835030 15790080 0 0 java
[Tue Sep 29 03:17:01 2026] [ 9120] 0 9120 58230 1204 98304 0 0 filebeat
...这就是“行凶记录”。挑几个关键信息读一读:
Out of memory: Killed process 8231 (java):内核在内存耗尽时选了 PID 8231 处决;anon-rss:7340120kB:这个进程占用了约 7 GB 匿名物理内存(堆、堆外、线程栈都算匿名内存)——机器一共 8 GB,进程独占了绝大部分;invoked oom-killer那行说明是谁在申请内存时触发了回收失败;- 下面的进程表能看到,是 java 一个人吃掉了 1835030 页 × 4KB ≈ 7GB,其他进程加起来微不足道;
oom_score_adj:0:没人给它开过“免死金牌”。
OOM 记录的位置取决于系统:CentOS/RHEL 系可以看 /var/log/messages;Debian/Ubuntu 看 /var/log/syslog 或 /var/log/kern.log;systemd 环境通用的是 journalctl -k | grep -i -E 'oom|killed process'。提醒一句:dmesg 是个环形缓冲区,机器重启就没了;日志文件也会被轮转,发现问题第一时间保存。
$ journalctl -k --since "2026-09-29 03:00" --until "2026-09-29 03:30" | grep -i -E 'oom|killed'
$ grep -i 'out of memory' /var/log/messages*第 2 步:那一刻机器的内存长什么样
光知道“被杀了”不够,要知道内存是被谁吃光的。事后现场已没了,我对比了同类实例(实例 1 还活着):
$ free -h
total used free shared buff/cache available
Mem: 7.6Gi 7.2Gi 118Mi 1.0Mi 310Mi 260Mi
Swap: 0B 0B 0B怎么读这张表:
free仅仅是“完全没被用”的内存,Linux 会把空闲内存拿去做页缓存,所以 free 小不一定有问题;- 要看的是
available。free 手册对它的定义是:“在不使用交换的前提下,估计可以分配给新启动应用的内存”,它把可回收的页缓存也算进去了; - 这里 available 只有 260Mi,说明确实没有可回收的余地;
Swap: 0B:没开 swap,也就没有缓冲,内存一满内核就直接找人开刀。
再看 JVM 自己:
$ ps -o pid,rss,vsz,cmd -p $(pgrep -f order.jar)
PID RSS VSZ CMD
8390 6981540 9776232 java -Xms6g -Xmx6g -XX:MaxMetaspaceSize=512m -jar order.jar实例 1 的 -Xmx6g,而 RSS 已经 6.6GB。机器 8GB(可用约 7.6GB),JVM 堆 6GB,剩下留给 元空间、线程栈、直接内存(Netty/NIO)、代码缓存、GC 自身结构、操作系统、filebeat 等其他进程——加起来早就超过了 1.6GB。
第 3 步:用 NMT 算 JVM 到底占了多少
JVM 的内存 ≠ 堆。想看清全貌,用 Native Memory Tracking(需要在启动参数里加 -XX:NativeMemoryTracking=summary,有少量性能开销):
$ jcmd 8390 VM.native_memory summary
Total: reserved=8104MB, committed=6790MB
- Java Heap (reserved=6144MB, committed=6144MB)
- Class (reserved=1080MB, committed=68MB)
- Thread (reserved=310MB, committed=310MB)
(thread #300)
- Code (reserved=248MB, committed=52MB)
- GC (reserved=290MB, committed=290MB)
- Internal (reserved=180MB, committed=180MB)
- Symbol (reserved=20MB, committed=20MB)堆之外还有 600MB+,其中 Thread 部分是 300 个线程 × 约 1MB 栈。再叠加 Netty 的 direct memory(这部分在 “Other” 或 “Internal” 里,默认上限约等于 -Xmx),如果业务突发流量,堆外内存一涨,总量就超了物理内存。
四、根因:内核是怎么选“替死鬼”的
Linux 默认允许内存超分配(overcommit):进程 malloc 时内核先答应,真用到页面时才真正分配。当物理内存耗尽、页缓存也回收不动、又没有 swap 时,内核只好启动 OOM Killer,挑一个进程杀掉来腾内存。
挑谁?内核给每个进程算一个 badness 分数:基础分大体正比于该进程占用的内存(RSS、页表、swap)占系统可用内存的比例,取值 0 到 1000,再加上 oom_score_adj 的调整值,分最高者被杀。也就是说,用内存最多的人最容易被杀,而 Java 进程恰好几乎总是机器上最大的那个。可以直接读取:
$ cat /proc/8390/oom_score
863
$ cat /proc/8390/oom_score_adj
0
# 想保护关键进程(如 sshd):
$ echo -1000 > /proc/$(pgrep -o sshd)/oom_score_adjproc 手册对 oom_score_adj 的描述:取值范围 -1000 到 +1000,值越大越容易被杀,-1000 相当于对该进程彻底禁用 OOM 杀戮(它的 badness 总是 0)。863 的分数说明这个 Java 进程在候选中“遥遥领先”。
但注意:不要把业务 Java 进程设成 -1000 来“解决”问题。那样内核会转而杀别的进程(sshd、监控、数据库),甚至直接 panic,机器整个登不上去——比死一个业务进程惨得多。正确做法是让内存别爆。
补充一个容易混淆的点:Docker/K8s 里的 OOM 与宿主机 OOM 是两回事。容器超过 cgroup 的 memory limit 时,日志是 Memory cgroup out of memory: Killed process ...,此时宿主机可能还有大把内存。这次我们是宿主机直接部署,所以是第一种。
这次的具体因果链:8GB 机器 → JVM -Xmx6g(占 75%)→ 加上堆外与线程栈约 1GB+ → 加上 filebeat、监控 agent、系统自身几百 MB → 凌晨批量任务触发 Netty 直接内存与堆同时增长 → available 归零 → 内核出手。关键点:JVM 堆并没有满,所以没有 OutOfMemoryError,进程在 JVM 还觉得“我还有空间”时就被内核带走了。
五、止血与修复
止血
- 把消失的实例拉起来(记得先保存
dmesg -T与/var/log/messages的相关片段); - 把堆参数临时调小,避免拉起后立刻再死:
-Xms4g -Xmx4g; - 检查还有没有其他实例在同样的边缘:
free -h里 available 是否低于总内存的 15%。
根治:给操作系统和堆外留出余量
一条经验公式(不是标准,是我们的内部约定):
机器总内存 ≥ Xmx + MaxMetaspaceSize + MaxDirectMemorySize + 线程数 × Xss + 代码缓存(~240M)
+ 其他常驻进程(filebeat/agent,约 0.5~1G) + 系统与页缓存预留(约 1~2G)
示例:8G 机器,跑单个 Java 服务
Xmx = 4G
Metaspace = 512M
Direct Memory = 512M (-XX:MaxDirectMemorySize=512m)
300 线程 × 1M = 300M
代码缓存等 ≈ 300M
其他 ≈ 1G
合计约 6.6G,仍留出 1G+ 的余量# 推荐启动参数(示例)
java -Xms4g -Xmx4g \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=512m \
-Xss512k \
-XX:NativeMemoryTracking=summary \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ \
-jar order.jar
# 系统层:留一点缓冲,避免内存一满就杀
# 1) 给机器配少量 swap(4G 内存起步配 2G 也可),权衡:swap 会让延迟变差,但能挡住瞬时尖峰
# 2) 盯 available,而不是 free
# 3) 为关键守护进程设置 oom_score_adj(如 sshd 设 -1000),但不要给业务进程设置如果程序是在 systemd 下运行,可以用 MemoryMax= 之类的 cgroup 限额来把故障“关在笼子里”(只杀这一个服务而不是整机乱杀),前提是限额之内 JVM 的总占用要算得过来。
六、复盘
| 做法 | 为什么 |
|---|---|
进程无声消失,先 dmesg -T | grep -i 'killed process' | SIGKILL 不留遗言,OOM Killer 会在内核日志里留下完整记录 |
看 free -h 的 available,不看 free | 页缓存可回收,available 才是真实余量 |
| Xmx 不超过机器内存的约 50%~60%(独占机器可到 70%) | JVM 还有元空间、线程栈、直接内存、代码缓存、GC 结构等堆外开销 |
显式限制 MaxMetaspaceSize、MaxDirectMemorySize | 让堆外“有上限”,出问题时是 JVM 抛异常,而不是内核杀进程 |
开启 NMT,怀疑堆外时 jcmd VM.native_memory | 用数据说明堆外到底占了多少 |
| 保存现场:dmesg、messages 及时归档 | dmesg 缓冲区会被覆盖,重启即丢 |
| 容器场景区分 cgroup OOM 与宿主机 OOM | 前者调 limit 与 JVM 参数,后者调机器规格 |
一句话备忘:Java 进程无声消失 →
dmesg -T | grep -i 'killed process';被 OOM Killer 杀就算内存账:Xmx + 堆外 + 线程栈 + 系统 < 总内存,available 别见底。
后来我们的约定是:机器内存规划时,先给操作系统和堆外留出位置,剩下的才轮到堆。毕竟内核不看你的 SLA,它只会冷冷地说一句:“内存不够了,总得有人走。”
参考资料
- proc_pid_oom_score_adj(5) 手册:说明 oom_score_adj 取值范围 -1000 到 +1000,以及 -1000 等于对该进程禁用 OOM 杀戮。
- free(1) 手册:解释 available 是不换页前提下可分配给新应用的内存估计值,与 free 的区别。
- CloudBees:Process killed by OOM Killer:Java 服务被 OOM Killer 杀掉的典型案例,如何在 dmesg 中确认,以及给操作系统预留 2-4GB 内存、不要把全部内存分给 JVM 的建议。
- Understanding the Linux Virtual Memory Manager:OOM Killer:介绍内核 OOM Killer 按 badness 评分选择受害进程的机制(早期内核版本的算法,原理仍适用)。
一起聊聊