一次 Java 进程神秘退出的排查实录:从 120G 内存到 tmp 目录里的“隐形杀手”

0 阅读10分钟

**摘要:**本文记录了一次线上 Java 进程反复神秘退出的完整排查过程。服务器拥有 120G 物理内存,JVM 堆仅占用 40G 左右,且无其他应用,但进程仍被 Linux 内核的 OOM Killer 反复击杀。通过逐层排查系统日志、JVM 内存占用、共享内存(Shmem)与 tmp 目录临时文件的关系,最终定位到根因:某个进程将 tmp 目录下的临时文件通过 mmap 映射到共享内存,文件持续增长导致内存被耗尽。文章还详细解读了 Linux 内核 vm.overcommit_memory 参数 0、1、2 三种内存分配策略的含义与取舍,并结合本次问题给出线上 Java 服务的选型建议与监控改进方案。

1. 现象:进程又没了

那天下午,运维群里突然炸了锅:线上 Java 服务又挂了。这已经是本周第三次,每次都是毫无征兆地消失,没有 OOM 日志,没有 core dump,连系统日志里都找不到明显的报错。更诡异的是,服务器配置并不低——120G 物理内存,JVM 堆只给了 40G 左右,而且这台机器上除了这个 Java 进程,再没有跑其他应用。

按理说,40G 的堆在 120G 的机器上,怎么也不该因为内存不够而挂掉。可事实就是,进程像被什么东西“掐死”了一样,悄无声息地退出了。

2. 第一步:先确认是不是 JVM 自己崩了

排查的第一步,自然是先看 JVM 有没有留下什么“遗言”。我登录服务器,先翻了翻 JVM 的日志目录,看看有没有 hs_err 文件或者 dump 文件:

ls -l /opt/app/logs/ | grep -E "hs_err|core|dump"
find / -name "hs_err_pid*" -type f 2>/dev/null

结果一无所获。没有 hs_err,没有 core dump,也没有 JVM 自己生成的 dump 文件。这说明 JVM 并不是因为自身崩溃(比如致命错误、JIT 编译问题)而退出,更像是被操作系统“请”出去的。

接着我看了下系统日志,重点找 OOM Killer 的痕迹:

dmesg -T | grep -i -E "killed process|out of memory|oom"
grep -i "oom" /var/log/messages | tail -50

日志里确实有 OOM Killer 的记录,而且被杀的进程 PID 正是我们的 Java 进程。这就对上了——进程不是自己崩的,是被 Linux 内核的 OOM Killer 干掉的。

3. 第二步:为什么 40G 堆会被 OOM Killer 盯上

这里就出现了一个矛盾点:JVM 堆才 40G,机器有 120G,怎么会触发 OOM?

要解开这个谜,得先搞清楚一个关键概念:JVM 占用的内存,远不止堆那 40G。除了堆,还有元空间(Metaspace)、线程栈、直接内存(Direct Memory)、JIT 编译产物、GC 相关的数据结构等等。这些统称为 JVM 的“非堆内存”。如果这些部分加起来很大,JVM 实际占用的 RSS(常驻内存)可能远超堆大小。

我赶紧用 jcmd 和 ps 确认了一下 JVM 实际占用的内存:

jcmd <pid> VM.native_memory summary
ps -o pid,rss,vsz,cmd -p <pid>

结果让我有点意外:JVM 的 RSS 确实在 40G 左右,并没有出现非堆内存暴涨的情况。也就是说,JVM 自己并没有“多吃多占”。那 OOM Killer 为什么还要杀它?

唯一的解释是:系统内存真的不够了。可 120G 的机器,JVM 才占 40G,剩下的 80G 去哪了?

4. 第三步:用 free 和 smem 揪出内存去向

我决定先看看系统内存的整体分布:

free -g
cat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvailable|Buffers|Cached|Shmem|Slab"

free 的输出让我心里一沉:内存确实被吃光了。但奇怪的是,Buffers 和 Cached 都不高,Slab 也正常,唯独 Shmem(共享内存)这一项高得离谱,而且还在持续增长。

为了进一步确认是谁在占用共享内存,我用了 smem 和 ipcs:

smem -t -k
ipcs -m

ipcs 列出了不少共享内存段,但光看这个还不够,我得找到这些共享内存段对应的进程。于是又用 lsof 查了一下:

lsof | grep -i "mem" | head -50

排查到这里,方向逐渐清晰:系统里有一块共享内存区域,占用量持续增长,而且从不释放。这块内存的增长,和 JVM 的退出时间点高度吻合——每当共享内存涨到某个临界值,OOM Killer 就会出手。

5. 第四步:共享内存和 tmp 目录的“暧昧关系”

共享内存不会凭空增长,背后一定有某个进程在持续写入。我顺着这个思路,开始对比共享内存的增长和系统里其他指标的变化。

我注意到服务器上有一个 tmp 目录,里面的临时文件数量一直在增加。起初我没太在意,以为只是普通日志或临时文件的堆积。但当我用 du 和 lsof 对比之后,发现了一个惊人的规律:

