服务器与 Linux 排查 列表全部文章
服务器与 Linux 排查

Java 进程“人间蒸发”:没有 hs_err、没有 OOM 异常,凶手是 Linux 的 OOM Killer

服务凌晨无声消失,没有 hs_err 文件也没有 OutOfMemoryError。dmesg 里一行 Out of memory: Killed process 揭开真相:JVM 堆加堆外内存把机器吃光,被内核枪毙。附排查命令、oom_score 原理和堆参数预留算法。

更多

凌晨 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、发布平台记录,那晚没人动过。于是目标锁定内核日志。

二、排查路线图

进程消失无 hs_err / 无 OOM 异常 怀疑 SIGKILL先排除人为 kill -9 dmesg -T | grep -i"killed process" 确认 OOM Killer看 rss / score / 时间点 算内存账堆 + 元空间 + 线程栈 + 堆外 与机器总内存对账free -h 的 available 调堆参数 / 加内存给系统留足余量

三、动手:从内核日志里找“行凶记录”

第 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_adj

proc 手册对 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 还觉得“我还有空间”时就被内核带走了。

五、止血与修复

止血

  1. 把消失的实例拉起来(记得先保存 dmesg -T 与 /var/log/messages 的相关片段);
  2. 把堆参数临时调小,避免拉起后立刻再死:-Xms4g -Xmx4g;
  3. 检查还有没有其他实例在同样的边缘: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,它只会冷冷地说一句:“内存不够了,总得有人走。”

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人