Java 进程凌晨无日志退出?图解 Linux OOM Killer 完整排查过程

发布时间:2026/10/31 2:11:25
Java 进程凌晨无日志退出?图解 Linux OOM Killer 完整排查过程 凌晨 01:49一个每分钟必跑的定时任务突然不再执行Java 进程无声无息地没了——没有异常、没有堆栈、没有 ShutdownHook连句报错都没留下。背景一台 2 核 2 GiB 的入门级服务器先交代一下环境。服务跑在阿里云 ECS 上2 核 vCPU、2 GiB 内存——就是那种入门级配置一年几百块的那种。上面跑着 MySQL、Redis、Nginx还有这个 SpringBoot 应用。说实话买的时候我心里是有数的这配置紧巴巴的。但想着小服务访问量不大应该扛得住。上周有个早上我照常打开后台发现服务挂了。不是报错不是 500是彻底连不上。登上服务器一看Java 进程已经退出了。看日志最后一条停在凌晨 01:49:00然后就是一片空白。而我发现的时候已经是早上八点半——中间整整六个半小时服务无法访问没有一个人知道。这事儿最反常的地方在于进程退出了但不是因为程序报错。日志里没有一行报错没有异常堆栈什么都没有。我把整个排查过程记下来因为这事儿的排查思路挺有意思——它不是靠猜是靠排除法一层层剥下去最后发现根因根本不在 Java 应用本身。一、故障现场一条日志也没有留下先看现场。startup.out是 SpringBoot 的启动输出我把它翻到最后01:49:00.123 [QuartzScheduler_Worker] INFO c.s.q.task.OrderCloseTask - 开始执行关单任务 01:49:00.128 [QuartzScheduler_Worker] INFO c.s.q.task.OrderCloseTask - 本次扫描到 0 条超时订单就这两行后面没了。这个OrderCloseTask是每分钟跑一次的定时任务风雨无阻。一个每分钟必响的闹钟在某一天突然不响了——说明进程是在这之后的一分钟内退出的。时间点卡得非常准。第一反应当然是 OOM。Java 进程内存溢出退出这是最常见的情况。但我搜遍了整个日志文件grep-iOutOfMemorystartup.out# 0 结果一个都没有。二、关键转折现场没有留下退出日志这里要讲一个关键知识点也是整个排查的转折点。Java 进程的退出方式有四种每种留下的痕迹完全不同退出方式触发方式留下什么正常退出System.exit()、点停止按钮完整的 shutdown 日志信号终止kill -15SIGTERM完整的 shutdown 日志内存溢出JVM 抛 OutOfMemoryError异常堆栈 heapdump 文件强制终止kill -9SIGKILL什么都没有前三种JVM 都有机会执行善后处理——它会运行 SpringBoot 注册的关闭钩子ShutdownHook把连接池、线程池、定时任务一个个优雅关闭每关闭一个记一行日志。第四种是操作系统直接强制终止JVM 没有任何处理机会。那么我的进程是哪种我把日志里所有的关闭记录都捞出来看grep-nShutdownHookstartup.out结果很有意思。前面几次停止都留下了这样完整的关闭日志21:47:44 [SpringApplicationShutdownHook] Shutting down ExecutorService 21:47:44 [SpringApplicationShutdownHook] QuartzScheduler shutdown complete 21:47:44 [SpringApplicationShutdownHook] 关闭后台任务任务线程池 21:47:44 [SpringApplicationShutdownHook] {dataSource-1} closed看这个线程名SpringApplicationShutdownHook。这就是我说的善后处理——进程收到停止信号后SpringBoot 认认真真地把 Quartz 停了、线程池关了、数据库连接池也关了每做一件都记一笔。而 01:49 那次呢一行都没有。这就是分水岭。同样是进程退出前面几次是走完流程的正常退出最后一次是没有任何日志就结束了。三、排除内存溢出JVM 并不是因为 OOM 退出的既然不是正常退出那是不是 JVM 内存溢出了第三种退出方式这时候我要找两样东西。我在启动脚本里是开了 OOM 快照的-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath.../heapdump.hprof这个参数的意思是一旦发生 OOM先把堆内存整个拍张快照存下来再退出。真发生 OOM 了一定会生成一个 hprof 文件而且这个文件通常很大几百 MB 起步。ls-lalogs/heapdump.hprof# No such file or directory没有。再翻 GC 日志垃圾回收日志找 Full GC——Full GC 是 JVM 回收内存的最后手段堆快撑满的时候会频繁触发grep-cFull GCgc.log# 0一次都没有。这两条合在一起结论已经很清楚了堆还没撑满JVM 还没来得及抛 OutOfMemoryError。进程是在正常运行时被外部强制终止的。不是程序自身异常而是来自系统层面的强制终止。四、根因浮现内核的 OOM KillerLinux 进程被终止的真正原因既然应用层查不出问题那就往下看操作系统。我打开 GC 日志的第一行——JVM 启动时会把当时系统的内存状况打印出来这是它留给我们的一张体检报告Memory: 4k page, physical 1777456k(579832k free), swap 0k(0k free)翻译成人话physical 1777456k—— 标称 2 GiB 的机器系统认到的总共只有1.7G579832k free—— JVM 启动时可用的只有566MBswap 0k—— 交换分区是0先算清楚这台机器到底有多少内存可用买的时候写的是2 GiB2048 MiB怎么到这里就只剩 1.7G 了这几笔扣减是跑不掉的扣减项大概多少说明内核与硬件保留100~200M内核自身、页表、硬件保留地址开机就占用了系统基础服务100~150Msshd、systemd-journald、云监控 agent阿里云自带等MySQL300~500M这台机器上的最大头Redis50~150M看数据量Nginx 其他50~100M一笔笔扣下来标称 2 GiB 的机器JVM 启动时能用的只剩566MB。这就是我前面说的账算不平——我当时给 Java 配的堆是 384M占了这 566MB 的68%。而且这还只是堆堆之外的部分一个字都没算进去下一节专门算这笔账。最后那个 swap 0k 是最刺眼的一行swap 是什么你可以把它理解成内存的备用空间。物理内存不够用的时候系统会把一些暂时不用的数据挪到硬盘上的 swap 里腾出空间给急用的程序。虽然慢但不至于当场不可用。而 swap 为 0意味着这台机器没有任何缓冲。内存一满就是硬碰硬。这里有个云服务器的坑要单独说一句阿里云 ECS 的默认镜像是不开 swap 的。官方出于云盘 IOPS 和使用寿命的考虑默认不启用很多云厂商都这样。这个策略对大内存机器没问题但对 2 GiB 这种小规格来说等于拆掉了安全缓冲。你可以用这条命令确认free-h# total used free shared buff/cache available# Mem: 1.7Gi 1.2Gi 120Mi 32Mi 400Mi 300Mi# Swap: 0B 0B 0B ← 看到了吗是 0我敢说不少在云上跑小规格 Java 应用的人free -h里 Swap 那一行都是 0只是平时没注意直到某天进程凌晨莫名退出。那内存满了吗我继续看 GC 日志里的堆占用趋势——这是最有力的一条证据21:51 Heap: 49M → (堆容量 256M) 22:15 Heap: 87M → 22:43 Heap: 132M → 23:09 Heap: 178M → 23:27 Heap: 234M → (堆容量已扩到 365M)看出问题了吗每次垃圾回收之后堆占用只涨不落。49M → 87M → 132M → 178M → 234M一条直线往上走从来没有回头。这就是内存泄漏的典型特征。健康的应用内存曲线应该是锯齿状——涨上去GC 一回收唰地掉下来再涨再掉。而这条曲线只有上升沿没有下降沿说明有些对象回收不掉一直在堆积。按这个速度从 21:51 跑到凌晨 01:49四个小时堆占用只会比 234M 更高。加上堆之外的开销下面会算这笔账JVM 的实际物理内存占用冲到 700MB 以上完全合理。而系统总共 1.7G还要跑 MySQL、Redis、Nginx启动时可用就只剩 566MB。账算不平了。这时候 Linux 内核会做一件很直接的事它会启动一个叫OOM KillerOut of Memory Killer的机制在所有进程里挑选内存占用最大的直接发送SIGKILL信号将其终止用释放出来的内存保证系统继续运行。而SIGKILL就是第四种退出方式——直接强制终止没有善后处理的机会。到这里整个链条闭合了内存泄漏 → 堆只涨不落 → JVM 总内存逼近系统上限 → 系统内存耗尽 → 内核 OOM Killer 触发 → 发送 SIGKILL → 进程终止日志戛然而止没有 ShutdownHook没有 heapdump每一个现象都对得上。想直接看证据的可以跳到文末的排查命令dmesg -T | grep -i killed process一条就能确认。五、算一笔账为什么 -Xmx 384M 的进程会占用 700M 物理内存这里有个很大的认知误区我以前也踩过以为-Xmx384m就是这个进程最多用 384M。完全不是。-Xmx只限制堆这一块。而一个 Java 进程的物理内存RSS至少包括组成部分大概占用说明堆 Heap384M-Xmx控制的部分元空间 Metaspace100~150M存放类信息默认无上限线程栈100M每个线程 1M × 100 个线程堆外内存 DirectMemory384M默认等于 Xmx很多人不知道CodeCache30~50MJIT 编译后的代码GC 自身开销若干G1 的 remembered set 等其中堆外内存默认等于 Xmx这条最容易被忽略——你设了 384M 的堆JVM 默认又给自己预留了 384M 的堆外内存额度你根本没主动设置它。所以一个只配了 384M 堆的 Java 进程实际物理占用冲到 700M 一点都不奇怪。把整台机器的账摊开看现在把这 2 GiB 内存的分配情况摆出来问题就一目了然了占用方内存占用说明MySQL300~500M大头小规格也省不下来Redis50~150M看数据量Nginx 系统进程50~100MJava 进程500M 起步泄漏后 700M持续在增长合计接近甚至超过 1.7G看出来了吗这台机器上Java 进程是唯一一个内存会持续增长的。MySQL 和 Redis 启动后内存基本稳定而 Java 因为内存泄漏堆占用一路从 49M 涨到 234M 还在往上走。这就好比几个人共享一个固定大小的空间其中一方占用的资源还在不断膨胀——资源迟早会不够分配。而内核的处理策略是优先终止占用内存最多的进程。顺带说一句 CPU2 核 vCPU 跑 SpringBoot MySQL 本身够用但如果你买的是突发性能实例t 系列还有个 CPU 积分机制——平时攒积分高负载时消耗积分积分用完 CPU 会被限制到基准性能通常 10%~15%。内存紧张的时候 GC 会更频繁CPU 再被限制响应会明显变慢。这次问题里 CPU 不是主因但小规格机器上这两样往往是连着出现的。六、五个 JVM 参数与 SpringBoot 配置误区根因找到了但真正的问题源头是这些配置。我一个个说。先说句公道话这些配置单独拿出来很多都不算错。800 线程放在 8 核 16G 的机器上完全合理debug 日志在排查期开着也正常。真正的问题是——配置没有跟着机器规格走。它们是从开发机或者更大的服务器上原样搬过来的而这台机器只有 2 核 2 GiB。误区 1生产环境开着热部署spring:devtools:restart:enabled:true# ← 生产环境不该开devtools 是开发时用的热部署工具改完代码自动重启。它为了实现不重启就能替换类会使用一个叫RestartClassLoader的自定义类加载器——而自定义类加载器是典型的内存泄漏来源类加载器无法卸载它加载过的所有类就一直留在 Metaspace 里。生产环境开这个没有必要。误区 2Tomcat 配了 800 个线程server:tomcat:threads:max:800min-spare:100# ← 一启动就先创建 100 个线程注意min-spare: 100是启动即创建。每个线程栈默认 1M100 个线程就是 100MB 物理内存而且这部分不在堆里GC 管不着。一台总共 1.7G 内存的机器配 800 线程 384M 堆配置和资源明显不匹配运行起来很容易出问题。误区 3生产环境日志开到 debuglogging:level:com.sourcecode:debug# ← 每条 SQL 都打印debug 级别下MyBatis 会把每一条 SQL 语句、每一个参数都打印出来。这意味着每次数据库查询都要拼字符串 → 编码 → 写缓冲区 → 落盘。这些临时字符串对象就是堆内存只涨不落的推手之一。我看了下日志10 个小时写了 6MB——量不算大但对象创建和销毁的频率很高堆就这么被一点点占用了。误区 4JVM 参数相互冲突-Xms256m-Xmx384m-Xmn128m-XX:DisableExplicitGC两个问题-Xmn128m和 G1 收集器冲突。G1 的核心能力就是动态调整年轻代大小-Xmn把它锁死了等于限制了 G1 的核心能力。-XX:DisableExplicitGC会影响堆外内存回收。有些框架比如用 Netty 的依赖显式调用System.gc()来回收堆外内存把这个禁用掉堆外内存就只涨不落。误区 5系统层 swap 为 0前面说过了。没有缓冲内存一满就是硬着陆。七、修复我做了这六件事按优先级排第一步加 swap最立竿见影fallocate-l2G /swapfilechmod600/swapfilemkswap/swapfileswapon/swapfileecho/swapfile none swap sw 0 0/etc/fstab# 顺手把 swappiness 调低只在真急用时才用 swapechovm.swappiness10/etc/sysctl.confsysctl-p这不解决根本问题但能避免进程被直接终止。有了 swap内存紧张时系统会先把数据挪到硬盘上缓冲而不是立刻强制终止进程。关于云服务器该不该开 swap网上一直有争议。反对的理由是云盘 IO 慢、有 IOPS 限制频繁换页会影响性能——这个顾虑没错所以我加了swappiness10默认 60意思是内存用到 90% 才考虑动用 swap平时完全不碰它。这样 swap 就纯粹是个应急缓冲平时不消耗 IO真到内存紧张时能缓冲一下。在这台 2 GiB 的机器上有这 2G 的兜底最坏情况也就是响应变慢而不是进程直接退出。第二步关掉生产环境的 devtoolsspring:devtools:restart:enabled:false第三步日志级别降到 infologging:level:com.sourcecode:info第四步Tomcat 线程数降下来threads:max:200min-spare:20第五步重调 JVM 参数删掉-Xmn128m# 不再和 G1 冲突删掉-XX:DisableExplicitGC# 换成 -XX:ExplicitGCInvokesConcurrent-Xms256m-Xmx384m→-Xms384m-Xmx640m# 给足空间留出缓冲新增-Xss512k# 线程栈减半100 线程省 50MB新增-XX:MaxDirectMemorySize96m# 限制默认等于 Xmx 的堆外内存新增-XX:MaxMetaspaceSize160m# 元空间设上限新增-XX:ExitOnOutOfMemoryError# 万一 OOM 直接退出避免异常状态新增-XX:NumberOfGCLogFiles5-XX:GCLogFileSize20M# GC 日志滚动避免占满磁盘第六步加进程守护这是解决服务不可用六小时没人知道的办法。我用 systemd[Service] Restarton-failure RestartSec15 MemoryMax900M MemoryHigh750MRestarton-failure让进程异常退出后自动拉起。MemoryMax900M这条容易被忽略但其实很重要——它改变了故障的影响范围没有它JVM 内存涨到系统扛不住由内核 OOM Killer 选择终止进程具体终止哪个并不确定有可能 MySQL 也被终止影响更大。有了它JVM 内存涨到 900M 就被 systemd 拦截并有序重启只重启 JavaMySQL、Redis 不受影响。这个 900M 是怎么定的堆 640M 元空间 160M 线程栈 CodeCache 堆外差不多就是 900M 这个量级。同时给整台机器留了余量Java 900M MySQL 500M Redis 150M Nginx/系统 100M ≈ 1.6G / 1.7G看得出余量极小——所以这一步必须配合前面的加 swap 一起做。如果实在不想开 swap就把MemoryMax降到 700M给系统多留一些空间。没有 systemd 的老系统用 crontab 也行* * * * * /home/sourcecode/server/server.sh guard/dev/null21每分钟检查一次进程不在就拉起。八、三条复盘经验第一没有报错本身就是最重要的线索。日志里空空如也不代表没发生过问题往往意味着问题发生在应用层看不见的地方。进程退出方式有四种看有没有 ShutdownHook就能把范围缩小一半。这是这次排查最关键的转折点。第二先算账再调参。很多人包括以前的我看到内存问题第一反应是把 Xmx 调大。但在这台 2 GiB 的机器上盲目调大 Xmx 只会更快出问题——你要算的是整个系统的账可用内存 标称内存 - 内核占用 - MySQL - Redis - Nginx JVM 实际占用 堆 元空间 线程栈 堆外内存 CodeCache我这边算下来可用只有 566MB而堆设了 384M占了 68%还没算堆外那一份——这样配置风险很高。小规格机器2 GiB 及以下配 Java 的一个经验值JVM 的-Xmx不要超过可用内存的50%同时一定要显式设置-XX:MaxDirectMemorySize和-XX:MaxMetaspaceSize把那些默认无上限的部分都限制住。留给系统的余量比给 JVM 的更重要——因为内核触发 OOM Killer 时并不看你设置了多大的堆。第三让故障可控比让它不发生更重要。内存泄漏的根治需要时间和耐心后续我打算用jmap -histo对比两次直方图找出增长最快的对象。但在此之前你完全可以通过加 swap、设置 MemoryMax、配置进程守护把一个**“服务不可用六小时无人知晓的故障降级成一个进程自动重启用户基本无感知”**的小波动。先止血再治本。这个顺序不能反。附几条能直接用的排查命令# 1. 确认是不是被 OOM Killer 终止的看到 Killed process 就是直接证据dmesg-T|grep-ikilled processjournalctl-k|grep-ikilled process# 2. 看内存和 swapfree-h# 3. 看堆占用趋势锯齿状健康只涨不落泄漏jmap-heappid# 4. 看哪类对象最多隔一小时抓两次对比增长最快的就是泄漏来源jmap-histo:livepid|head-20# 5. 看进程实际物理内存比 Xmx 大得多是正常的ps-orss-ppid|awk{print $1/1024 MB}最后说一句这次问题里最有价值的不是那几行配置改动是搞清楚了进程到底是因为什么退出的。知道了退出方式你才知道该去哪儿找原因不知道退出方式就只能反复调参数效率很低。而搞清楚退出方式的关键就在那一行缺失的SpringApplicationShutdownHook里。—— 它没出现本身就是最大的线索。如果这篇文章帮你省下了一次排查问题的时间欢迎点赞、收藏、关注有疑问评论区交流。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询