1. 问题现象与初步感知:什么是Tomcat“假死”?
在线上服务运维中,Tomcat的“假死”状态绝对是一个让人血压飙升的经典问题。它不像服务彻底崩溃那样干脆利落,会留下明确的错误日志和进程退出的信号。恰恰相反,从外部监控看,Tomcat进程依然健在,ps命令或者podman ps(如果你在用容器)显示状态为“Up”,可能已经运行了“18 minutes ago”甚至更久。但当你尝试访问应用时,浏览器却一直转圈,最终超时;或者健康检查接口(比如/actuator/health)返回失败。这种“进程活着,但服务不响应”的尴尬局面,就是我们常说的“假死”。
我遇到过最典型的一次是,一个核心交易服务在凌晨流量低谷时一切正常,但一到早高峰,响应时间曲线就像坐上了火箭,直冲云霄,然后彻底平直——没有任何新的成功响应了。登录服务器,curl localhost:8080直接卡住,但Tomcat的进程ID还在,jstack命令甚至能打出线程栈,可业务就是不动了。这种问题排查起来,就像给一个还有心跳和呼吸,但对外界刺激毫无反应的病人做诊断,需要一套系统性的“体检”流程。
2. 系统性排查思路与工具箱准备
面对假死,切忌无头苍蝇似的乱试。一个高效的排查思路应该像老中医的“望闻问切”,由外而内,层层递进。首先,你需要准备好你的“手术刀”工具箱。对于Java应用,以下几把刀是必备的:
- JDK命令行工具:这是最基础也是最强大的原生工具集。确保你的服务器上安装了与应用匹配的JDK(注意:
jdk和tomcat的版本有要求吗?通常建议使用Tomcat官方推荐的兼容版本,避免已知Bug)。jps或ps -ef | grep java:快速定位Java进程PID。jstack <pid>:获取Java进程的线程转储(Thread Dump),这是分析线程状态的核心依据。通常需要连续打2-3次(间隔5-10秒),通过对比观察线程状态的变化。jmap -heap <pid>:查看堆内存概要信息。jmap -histo:live <pid>:查看堆内存中对象的统计信息,快速定位疑似内存泄漏的大对象。jstat -gcutil <pid> 1000 10:每1秒采样一次GC情况,共10次,用于观察GC频率和耗时。
- 系统级命令:
netstat -antp | grep <pid>或ss -antp | grep <pid>:查看该进程持有的所有网络连接状态。重点关注CLOSE_WAIT、TIME_WAIT数量是否异常增多。top -Hp <pid>:查看该进程内各个线程的CPU和内存占用情况。可以结合jstack的线程ID(nid,通常是十六进制)来定位消耗资源的线程。vmstat 1或sar:查看系统整体的CPU、内存、IO等待情况。
- 日志分析:这是“问诊”的关键。集中查看假死时间点前后(通常前后5-10分钟)的日志:
- Tomcat日志:
catalina.out,localhost.log,localhost_access_log. - 应用日志:你的Spring Boot、Spring MVC或其他框架的日志文件。
- GC日志:如果配置了JVM参数
-Xloggc,这里会有最详细的垃圾回收记录。
- Tomcat日志:
注意:在生产环境执行这些命令,尤其是
jmap和频繁的jstack,本身会带来一定的性能开销(Stop-The-World),可能会加剧问题或影响正常请求。务必在业务低峰期操作,或通过监控系统预设的报警触发自动抓取。
3. 核心排查路径一:线程死锁与资源竞争
拿到线程转储(Thread Dump)后,我们首先要排查的就是经典的死锁问题。用文本编辑器打开jstack输出的文件,直接搜索“deadlock”或“Found one Java-level deadlock:”。如果幸运(或者说是不幸)地找到了,jstack通常会清晰地指出哪些线程在互相等待哪些锁。
但更多时候,假死并非严格的死锁,而是线程池耗尽或资源竞争导致的全局性阻塞。这时,你需要分析线程的“状态”(State)。重点关注以下几种状态:
BLOCKED (on object monitor):线程在等待进入一个同步方法或代码块。如果大量业务线程(比如http-nio-8080-exec-*)都阻塞在同一个锁对象上,说明存在热点锁竞争。这可能是因为不恰当地在方法上使用了synchronized,或者错误地使用了一个全局锁(例如一个静态的HashMap进行频繁的读写)。WAITING (parking):线程在等待某个条件(如Object.wait())。这常见于任务队列已满,工作线程无事可做。RUNNABLE:线程正在执行。但如果一个线程长时间处于RUNNABLE状态且一直持有锁,也可能导致其他线程阻塞。需要看它正在执行什么代码。
实操分析示例: 假设你的线程转储里,有30个http-nio-8080-exec-5这样的线程,状态都是BLOCKED,并且都在等待锁0x00000000f4a1c0d8。通过查找哪个线程持有这个锁(jstack会显示locked <0x00000000f4a1c0d8>),你发现是一个名为AsyncProcessorThread的线程持有。进一步看这个线程的栈,发现它卡在了一个数据库查询或者一个缓慢的外部HTTP调用上。根源就找到了:一个慢操作持有了公共锁,阻塞了所有Web请求线程。
关于HashMap的潜在风险:在排查时,如果看到大量线程栈中涉及HashMap的put或get操作,尤其是在并发环境下使用未同步的HashMap,这本身就是一个风险点。虽然HashMap的线程不安全通常表现为数据错乱而非直接死锁,但在Java 7及之前版本,高并发put导致扩容时可能形成循环链表,引发CPU飙升。更常见的是,开发者用一个静态的HashMap作为缓存,却没有做好并发控制(如使用ConcurrentHashMap或加锁),导致多个线程修改时内部状态不一致,也可能间接引发问题。
4. 核心排查路径二:内存泄漏与GC风暴
内存问题导致的假死非常隐蔽。现象可能是服务响应越来越慢,最后停滞。此时,jstat是你的第一道探测器。
运行jstat -gcutil <pid> 1000,观察关键指标:
O(Old Generation利用率):如果持续保持在95%以上甚至100%,说明老年代快满了。FGC/FGCT(Full GC次数/耗时):如果FGC在短时间内疯狂上涨,FGCT耗时很长(例如每次都要数秒),说明系统正在经历“GC风暴”。Full GC会暂停所有应用线程(Stop-The-World),如果频繁发生且耗时久,从外部看就是服务间歇性或持续性地无响应。
下一步,用jmap揪出元凶:
jmap -histo:live <pid> | head -50查看存活对象中数量最多、占用空间最大的类。常见嫌疑犯是自定义的类、char[](字符串)、byte[](网络传输、文件操作)以及一些框架内部对象。- 如果怀疑是内存泄漏,可以生成堆转储文件进行深度分析:
jmap -dump:live,format=b,file=heap.hprof <pid>。然后用MAT(Memory Analyzer Tool)或JVisualVM打开这个.hprof文件。MAT的“Leak Suspects Report”功能非常强大,能自动分析出可能泄漏的对象引用链。
典型的内存泄漏场景:
- 静态集合类滥用:例如,在
HashMap或List中缓存了用户会话对象、查询结果集,并且只添加不清理。 - 未关闭的资源:数据库连接、文件流、网络连接(
HttpClient)未在finally块中关闭。 - 线程局部变量(ThreadLocal)使用不当:特别是在使用线程池(Tomcat的请求处理就是线程池)时,如果
ThreadLocal中存放大对象且用完后未调用remove(),则该对象会在线程存活期间一直存在,因为线程池的线程是会复用的。 - 第三方库或框架的Bug:某些旧版本的框架或连接池可能存在已知的内存泄漏问题。
5. 核心排查路径三:网络连接与IO问题
当应用大量依赖外部服务(数据库、缓存、微服务)时,网络问题会直接传导至应用层,造成假死。这里的关键命令是netstat或ss。
执行netstat -antp | grep <tomcat_pid>,仔细查看连接状态:
- 海量的
CLOSE_WAIT状态连接:这是最经典的信号之一。CLOSE_WAIT表示对方(客户端)已经关闭了连接(发送了FIN),但我方(服务端)的应用代码没有正确地关闭套接字。如果CLOSE_WAIT连接数持续增长,会快速耗尽系统的可用端口和文件描述符,导致新的连接无法建立。根本原因通常是应用没有在finally块中关闭网络资源(如数据库连接、HTTP连接)。 - 大量的
TIME_WAIT连接:这在高并发短连接场景下比较常见。如果数量过多(数万),可能会占满本地端口。可以调整系统内核参数(如net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle,但需谨慎)来缓解,但更优解是优化应用,使用连接池(如数据库连接池DBCP/HikariCP,HTTP连接池)来复用长连接。
IO阻塞导致的线程停顿: 除了网络IO,文件IO也可能成为瓶颈。如果线程转储显示大量线程处于RUNNABLE状态,但栈顶是java.io.FileInputStream.read或类似的Native方法,并且持续很久,说明可能正在读写一个巨大的文件,或者磁盘IO性能极差(使用iostat命令验证)。这会导致处理该请求的线程被长时间占用,如果并发请求都涉及IO,线程池很快就会被耗尽的等待IO的线程占满。
6. 实战排查流程与问题复现模拟
让我们模拟一个完整的排查流程。假设监控报警:服务无响应,但进程存活。
第一步:快速状态确认
ssh登录服务器,ps -ef | grep tomcat获取PID。curl -m 5 http://localhost:8080/health测试应用,确认超时。tail -100f logs/catalina.out查看最新日志,有无异常堆栈(如OutOfMemoryError)。
第二步:抓取即时快照
jstack -l <PID> > jstack_$(date +%s).log抓取第一次线程转储。jstat -gcutil <PID> 1000 5观察GC情况。netstat -antp | grep <PID> | wc -l以及netstat -ant | grep CLOSE_WAIT | wc -l统计连接数。
第三步:分析并定位可能方向
- 如果
jstat显示FGC频繁且Old区满,重点怀疑内存泄漏,立即用jmap -histo查看对象。 - 如果
netstat显示CLOSE_WAIT上千,重点怀疑连接未关闭,去代码中检查所有网络操作。 - 如果以上都正常,重点分析线程转储。将
jstack日志文件上传到在线分析工具(如fastthread.io)或使用本地的jstack分析脚本,查看线程状态分布。发现80%的线程是BLOCKED状态?立刻查找他们等待的锁和持有锁的线程在做什么。
第四步:根因验证与修复找到可疑点后,需要结合代码和日志进行验证。例如,怀疑是某个查询慢导致锁竞争,就去数据库慢查询日志中对应时间点查找;怀疑是内存泄漏,就分析jmap输出的顶级对象所属的类,在代码中查找该类的引用点。
实操心得:很多时候问题不是单一的。可能是内存缓慢泄漏,导致Full GC越来越频繁,每次GC停顿时间变长,使得部分请求超时;超时的请求客户端断开,产生
CLOSE_WAIT;同时,GC停顿又使得线程处理变慢,任务队列堆积,最终全面崩溃。因此,需要综合多项指标判断。
7. 常见问题速查与预防措施
根据经验,我整理了一份Tomcat假死常见原因速查表,你可以像查字典一样对照症状找可能原因:
| 现象/监控指标 | 可能原因 | 排查工具与重点 |
|---|---|---|
| CPU使用率正常,但请求无响应 | 1.线程死锁2.全局锁竞争3.外部依赖(DB、API)超时 | jstack看线程状态和锁信息;检查外部服务监控。 |
| CPU使用率100% | 1.无限循环/递归2.频繁的Young GC3.HashMap并发扩容(Java 7) | top -Hp找高CPU线程,jstack对应nid看栈;jstat看GC。 |
| 内存使用率持续增长,FGC频繁 | 内存泄漏 | jmap -histo,jmap -dump;MAT分析堆转储。 |
CLOSE_WAIT连接数异常高 | 网络连接未正确关闭(Socket, DB Connection, HttpClient) | netstat;代码审查finally块中的资源关闭。 |
| 磁盘IO等待高(%util) | 日志打印过频、同步写文件、磁盘慢 | iostat -x 1;检查日志配置(如Logback的immediateFlush)。 |
| 线程池活跃线程数达最大值 | 1.任务处理过慢(慢SQL、慢逻辑) 2.任务队列满 | 调整maxThreads、acceptCount;优化业务逻辑。 |
预防胜于治疗,一些有效的预防措施包括:
- 代码层面:
- 避免使用
synchronized修饰整个方法或使用粗粒度锁。优先考虑并发容器(ConcurrentHashMap)、显式锁(ReentrantLock)或无锁设计。 - 对静态集合类(如用作缓存的
HashMap)的访问必须做好同步,或直接使用ConcurrentHashMap。 - 所有
InputStream、OutputStream、Connection、HttpClient等资源,必须在try-with-resources或finally块中确保关闭。 - 谨慎使用
ThreadLocal,用完后务必调用remove()。
- 避免使用
- 配置层面:
- 为JVM配置合理的堆大小(
-Xms,-Xmx)和GC参数,并务必开启GC日志(-Xloggc:... -XX:+PrintGCDetails -XX:+PrintGCDateStamps)。 - 配置Tomcat的连接器(Connector)参数,如
maxThreads(处理请求的最大线程数)、acceptCount(等待队列长度),使其与你的硬件和业务负载匹配。 - 使用Druid、HikariCP等成熟的连接池,并配置合理的超时时间(连接超时、查询超时、空闲检测)。
- 为JVM配置合理的堆大小(
- 监控层面:
- 建立完善的应用监控:JVM内存、GC次数与时间、线程池状态、关键接口响应时间与QPS。
- 设置关键指标报警:如Full GC频率、线程池活跃度、
CLOSE_WAIT数量、接口超时率等。
8. 高级工具与持续 profiling
对于更复杂、间歇性出现的问题,上述一次性快照可能不够。这时需要引入持续性的Profiling工具,记录一段时间内的应用行为。
- Arthas:阿里开源的Java诊断神器,堪称线上排查的瑞士军刀。它可以在不重启应用的情况下,动态跟踪方法调用耗时、查看方法入参返回值、监控线程状态、甚至热修改代码。例如,使用
trace命令追踪某个慢方法的调用路径和耗时,使用thread命令查看所有线程的繁忙状态,比反复执行jstack更方便。 - APM工具:如SkyWalking、Pinpoint。它们通过字节码增强技术,自动追踪每一次请求的完整调用链,包括跨服务的调用。当发生假死或慢请求时,你可以清晰地看到时间消耗在哪个服务、哪个数据库语句、甚至哪一行代码上。这对于微服务架构下的问题定位是革命性的。
- JMX与VisualVM:对于测试或预发环境,可以通过JMX远程连接,使用VisualVM进行实时的可视化监控,包括CPU、内存、线程的图表,以及抽样器(Sampler)来定位CPU热点或内存分配热点。
排查Tomcat假死问题,是一个综合运用操作系统、网络、JVM和应用知识的过程。它没有一成不变的答案,但遵循“由外而内、先整体后局部、抓取快照对比分析”的思路,结合强大的工具,绝大多数问题都能被定位。最后记住,每一次线上问题的解决,都是对系统脆弱点的一次认知升级,把排查过程中发现的问题根因,转化为代码规范、配置检查清单和监控报警项,才能让系统越发稳健。