凌晨两点多被电话叫起来,值班的同学说订单查询接口全线超时,监控大盘上 P99 直接顶到三十秒,可服务进程还在、端口还在监听、CPU 不高不低地挂在 40% 左右,日志一动不动。第一反应是"进程挂了",结果ps一看 PID 活得好好的,kill -0也返回成功。这种状态我们私下里都叫它"僵尸进程",但要真去ps aux | grep Z找 defunct,一个都找不到。这玩意儿的本质是个 Java Web 服务在应用层"假死"——进程活着,业务死了。这类问题最容易出现在两块地方:一是线程池被自己堵死,二是 GC 把整个世界停住了。前者是"线程池假死",后者是"GC 假死",它们长得像,排查路径却完全不同。下面我把自己这几年踩过的坑、用过的命令、算过的参数一次讲透,从现象判定到取证流程到修复验证,能直接抄作业,也能帮你建立一套以后遇到同类问题不用慌的判断框架。不管你是刚接手线上服务的新人,还是带团队的老兵,这篇都值得存下来。
1. 先把"僵尸"这两个字拆开:你看到的假死至少分三种
1.1 OS 上的 defunct 跟 Java 假死根本不是一回事
真正的僵尸进程是操作系统层面的概念:子进程退出后,父进程没有调用wait()回收它的退出状态,于是这个进程的进程描述符还留在内核的进程表里,状态标记为Z。它不占 CPU、不占内存,只占一个 PID 槽位,特点是ps里能看到<defunct>,STAT列是Z或者Z+。这种僵尸的危害很小,杀不掉也没关系,把父进程重启或者让它正确 wait 就消失了。判断方法极其简单:
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/ {print}'如果这条命令没有任何输出,那你的问题跟 OS 僵尸一点关系都没有,可以直接把排查方向转到 JVM 内部。我在实际排查里遇到过太多次,有人一看"进程不响应"就往僵尸进程上靠,翻半天文档发现完全跑偏,白白浪费一小时的黄金止损时间。记住一个判据:OS 僵尸进程一定是"已经死了但没被回收",它绝对不会响应请求;而 Java 假死是"还活着但干不了活",端口通常还通,TCP 三次握手还能完成,只是应用层不返回数据。这两者的现象差异非常明显,用telnet或者curl -v试一下就能区分:连接能建立但没响应,就是应用层问题;连接直接被拒,才可能是进程真的没了。
1.2 Java Web 的"活死"三种典型形态
我把线上遇到的假死归成三类,每一类的指纹都不一样。
第一类是线程池耗尽型。所有工作线程都被卡在某个地方——等数据库连接、等下游 HTTP 响应、等一把锁、等一个Future.get()没设超时。新请求进来后进队列,队列满了走拒绝策略,或者干脆无限排队,表现出来的就是请求堆积、响应时间线性拉长、最后全超时。这时候 CPU 通常不高(线程都在 WAITING/TIMED_WAITING),日志可能还在打但打得极慢。
第二类是GC 假死型。堆内存吃紧,老年代频繁 Full GC,每次 STW 停个一两秒,应用线程被反复冻结。极端情况下老年代回收不动,连续 Full GC,应用线程几乎没机会跑,表现出来的就是"心跳还在、业务全停"。这时候 CPU 会飙高(GC 线程在拼命干活),而且往往伴随OutOfMemoryError或者GC overhead limit exceeded。
第三类是外部依赖拖死型,严格说它是第一类的子集,但值得单拎出来:数据库连接池耗尽、下游接口挂起不返回、消息队列消费卡住、分布式锁没释放。线程池只是"受害者",真正的凶手在更下游。
1.3 一张对照表先把方向定死
| 观测维度 | 线程池耗尽 | GC 假死 | 真正的 OS 僵尸 |
|---|---|---|---|
| 进程状态 | 存活,R/S 状态 | 存活,R 状态偏多 | Z 状态,defunct |
| 端口监听 | 正常 | 正常 | 通常已释放 |
| CPU 使用率 | 偏低(大量 WAITING) | 偏高(GC 线程忙) | 几乎为 0 |
| 内存占用 | 平稳或缓涨 | 接近上限、锯齿剧烈 | 极低 |
| 日志输出 | 变慢但仍有 | 可能整段空白 | 完全无 |
| 线程快照特征 | 大量 WAITING/TIMED_WAITING | 线程多为 RUNNABLE 但栈停在 safepoint 附近 | 抓不到(进程已死) |
| 典型根因 | 队列无界、无超时、连接池小 | 堆设置不当、内存泄漏、容器内存错配 | 父进程未 wait |
这张表我打印出来贴在工位上过,凌晨被叫起来的时候脑子是糊的,照着表走比凭感觉靠谱得多。把方向定死之后,后面的所有动作才有意义。
2. 排查方案的整体设计:五层漏斗,先证伪再证实
2.1 为什么我不建议一上来就 jstack
很多人一发现服务不响应,第一反应就是jstack抓线程快照。这个动作本身没错,但直接抓有个风险:如果现场是 GC 假死,你执行jstack的时候 JVM 可能正卡在 safepoint 上,命令会挂住十几秒甚至几分钟不返回,你以为是工具坏了,其实是 VM 在等你。更糟的是,在业务已经雪崩的时候,jstack本身会触发一次 safepoint,可能把本来就脆弱的服务彻底推下去。
我现在的顺序是先看外部指标,再看内部状态,最后才做重量级取证。这套漏斗分五层,从上往下逐层收窄:
- 连通性层:端口通不通、TCP 能不能建连、有没有响应。
- 资源层:CPU、内存、磁盘 IO、网络 IO、负载。
- JVM 宏观层:GC 频率与耗时、堆使用曲线、线程总数。
- JVM 微观层:线程快照、堆直方图、热点方法。
- 代码层:结合业务日志定位到具体方法。
每一层都有明确的"通过/不通过"判据,一旦某一层异常,就锁定在这一层深挖,不再往下走。这样做的好处是绝大多数问题在前三层就能定性,只有真正棘手的才需要走到第四第五层。
2.2 五层漏斗的分层判据
第一层的动作最轻:
# 看端口是否监听 ss -lntp | grep 8080 # 看能否建连并拿到响应,超时设 5 秒 curl -sv -m 5 http://127.0.0.1:8080/health 2>&1 | tail -20如果ss有输出但curl超时,基本可以确认是应用层假死,进程还活着。这时候再看第二层:
top -Hp <pid> # 看进程内各线程的 CPU 占用 pidstat -p <pid> 1 5 # 看进程级 CPU、内存、IO free -m # 看系统内存和 swap第三层看 GC,这是很多人的盲区:
jstat -gcutil <pid> 1000 10输出里重点看FGC(Full GC 次数)和FGCT(Full GC 总耗时)。如果十秒内 FGC 涨了好几次,FGCT 涨了几秒,那基本就是 GC 假死。第四层才上jstack、jmap、jcmd。第五层结合业务日志和链路追踪。
提示:第三层的
jstat是只读操作,几乎不影响业务,可以放心在生产环境跑。但jmap -dump会触发一次 Full GC 并且把整个堆写到磁盘,一个 8G 的堆可能卡住几十秒、写出几个 G 的文件,生产环境务必谨慎,优先用jmap -histo或者jcmd GC.class_histogram这种轻量方式。
2.3 观测能力要提前埋:没有监控的排查全靠运气
说句实话,上面这套漏斗能不能跑起来,取决于你有没有提前埋点。我见过太多服务,出问题的时候连 GC 日志都没开,只能靠猜。这几个东西我现在要求所有 Java 服务上线前必须配齐:
# JDK 9+ 的统一日志配置 -Xlog:gc*,gc+heap=info,gc+age=trace:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50M # JDK 8 的写法 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log \ -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M # OOM 时自动 dump,救命的 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump.hprof还有一个常被忽略的点:线程池一定要起名字。默认的pool-1-thread-1这种命名,在线程快照里几十个池子混在一起,你根本分不清哪个是哪个。我习惯给每个线程池一个业务前缀,排查的时候grep一下就知道是哪块业务堵了:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "order-query-" + seq.getAndIncrement()); t.setDaemon(false); return t; } };命名这件事看起来是个小细节,但它能把你从"翻两小时线程栈"变成"看一眼就知道问题在哪",投入产出比高到离谱。
3. 线程池把自己堵死:最常见的一种"软僵尸"
3.1 一个线程池是怎么从"忙"变成"死"的
线程池假死的演化过程是有固定剧本的,理解了剧本你就能提前看出来。假设一个池子核心线程 20、队列容量 2000、最大线程也是 20(很多人图省事这么配),下游某个接口平时 50ms 返回,某天它开始变慢,变成 3 秒才返回。这时候 QPS 假设是 200,需要的并发线程数就是 200 × 3 = 600 个,而池子只有 20 个,剩下的请求全部进队列。队列 2000 个位置,200 QPS 的情况下十秒就填满,填满之后新的请求要么被拒绝,要么被调用方一直阻塞(取决于拒绝策略)。
关键在于:队列满了之后,最大线程数如果等于核心线程数,池子就再也不会扩容了。ThreadPoolExecutor的扩容逻辑是"核心线程满了 → 进队列 → 队列满了 → 扩到最大线程数 → 还满就拒绝"。如果最大等于核心,队列满了就是直接拒绝,没有任何缓冲。这个设计很多人不知道,配完参数以为万事大吉,出事的时候才发现根本没有弹性。
更隐蔽的一种是同一个池子跑快慢两种任务。比如一个池子里既跑 10ms 的缓存查询,又跑 3 秒的对账任务,慢任务一旦多起来,把线程全占了,快任务也得排队,本来跟慢任务毫无关系的接口跟着一起雪崩。这种"故障传染"是我见过最难查的一类,因为从日志上看两个业务毫无关联。
3.2 线程数到底开多少:算一遍就清楚了
线程数的计算不是什么玄学,用 Little's Law 就够了:并发数 = QPS × 平均耗时(秒)。举个例子,某查询接口峰值 QPS 200,内部平均耗时 50ms,那理论并发是 200 × 0.05 = 10 个线程。考虑到抖动和下游变慢,留三倍余量,开 30 个比较合适。这是纯 CPU 型或者轻 IO 型任务的算法。
如果是重 IO 型任务,比如一次请求里包含两次 RPC 调用,每次平均 100ms,那单请求耗时 200ms,QPS 50 的话并发就是 50 × 0.2 = 10 个。这类任务线程大部分时间在等 IO,CPU 是闲的,所以可以按CPU 核数 × (1 + 等待时间/计算时间)这个经验公式再放大一些。一台 8 核机器,等待占比 90%,那8 × (1 + 9) = 80,这个量级是合理的。
| 场景 | 计算方式 | 8 核机器建议值 | 说明 |
|---|---|---|---|
| 纯 CPU 计算 | 核数 + 1 | 9 | 多了只会增加上下文切换 |
| 轻 IO(DB 查询) | QPS × 耗时 × 3 | 30 到 50 | 留三倍抖动余量 |
| 重 IO(多次 RPC) | 核数 × (1 + W/C) | 60 到 100 | 等待占比越高开得越多 |
| 慢任务(批处理、对账) | 独立池,容量按峰值任务数 | 单独评估 | 绝对不能和快任务混用 |
注意,maximumPoolSize 一定要设成比 corePoolSize 大,否则队列满了没有扩容空间。同时队列必须有界,LinkedBlockingQueue不传容量默认是Integer.MAX_VALUE,等于把队列当成了无底洞,请求全堆在内存里,最后 OOM 或者超时,比直接拒绝还惨。
3.3 队列与拒绝策略的取舍
队列长度怎么定?我的经验是核心线程数 × 2 到 3,再大意义不大。队列的本质是"削峰缓冲",它能吸收的是几秒钟的抖动,不是持续三分钟的下游故障。队列太长只会让请求在队列里等到超时,用户等了 30 秒拿到一个超时错误,体验还不如 500ms 就快速失败。
拒绝策略四种,选哪个很讲究:
| 策略 | 行为 | 适用场景 | 风险 |
|---|---|---|---|
| AbortPolicy | 抛 RejectedExecutionException | 核心交易链路,必须让调用方知道 | 需要上层捕获,否则 500 |
| CallerRunsPolicy | 由提交任务的线程自己执行 | 允许降速、不能丢任务的场景 | 会阻塞调用线程,可能拖垮上游 |
| DiscardPolicy | 静默丢弃 | 日志上报、埋点等可丢数据 | 无声无息,问题难发现 |
| DiscardOldestPolicy | 丢掉最老的再提交 | 只关心最新数据的场景 | 同上 |
我个人的默认选择是AbortPolicy加自定义异常处理,在业务层统一捕获并返回"系统繁忙,请稍后重试"。这样调用方能立刻知道,不会傻等。CallerRunsPolicy看着很美好——不丢任务还自动降速——但如果提交任务的线程本身是 Tomcat 的工作线程,它被阻塞住之后连拒绝响应都返回不了,反而把 Web 容器一起拖死,这个坑我踩过,慎用。
3.4 用 jstack 把阻塞点钉死
确认是线程池问题之后,下一步就是找出线程到底卡在哪。工具就是jstack,但用法有讲究:单次快照只能看到"卡住了",看不到"卡了多久",所以至少要抓三次,间隔几秒,对比线程栈有没有变化。
PID=12345 for i in 1 2 3; do jstack -l $PID > /tmp/td-$i.txt sleep 5 done-l参数会额外打印锁信息(Locked ownable synchronizers),排查死锁必加。抓完之后先做个粗统计,看线程状态的分布:
for f in /tmp/td-1.txt /tmp/td-2.txt /tmp/td-3.txt; do echo "== $f ==" grep "java.lang.Thread.State" $f | sort | uniq -c done如果三次快照里WAITING或TIMED_WAITING的数量基本不变,而且都集中在某几个方法上,那就是堵在那儿了。接着按线程名过滤:
grep -A 30 "order-query-" /tmp/td-1.txt | head -60重点看栈顶往下数第三四层,通常就是真正的阻塞点。比如栈里出现com.zaxxer.hikari.pool.HikariPool.getConnection,那就是在等数据库连接;出现java.net.SocketInputStream.socketRead0,那就是在等下游响应;出现java.util.concurrent.locks.LockSupport.park再往上找,往往是Future.get()没设超时。
还有一种情况要特别注意:三次快照里线程 ID 相同、栈完全相同、状态都是BLOCKED,那八成是死锁。jstack在输出末尾会自动检测并打印Found one Java-level deadlock,grep -i deadlock一下就能确认。
注意:
jstack在 JDK 里有个坑,如果进程是用-Xrs启动的,或者 JVM 正卡在长时间 GC 里,jstack可能不返回。这时候可以改用kill -3 <pid>,线程快照会打到标准输出(通常是catalina.out或者容器的日志文件里),虽然格式一样但不会卡住。
4. GC 假死:进程活着,业务世界停了两分钟
4.1 STW 是怎么把容器"冻住"的
JVM 的垃圾回收,尤其是老年代的回收,会触发 Stop-The-World:所有应用线程被挂起,等到 GC 完成才恢复。这个暂停时间在理想情况下是几十毫秒,但现实里能到几秒甚至几十秒。G1 号称能把停顿控制在预期范围内,但那是建立在你参数配得合理、堆大小合适的前提下。如果堆有 16G、老年代里全是一个巨大的HashMap,每次 Mixed GC 都要遍历一遍,暂停时间上不封顶。
GC 假死的可怕之处在于它没有任何应用层日志。因为是全局暂停,连打日志的线程都被冻住了,你在日志里看到的就是一段长达几十秒的空白,然后突然恢复。很多人看到日志断档,第一反应是"线程池阻塞了",方向就跑偏了。判据其实很简单:看 GC 日志里Total time for which application threads were stopped这行的数值,如果加起来远超正常水平,那就是它。
还有一种更阴险的:不是 Full GC 时间太长,而是触发得太频繁。每次只停 200ms,看起来不严重,但如果一秒触发五次,那应用实际能干活的时间只剩不到一秒的一半。这种"温水煮青蛙"式的假死,表现在监控上就是吞吐量悄悄下降、响应时间慢慢变长,很难归因。jstat里FGCT随时间线性增长是最直接的证据。
4.2 GC 日志和 jstat 该看哪几行
jstat -gcutil的输出有十几列,别被吓到,抓住几个关键的就行:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 96.88 45.12 99.87 93.20 89.11 1523 28.412 87 142.336 170.748O(老年代使用率)长期贴着 99% 不下来,说明回收不动了,这是最危险的信号。FGC(Full GC 次数)短时间内快速增长,比如十秒涨了五次,说明进入了频繁 Full GC 状态。FGCT(Full GC 总耗时)和GCT(GC 总耗时)的比值,能算出 GC 占用了多少 CPU 时间。如果GCT在十秒采样里涨了三秒,那相当于 30% 的时间在 GC,业务必然受影响。YGC很频繁但YGCT很小,说明新生代大小合适;如果YGC频繁且YGCT也涨得快,可能是新生代太小或者对象晋升太快。
GC 日志的读法更直接,新版统一日志格式下重点看这几类:
# 找出耗时最长的几次 STW grep "Pause" /var/log/app/gc.log | sort -t' ' -k1 | tail -20 # 统计 Full GC 次数 grep -c "Pause Full" /var/log/app/gc.log如果是 JDK 8 的老格式,看Full GC那几行里的[Times: user=... sys=... real=...],real就是实际暂停时间。real超过 2 秒的都要重视。
4.3 容器里最常见的那个坑:JVM 看到的不是你以为的内存
这个坑值得单独说,因为它太普遍了。JDK 8u191 之前的版本,JVM 在容器里读的是宿主机的/proc/meminfo,不是 cgroup 的限制。也就是说,一个限制 2G 的容器,JVM 可能看到宿主机有 64G,然后心安理得地把最大堆设成 16G。结果就是:容器内存超限被内核直接杀掉,或者进程疯狂 swap 把 IO 打满,表现出来就是"进程还在但完全不动"。
排查方法:
# 看容器内存限制 cat /sys/fs/cgroup/memory/memory.limit_in_bytes # 看 JVM 实际认定的最大堆 java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i maxheapsize两个数字对不上,就是错配了。修复方式是显式指定堆大小,别依赖自动推断:
-Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g注意MaxDirectMemorySize也要显式设。Netty、NIO 这些用直接内存的,默认上限等于最大堆,堆外泄漏一样能把容器撑爆,而且 OOM 的时候抛的是OutOfMemoryError: Direct buffer memory,堆 dump 里啥也看不到,特别难查。
提示:堆外内存泄漏的排查可以用
jcmd <pid> VM.native_memory summary(需要启动时加-XX:NativeMemoryTracking=summary,会带来 5% 到 10% 的性能损耗,建议只在排查期开启)。另外pmap -x <pid>看 RSS 分布,如果堆只有 4G 但 RSS 到了 10G,那多出来的部分八成在堆外。
5. 一次完整实操:从复现到取证到修复
5.1 先把问题复现出来
我习惯在自己的测试环境里把问题复现一遍,复现不了的问题修起来都是碰运气。线程池假死的复现很简单,写个接口,内部提交一个慢任务到池子里,池子容量故意设小:
@RestController public class DemoController { // 核心 4、最大 4、队列 8,故意的 private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor( 4, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(8), r -> new Thread(r, "demo-task-" + r.hashCode()), new ThreadPoolExecutor.AbortPolicy() ); @GetMapping("/slow") public String slow() throws Exception { Future<String> f = POOL.submit(() -> { Thread.sleep(3000); // 模拟下游变慢,且没有超时控制 return "ok"; }); return f.get(); // 没有超时,一直等 } }用 ab 或者 wrk 打几十个并发上去,很快就能看到RejectedExecutionException,或者请求全部挂住。这时候你去curl健康检查接口,如果健康检查也走这个池子,那就一起挂;如果健康检查是独立的,还能返回,但业务接口全超时——这就是典型的"进程活着、业务死了"。
GC 假死的复现稍微麻烦点,用-Xmx128m启动,然后写个接口拼命往一个静态 List 里塞对象:
private static final List<byte[]> LEAK = new ArrayList<>(); @GetMapping("/leak") public String leak() { for (int i = 0; i < 200; i++) { LEAK.add(new byte[1024 * 1024]); // 每次塞 200M } return "size=" + LEAK.size(); }反复调用几次,日志里就会看到 Full GC 越来越频繁,最后java.lang.OutOfMemoryError: Java heap space。在 OOM 之前的那段时间里,应用就是假死状态,你curl任何接口都是超时。
5.2 现场取证的标准动作
线上出问题的时候,动作要快、要准、要有顺序。我整理了一个清单,出事的时候照着做,能保证不遗漏关键信息:
PID=$(pgrep -f 'your-app.jar' | head -1) TS=$(date +%Y%m%d-%H%M%S) DIR=/tmp/incident-$TS mkdir -p $DIR # 1. 系统层 top -b -n 1 > $DIR/top.txt free -m > $DIR/free.txt ss -lntp > $DIR/ss.txt df -h > $DIR/df.txt # 2. JVM 宏观 jstat -gcutil $PID 1000 20 > $DIR/jstat.txt & jcmd $PID VM.flags > $DIR/flags.txt jcmd $PID VM.system_properties > $DIR/sysprops.txt # 3. 线程快照,间隔抓三次 for i in 1 2 3; do jstack -l $PID > $DIR/td-$i.txt sleep 5 done # 4. 对象直方图,轻量,不会 Full GC jcmd $PID GC.class_histogram > $DIR/histo.txt # 5. 等待 jstat 结束 wait这里有个细节:jcmd GC.class_histogram比jmap -histo:live轻量,后者会触发一次 Full GC。在服务已经假死的时候,任何触发 Full GC 的操作都可能成为压死骆驼的最后一根稻草,能省就省。堆 dump 只在明确怀疑内存泄漏、且服务已经无可救药准备重启的时候才做。
取证完之后按顺序看:先看top.txt里 CPU 高不高,高就往 GC 方向走,低就往线程阻塞方向走;再看jstat.txt的 FGC 增长速度;最后看线程快照的状态分布。
5.3 定位、修复、回归验证
假设最后定位到是线程池配置问题,修复方案通常是这样几层:
// 第一层:不共用池子,按业务隔离 @Bean("queryExecutor") public ThreadPoolExecutor queryExecutor() { return new ThreadPoolExecutor( 32, 64, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(64), namedFactory("query-pool"), new ThreadPoolExecutor.AbortPolicy() ); } // 第二层:任务必须有超时,绝不裸调 f.get() Future<String> f = pool.submit(task); try { return f.get(800, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { f.cancel(true); // 中断任务 throw new BizException("下游响应超时"); } // 第三层:下游调用本身也要设超时 RequestConfig config = RequestConfig.custom() .setConnectTimeout(300) .setSocketTimeout(800) .build();三层缺一不可。只设线程池超时不设 HTTP 超时,线程还是会被卡住;只设 HTTP 超时不管队列,请求还是会在队列里堆积。这三层是纵深防御,任何一层失效,剩下的还能兜住。
修复之后的回归验证同样重要,别改完就完事。我的验证方式是用压测工具把下游故意打慢(比如加个 sleep 开关),然后观察:线程池队列是否在抖动后自动恢复、被拒绝的请求比例是否在可接受范围、健康检查接口 P99 是否稳定在 100ms 以内。只有这些都过了,才算真正修好。
6. 排查速查表与几条踩坑经验
6.1 症状到结论的速查表
| 你看到的现象 | 最可能的原因 | 下一步动作 |
|---|---|---|
| 端口通但请求无响应,CPU 低 | 线程池耗尽或锁等待 | jstack抓三次,看状态分布 |
| 端口通但请求无响应,CPU 高 | GC 假死或死循环 | jstat -gcutil看 FGC,top -Hp找热点线程 |
| 日志突然断档几十秒后恢复 | GC 长暂停 | 查 GC 日志的 STW 时间 |
大量RejectedExecutionException | 线程池队列满 | 看队列容量和拒绝策略 |
| 连接池等待超时 | 数据库连接池耗尽 | jstack找getConnection,扩池或查慢 SQL |
| 进程被 SIGKILL 且无异常日志 | 容器内存超限 | 查 cgroup 限制和 JVM 堆设置是否匹配 |
jstack卡住不返回 | JVM 在 safepoint 或长 GC | 改用kill -3输出到日志 |
线程状态全是BLOCKED且栈相同 | 死锁 | grep -i deadlock确认,按锁顺序排 |
6.2 我认为最值钱的几条经验
第一条,健康检查接口绝对不能跟业务共用线程池。我见过一个服务,健康检查走的是同一个业务线程池,结果业务一堵,健康检查也超时,负载均衡把节点全部摘除,整个集群雪崩。健康检查应该是独立的、极简的、不依赖任何外部资源的,最好连日志都不打。
第二条,任何Future.get()、CountDownLatch.await()、lock.tryLock()都要带超时。这条规则简单粗暴,但只要全团队执行到位,能消灭 80% 的线程池假死。裸调get()就是在赌下游永远不挂,而墨菲定律告诉你它一定会挂。
第三条,线程池参数要跟着容量评估走,不能拍脑袋。我见过 core=200、max=200、queue=Integer.MAX_VALUE 的配置,写的人觉得"线程多、队列大,肯定没问题",实际上队列无界等于把所有背压都吃进内存,最后不是超时就是 OOM。参数算一遍只要五分钟,省下的是几个小时的故障排查。
第四条,GC 日志必须从第一天就开,而且要配轮转,别让日志把磁盘写满。这条看起来像废话,但我接手过的服务里,至少三成出事的时候才发现 GC 日志根本没开,只能靠复现,而线上流量根本复现不了。
第五条,别迷信"重启就好了"。重启确实能让服务恢复,但如果是内存泄漏或者线程泄漏,重启只是把炸弹的引信重新点着。我现在的习惯是,每次重启之前先把线程快照和堆直方图留下来,哪怕当时没空分析,事后总有时间回头看。这个习惯帮我找出过两个潜伏了半年才爆发的连接泄漏问题。
注意:如果你排查到最后发现是 GC 问题,先别急着调参数。
-XX:MaxGCPauseMillis调小一点、堆调大一点,这些是治标。真正的根因往往是"有对象活得太久",去代码里找那个不该缓存的List、那个忘了remove的ThreadLocal、那个静态的Map,比调参数有用得多。
最后分享一个我自己用的小脚本,出问题的时候一条命令把前面五层漏斗的信息全抓下来,省得手忙脚乱:
#!/bin/bash PID=$1 [ -z "$PID" ] && echo "usage: $0 <pid>" && exit 1 D=/tmp/diag-$(date +%H%M%S); mkdir -p $D jcmd $PID VM.flags > $D/flags.txt 2>&1 jstat -gcutil $PID 1000 10 > $D/gcutil.txt 2>&1 jcmd $PID GC.class_histogram > $D/histo.txt 2>&1 for i in 1 2 3; do jstack -l $PID > $D/td-$i.txt 2>&1; sleep 4; done echo "done -> $D"实际用下来,这套流程跑通之后,同类问题从"两个小时的救火"变成了"二十分钟的体检"。我最深的体会是,所谓"僵尸进程"从来不是某个神秘的现象,它就是几个确定的失效模式叠加出来的表象——线程池没设超时、队列没设上限、GC 日志没开、容器内存没对齐。把这些基本功做扎实,半夜被叫起来的次数会明显变少,即便被叫起来,心里也有底。