du -sh /tmp/*
lsof +L1 | grep -i deleted | head -30

tmp 目录里临时文件的总大小,和共享内存的占用量几乎同步增长,两者呈明显的正相关。更关键的是,这些临时文件被某个进程打开后,文件虽然被标记为“deleted”,但句柄一直没有释放——这正是典型的“文件被删除但进程仍持有句柄”的场景。

到这里,我基本可以断定:这些临时文件并不是普通的磁盘文件,而是被映射到了内存里。也就是说,某个进程把 tmp 目录下的文件通过 mmap 映射到了共享内存区域,文件不断增长,映射的内存也不断增长,而且进程从不主动释放。

6. 第五步:验证——内存映射的“铁证”

为了验证这个猜想,我做了两件事。

第一,查看进程的 maps 文件,确认是否有 tmp 目录下的文件被映射进内存:

grep "/tmp" /proc/<pid>/maps | head -20

第二,用 pmap 看具体的内存映射明细:

pmap -x <pid> | grep "/tmp" | head -20

结果证实了我的判断:确实有进程把 tmp 目录下的临时文件通过 mmap 映射到了内存中。文件每增长一块,映射的共享内存就跟着涨一块,而且这些映射的内存被标记为“不可回收”,OOM Killer 在计算可用内存时,这部分会被算作“已占用”。

真相大白了:不是 JVM 的问题,而是服务器上某个进程(很可能是业务代码里某个库或组件)在 tmp 目录下不断创建临时文件,并通过内存映射的方式持续占用共享内存,最终把系统内存耗尽,导致 OOM Killer 把无辜的 Java 进程给杀了。

7. 深入:Linux 对应用内存分配的三种策略(0、1、2)

排查到这里,问题本身已经定位清楚了。但作为一个负责任的排查帖,我还想多说一句:为什么 OOM Killer 会选中我们的 Java 进程?这就涉及 Linux 内核的一个关键参数——vm.overcommit_memory。

这个参数控制着内核如何对待进程的内存申请,一共有三个取值:

取值策略名称含义
0启发式策略(Heuristic Overcommit)内核根据当前内存使用情况,粗略判断是否允许进程的内存申请。这是大多数发行版的默认值。它允许一定程度的内存超卖,但会在系统内存明显不足时拒绝申请。这种策略下,OOM Killer 的触发时机比较“模糊”,容易出现“看起来内存还够,但进程还是被杀”的情况。
1总是允许(Always Overcommit)内核从不拒绝进程的内存申请,无论系统内存是否充足。这种策略下,进程可以申请远超物理内存的虚拟内存,但一旦实际访问内存导致物理内存耗尽,OOM Killer 就会立刻介入,随机或按评分杀掉进程。这种策略适合对内存申请成功率要求极高、且能接受 OOM 风险的场景。
2禁止过量(Never Overcommit)内核严格按照“物理内存 + swap”的总量来审批内存申请,不允许任何超卖。进程申请的内存如果超过系统可用总量,会被直接拒绝(返回 ENOMEM)。这种策略最保守,能最大程度避免 OOM,但可能导致一些需要大内存的进程(比如 JVM 启动时预留的堆空间)直接启动失败。

8. 结合本次问题:线上到底该用哪种策略

回到我们这次的问题。服务器默认的 vm.overcommit_memory 是 0(启发式策略)。在这种策略下,内核允许一定程度的内存超卖,但判断标准比较“模糊”。当共享内存被 mmap 文件占满后,系统可用内存急剧下降,内核在某个临界点触发了 OOM Killer,而它选中的“牺牲品”,恰恰是内存占用最大的 Java 进程。

那么,正常线上应该用哪种策略?我的建议是:

  • 如果业务对内存申请成功率要求极高,且能接受 OOM 风险,可以设置为 1(总是允许)。但要注意,这种策略下 OOM Killer 的杀伤力更大,因为系统可能已经严重超卖,一旦触发,往往是批量杀进程。
  • 如果业务对稳定性要求极高,宁可启动失败也不愿意运行中被杀,可以设置为 2(禁止过量)。这种策略最安全,但 JVM 这类需要预留大内存的应用,启动时可能因为内存申请被拒而直接报错。
  • 对于大多数线上 Java 服务,我建议保持默认的 0(启发式策略),但前提是必须做好两件事:一是监控好系统内存的“隐性占用”(比如共享内存、Slab、不可回收的 mmap),二是给 JVM 和系统预留足够的安全余量。本次问题恰恰是栽在了“隐性占用”上——共享内存的增长完全不在常规监控视野内,等到发现时,系统已经被拖垮了。

换句话说,策略本身没有绝对的好坏,关键是要理解每种策略的取舍,并结合自己的业务场景和监控能力来选择。对我们这次的问题而言,即使把策略改成 2,也只能避免 JVM 被误杀,但根本问题——tmp 目录下不断增长的 mmap 文件——不解决,内存迟早还是会被耗尽。

9. 复盘与总结

这次排查,从“Java 进程神秘退出”到“共享内存持续增长”,再到“tmp 目录临时文件被 mmap 映射”,整个过程像剥洋葱一样,一层一层揭开真相。复盘下来,有几个关键点值得记住:

  • JVM 占用的内存,远不止堆那点。排查内存问题时,一定要看 RSS 和 Native Memory,而不是只看堆大小。
  • 系统内存的“隐性占用”是最大的坑。共享内存、Slab、不可回收的 mmap,这些都不在常规监控里,但它们可能悄悄吃掉几十 G 内存。
  • OOM Killer 杀进程,不代表进程有问题。它只是“按内存占用评分”选了个最大的,很多时候是替别人背锅。
  • mmap 临时文件要警惕。业务代码里如果用了内存映射文件,一定要确保文件有上限、映射有释放,否则就是一颗定时炸弹。

最后,我把这次的经验沉淀成了一条监控规则:除了常规的 CPU、内存、磁盘,一定要把 /proc/meminfo 里的 Shmem 和 Slab 纳入监控,一旦发现持续增长且不回落,就要立刻排查,别等到 OOM Killer 替你“报警”。