尧图精选

Java Web“僵尸进程”排查:不是OS僵尸,而是线程池与GC假死

🕒 发布时间:2026/9/28 7:30:16 📁 来源:尧图网络
凌晨两点被监控电话叫醒第一眼看到的就是Tomcat进程还活着、端口还在监听、但接口全部超时。ps里查进程状态正常netstat也能看到大量ESTABLISHED连接挂着就是没有任何响应。这种进程看起来没死业务却完全僵住的现场就是Java Web开发里最让人头疼的僵尸进程问题。注意标题里说的僵尸进程和操作系统教科书里的Z状态僵尸进程往往是两码事。作为后端开发者你必须先分清楚自己在排查哪一种否则方向错了折腾一晚上也解决不了问题。这篇文章就按我实际踩坑的顺序把这两类僵尸从头到尾拆一遍。1. 僵尸进程四个字在Java Web里其实说的是两件事1.1 操作系统里的Zombie它为什么赖着不走操作系统教科书定义的僵尸进程是Z状态defunct的进程。它的产生机制很简单子进程先退出父进程没有调用wait()或waitpid()回收子进程的退出状态那么子进程的进程表项就会一直留在内核里占着一个PID谁也不能用。这时候你用ps -ef能看到类似[java] defunct的记录。Java Web服务里最常见的产生场景是用ProcessBuilder或Runtime.exec()去调用外部命令比如用命令行的ffmpeg处理视频、调用mysqldump备份、跑一个外部的Python脚本。问题在于很多人只调用了process.waitFor()去等退出码但子进程退出之后如果Process对象没有被正确关闭、父进程Java代码本身也没有继续等待那么这个子进程就会短暂或长期地陷入Z状态。稍微补充一个Java层面的细节Java的Process实现底层在部分系统上会启动一个清理线程通过waitpid(WNOHANG)异步回收退出子进程所以严格来说单纯的一次子进程退出不一定立刻变成永久僵尸。但如果子进程的管道流没有被关闭、Process对象被遗留、或者你fork出来的进程本身又产生了子进程常见的shell套shell回收链路就可能断掉僵尸就会累积。每次累积一个PID终有一天进程数超过上限新的子进程就创建不出来了。1.2 更常见的僵死进程活着服务死了真正让Java Web开发者半夜爬起来处理的情况绝大部分不是操作系统的Z状态僵尸而是进程状态明明活着HTTP服务却彻底没有响应。我习惯把这种情况叫站死或假死。假死的典型表象是ps -ef里Java进程还在CPU可能很低也可能飙满端口仍然监听curl却超时监控显示线程数已经接近Tomcat的最大值数据库连接池、Redis连接池等资源被耗尽请求全部卡在业务代码或IO等待上形成雪崩。这种假死和操作系统的僵尸进程完全是两码事但因为外行的观感都是这系统僵住了所以经常被合并称为僵尸进程。如果你用ps查不到defunct记录那就别再纠结操作系统的回收机制了立刻转入到Java程序的线程级排查里。判断一次僵尸式无响应该走哪条路线我的经验是先看两条命令ps -ef | grep java确认有没有defunct再看jstack pid | head -100确认线程是不是都堵在某个业务栈上。前者命中走操作系统路线后者命中走应用排查路线。2. 先别重启一次完整的假死现场排查2.1 表象确认从监控告警到进程快照遇到假死我强烈建议你别急着kill -9重启。进程一旦被杀线程栈和堆现场就全没了等于放弃破案。正确做法是先做一次完整的现场快照保留证据再考虑恢复。第一步是拿到进程的全局状态。登录服务器后先执行ps -eo pid,ppid,stat,etime,%cpu,%mem,cmd | grep java重点看两列stat里有没有Z、进程的%CPU和%MEM是否是异常值。如果进程是Sl状态但CPU接近100%说明业务线程在疯狂循环运算如果CPU趋近于0多半是线程都阻塞在等待资源上。第二步看TCP连接。很多时候假死表现为连接积压netstat -antp | grep java-pid | awk {print $6} | sort | uniq -c | sort -nr如果SYN_RECV或者ESTABLISHED数量异常高先把连接数记录下来这是判断是否为连接堆积引发的重要参考。第三步才是看线程。我一般是按顺序执行下面这几条命令把输出都存成文件top -H -p pid # 看是哪个线程在消耗CPU jstack pid jstack.log jstat -gcutil pid 1000 10 gc.log jmap -heap pid heap.log注意jmap在Java 8之后的版本要小心执行它可能触发一次STW。但为了拿到堆信息该用还得用如果实在不敢用jmap -dump可以先jmap -heap看概览或者用jstat代替判断内存趋势。2.2 jstack线程转储的解读一眼看出谁在卡拿到jstack.log后先不要逐行读先用一条命令把线程状态的分布统计出来grep ^ jstack.log | awk {print $NF} | sort | uniq -c | sort -nr正常情况下WAITING/TIMED_WAITING会有但数量不会超过工作线程上限。如果出现大量RUNNABLE线程且都在CPU飙高说明是计算密集型的死循环或锁竞争如果大量WAITING线程的栈都停留在同一个锁对象上说明发生了严重的锁竞争或死锁。定位死锁也很直接。jstack文件末尾经常会出现Found one Java-level deadlock这样的段落它会列出两个线程各自持有锁和等待锁的信息。看到这段基本就实锤了。另一种常见情况是线程没有死锁但大量线程都卡在同一个方法上。比如我以前排查过一个线上问题日志里几百个线程全部停留在java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt这种栈十有八九是线程在等待一个队列或条件变量。往下翻栈帧就能看到具体是哪把锁、哪个队列。2.3 辅助工具jstat、jmap、top -H 怎么配合使用单独看jstack只能知道线程堵在哪里要判断为什么堵还得配合其他几个工具。jstat -gcutil连续采样几次能看到GC各区占用和GC次数。如果FGC每隔几秒就涨一次且每次Full GC的时间很长基本在日志里表现为FCT字段倒推一下那假死的直接推手就是垃圾回收停顿。这种场景下线程栈反而不一定有异常的锁你看到的可能是一片正常的业务线程在等待GC完成。top -H -p pid能把进程内的线程按CPU排序。挑出CPU最高的几个线程的十进制PID然后在jstack里用十六进制去找对应线程。具体操作用printf %x\n tid转成十六进制再grep线程栈。如果CPU最高的是一个GC线程那更说明要往GC方向查。jmap -heap或jmap -dump的作用是看堆概况或导出堆。假死场景下我会优先看Old区和元空间是否接近上限老年代长期高水位、元空间爆掉都会触发频繁Full GC最终变成假死。如果你想深挖对象内存泄漏最好把堆dump下来用MAT或者VisualVM分析Dominator Tree这个放到后面第5节再细说。排查顺序建议是先看线程状态分布再看CPU热点再看GC数据。因为线程层面能解释大部分假死原因GC层面解释剩余少部分而堆转储通常用于确认问题后的深入分析。3. 真正的ZombieJava外部命令子进程遗留与回收3.1 ProcessBuilder/Runtime.exec 的回收机制说实话Java Web项目里真正在操作系统层面产生僵尸进程的场景远比假死少见但一旦出现就很顽固。最常见的是通过Runtime.exec或ProcessBuilder去跑外部命令。先看一个典型的错误写法Process process Runtime.getRuntime().exec(mysqldump -u root db backup.sql); process.waitFor();这段代码看着没问题但实际隐藏着两个坑一是没有读子进程的输出流和错误流二是通过shell重定向后实际启动的可能是sh -c mysqldump ...这条命令如果又创建了子进程你waitFor的只是shell进程真正的mysqldump进程可能成为孤儿随后变成僵尸。正确的最小实现差不多是这样ProcessBuilder builder new ProcessBuilder(bash, -c, command); builder.redirectErrorStream(true); Process process builder.start(); try (BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { // 消费输出 } } int exitCode process.waitFor();这里两个关键动作redirectErrorStream(true)把错误流并入标准输出省得开两个线程去读必须把子进程的标准输出读干净否则子进程的管道缓冲区被写满会阻塞在写管道上表现为子进程不退出、Java主流程一直等。3.2 输出流没读子进程被管道堵死我第一次踩这个坑是在一个文件转换功能里用Java调ffmpeg转视频。本地测试正常上了生产环境部分任务就卡死。当时查业务日志没有任何异常老总觉得是Java进程僵死。后来我试着单独执行那条ffmpeg命令发现它其实一直在运行只是输出日志因为管道缓冲区满了完全推不出去。这个问题的本质是管道缓冲区有限通常只有几十KB。子进程把标准输出写到缓冲区如果Java侧不读缓冲区写满后子进程就会阻塞在write()系统调用上。如果父进程不退出子进程也不退出彼此就这么僵住。线程dump里你会看到Java线程停在InputStream.read()而外部子进程停在管道写等待。表面看是Java Web僵死实际上是管道没消费。解决思路很简单要么像上面那样始终消费输出流要么如果确实不需要输出就用redirectOutput(ProcessBuilder.Redirect.DISCARD)把输出重定向到/dev/null或某个日志文件彻底绕开管道。3.3 清理现场父进程没wait的僵尸该不该杀如果你已经确认系统里有Z状态的僵尸第一步不是kill -9而是要看清僵尸的父进程是谁。因为僵尸进程本身不消耗CPU和内存只占一个进程表项比如再创建40个就需要清理30个。直接kill僵尸进程通常无效除非把它的父进程杀掉或让父进程对僵尸执行wait。在Java Web场景里如果僵尸的父进程是你的Java进程那这个Java进程很可能在启动子进程后没有妥善关闭Process对象。正确做法是修改代码在waitFor()之后显式调用process.destroy()或destroyForcibly()并确保Process的引用不再被长期持有方便GC及时清理底层的文件描述符。如果问题已经发生、不想重启Java进程那可以确认一下僵尸的父进程是否已经到了18号内核回收进程kthreadd或者PID 1容器里的init如果是通常会被系统慢慢回收如果不是只能靠改代码发布新版本或者在运维侧用外部守护逻辑兜底。还有一种常见误判你看到的不一定是僵尸而是活着但永远结束不了的子进程比如shell脚本里sleep或等待网络IO的子任务。这种用pstree -p pid看一下进程树就清楚了别再傻傻等waitFor()该destroyForcibly()就果断调用。4. 假死元凶之一线程池与连接池集体瘫痪4.1 Tomcat工作线程被打满的典型特征假死现场里我遇到过最多的是线程池被打满。Tomcat默认的工作线程池由maxThreads决定常规配置是200意味着同时最多只有200个请求在处理。当慢请求把工作线程全部占用后新的请求只能排队如果队列acceptCount也满了多余的连接直接被拒绝或丢弃。这种情况下jstack的特征非常明显有大量线程的栈都停留在业务代码的远程调用处比如httpclient.execute、RestTemplate.exchange、FeignBlockingLoadBalancerClient.execute或者数据库JDBC的ConnectionImpl等待上。它们都处于WAITING或TIMED_WAITING状态而不是正常返回。如果你不想等一下午的jstack解读可以先用一条命令看活动线程数curl -s http://localhost:8080/actuator/metrics/tomcat.threads.busy返回的busy数量如果长期等于maxThreads的设置值那结论就八九不离十了。4.2 数据库连接池耗尽慢SQL如何拖垮全站线程池耗尽背后最典型的推手是数据库连接池耗尽。HikariCP默认maximumPoolSize是10一个Tomcat工作线程处理请求时通常会获取一个数据库连接。如果10个连接都被占用且每个查询都要五秒才返回那么能同时处理SQL的请求只有10个这10个请求阻塞后又会在Tomcat线程池里等待新的连接于是整个应用就被这10条慢SQL拖死。排查连接池耗尽在jstack里找这样的栈at com.zaxxer.hikari.pool.HikariPool.getConnection at com.zaxxer.hikari.pool.HikariPool.getConnection at org.springframework.jdbc.datasource.DataSourceUtils.getConnection大量线程停在这里基本可以确定是获取连接超时。配合数据库侧看show processlist经常能发现一堆长时间不结束的Sleep或Query状态会话。这些会话不释放连接池就一直是满的。4.3 真正的解法不是调大参数遇到线程池和连接池耗尽经验不足的运维第一反应是调大maxThreads和maximumPoolSize。我见过把maxThreads调到1000、连接池调到200的配置结果不是变好了而是数据库被压垮、内存直接飙升故障时间反而延长。参数调大只能给系统争取几分钟的喘息治标不治本。真正有效的做法是分层处理给所有外部调用配置合理的超时比如HTTP客户端的连接超时和读取超时都设置3秒以内SQL执行时间用MyBatis的defaultStatementTimeout控制把非核心流程异步化比如发消息、写审计日志、生成报表从同步链路里剥离用消息队列或独立线程池处理对下游不稳定接口做熔断一旦连续失败率超过阈值直接返回兜底结果不再占用线程核心链路与非核心链路做线程池隔离避免一次批量导出任务把Web请求线程全部占光。这些才是让系统远离僵死的根本设计。5. 假死元凶之二GC停滞与内存异常5.1 Full GC引起的世界停顿线程池和连接池都查完了、没发现明显堵塞时下一个怀疑对象就是GC。jstat -gcutil如果显示FGC次数在持续增长而且每次Full GC的耗时都很长那Java进程会频繁进入世界停顿状态表现为所有请求都像被冻住一样。用命令采样一下jstat -gcutil pid 1000 10每次采样间隔1秒如果看到O老年代从70%一路涨到95%以上接着触发FGC然后下降到50%左右又慢慢涨上去这个周期反复出现就是典型的内存持续增长导致频繁Full GC。Full GC长时间的另一个常见原因是堆太大但分配不均或者交换空间swap被使用。当JVM堆的一部分被换出到磁盘GC扫描时就要从磁盘读回停顿时间可能长达数十秒。这种场景排查完内存后还要检查服务器可用内存和交换分区配置别只看JVM参数。5.2 堆内存和GC日志怎么看拿到jmap -heap输出后重点看几个值Eden Space、Old Space的使用比例以及元空间Metaspace是否接近上限。老年代反复增长说明存在业务数据长期驻留比如缓存了不该缓存的大对象、静态集合不断增长、或者某些框架的类加载器泄漏。建议线上JVM至少加上这样的GC日志配置否则事后排查根本没有证据-Xlog:gc*:/data/logs/gc.log:time,uptime,level:filecount10,filesize10m注意Java 8的写法还是-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJava 11以上建议用上面的模块化日志方案。加了日志后Full GC前后的对象迁移情况就能看得清清楚楚。5.3 常驻内存泄漏的定位jmap与MAT如果确认GC不健康接着要回答谁占着内存不放。这时需要堆转储jmap -dump:live,formatb,file/data/dump.hprof pid文件会很大几十MB到几GB都有可能记得在低峰期、磁盘空间充足时执行。拿到dump文件后用MAT打开看Leak Suspects报告和Dominator Tree通常能直接看到某个HashMap、ArrayList或缓存对象占了几百MB再看引用链条就能定位到业务代码里的静态集合或者框架缓存。有一次我们排查一个每天早上10点准时假死的问题最后在MAT里看到一个基于ConcurrentHashMap的本地缓存每天记录用户操作痕迹value是一个不断增长的List既没设过期也没做容量上限。这类问题的特点是刚发布完系统一切正常运行几周后GC逐渐恶化最终在某段流量高峰期触发连续Full GC服务直接僵死。6. 防御性设计让系统很难走进僵死状态6.1 超时、熔断、隔离三项基本设置僵尸进程问题的可怕之处在于它的爆发往往是压死骆驼的最后一根稻草而骆驼其实已经被一根一根地压了很久。所以比事后排查更重要的是让系统设计本身带有不死的能力。首先是超时。HTTP客户端、JDBC连接、Redis操作、消息发送所有涉及外部的调用都必须有明确的超时时间。没有超时的代码就是一颗定时炸弹。我常用的参考配置HTTP连接超时1~3秒HTTP读取超时3~5秒JDBC连接获取超时3秒SQL执行超时5~10秒Redis读写超时1~2秒。其次是熔断。用Resilience4j或Sentinel给每个下游依赖设置独立的熔断阈值。下游连续失败时快速打开熔断器让请求走降级逻辑不再阻塞等待。然后是隔离。即使一个上游项目突发高流量也只能占满它自己的线程池不允许挤占其他核心接口的资源。Spring的Async要单独定义线程池不能用同一个Executor到处提交任务。6.2 健康检查别只看端口很多系统的僵尸进程问题在容器化和云上环境里不是被解决的而是被重启解决的但前提是你的健康检查得能真正发现问题。Kubernetes的livenessProbe如果只检查端口存活那当线程池耗尽、应用假死但端口还在监听时探针永远不会失败服务也不会被自动重启。我建议健康检查接口做成联动式比如暴露一个/health/check检查数据库连接池是否还有空闲连接检查线程池的活跃线程数有没有超过阈值检查最近一次Full GC时间和距离当前的时间间隔检查关键下游依赖Redis、消息队列是否可连接。只要任一关键指标异常就返回非200状态。这样编排平台才能及时重启实例把故障掐在用户感知之前。6.3 留好救命通道预降级与线程数自适应最后一次实战经验在一个大促项目中我们把所有核心接口都设计了一个逃生舱。当线程池活跃度超过80%时自动拒绝非核心请求直接返回一个降级响应同时把流量引导到静态缓存或备用方案。这条规则能保证系统在最坏情况下仍然保留处理核心请求的能力而不是所有线程陷入等待链里一起等死。另外补充一个和小白相关的经验在生产环境写Java Web代码时尽量别用Runtime.exec去调外部命令如果必须调也别在主线程里同步等一个永远不会结束的子进程。优先使用java.util.concurrent的CompletableFuture把子进程结果的等待放到独立线程里设置超时后强制清理。我们团队现在的规范是所有外部子进程调用必须实现可超时、可取消、日志完整三个条件少一个都不允许上线。回到文章开头说的那个凌晨告警。我按这个顺序排查先ps确认没有系统僵尸再用jstack看到大量线程卡在数据库连接获取接着jstat发现老年代在涨最后定位到一段无限循环写入日志的代码导致数据库连接迟迟不释放。整个过程没有重启一次服务在一个配置变更后自行恢复。遇到Java Web僵尸进程最忌讳的不是没招而是太急着杀进程把唯一的线索亲手掐断。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →