news 2026/10/28 13:27:35

Java服务假死排查:线程池耗尽与GC假死五层漏斗实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java服务假死排查:线程池耗尽与GC假死五层漏斗实战

凌晨两点多被电话叫起来,值班的同学说订单查询接口全线超时,监控大盘上 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,可能把本来就脆弱的服务彻底推下去。

我现在的顺序是先看外部指标,再看内部状态,最后才做重量级取证。这套漏斗分五层,从上往下逐层收窄:

  1. 连通性层:端口通不通、TCP 能不能建连、有没有响应。
  2. 资源层:CPU、内存、磁盘 IO、网络 IO、负载。
  3. JVM 宏观层:GC 频率与耗时、堆使用曲线、线程总数。
  4. JVM 微观层:线程快照、堆直方图、热点方法。
  5. 代码层:结合业务日志定位到具体方法。

每一层都有明确的"通过/不通过"判据,一旦某一层异常,就锁定在这一层深挖,不再往下走。这样做的好处是绝大多数问题在前三层就能定性,只有真正棘手的才需要走到第四第五层。

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 计算核数 + 19多了只会增加上下文切换
轻 IO(DB 查询)QPS × 耗时 × 330 到 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.748
  • O(老年代使用率)长期贴着 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 日志没开、容器内存没对齐。把这些基本功做扎实,半夜被叫起来的次数会明显变少,即便被叫起来,心里也有底。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 13:26:16

Cloudflare Workers 构建的前端开发沙盒操作系统

1. 项目概述&#xff1a;这不是一个操作系统&#xff0c;而是一次对开发者工作流的深度重构“cloudflare-os”这个标题在当前技术社区里确实容易引发第一反应的误解——看到“OS”就联想到小米澎湃OS、OpenHarmony、Cachy OS这类真正意义上的操作系统发行版。但实际拆解关键词组…

作者头像 李华
网站建设 2026/10/28 13:25:43

营业执照OCR识别技术:原理、优化与应用实践

1. 营业执照识别技术解析与应用实践在商业活动日益频繁的今天&#xff0c;企业资质审核、金融开户、政务办理等场景都离不开营业执照信息的快速采集与核验。传统人工录入方式不仅效率低下&#xff08;平均每份需要3-5分钟&#xff09;&#xff0c;错误率更是高达15%。我们团队通…

作者头像 李华
网站建设 2026/10/28 13:25:42

从模运算到GF(2^8):密码学有限域原理与工程实践

做密码学相关的东西&#xff0c;绕不开一个坎&#xff1a;算法里的加减乘除&#xff0c;和你平时写的加减乘除不是一回事。我第一次在分组密码的字节代换环节栽跟头&#xff0c;就是因为顺手写了(a * b) % 256&#xff0c;结果前十几个字节的输出和参考实现一模一样&#xff0c…

作者头像 李华
网站建设 2026/10/28 13:24:56

AI Agent工具循环失控:recursionLimit与条件路由的治理实践

1. 工具循环失控的现场&#xff1a;从一个“读不完的文档” Bug 说起写过 AI Agent 的朋友应该都有过这种经历&#xff1a;你给智能体配了三五个工具&#xff0c;本来只是让它查个资料、整理个表格&#xff0c;结果它像魔怔了一样&#xff0c;反反复复把同一个工具调用十几遍。…

作者头像 李华
网站建设 2026/10/28 13:23:28

YOLO26在交通手势识别中的应用与优化

1. 项目概述&#xff1a;当YOLO26遇上交通手势识别去年在某个十字路口等红灯时&#xff0c;我注意到交警的手势动作和信号灯显示存在短暂不同步&#xff0c;这个细节让我萌生了开发交通手势识别系统的想法。而YOLO26作为今年目标检测领域的新晋强者&#xff0c;其在小目标检测和…

作者头像 李华
网站建设 2026/10/28 13:22:46

告别 perfect:impeccable 的用法、搭配与避坑指南

我手机里一直躺着一条没发出去的英文朋友圈草稿&#xff0c;内容是想夸朋友订的那家民宿——位置好、装修细、老板还贴心。憋了半天&#xff0c;脑子里翻来覆去只有 perfect、nice、awesome 这几个老朋友&#xff0c;怎么看都觉得差点意思。后来我专门查了一下&#xff0c;真正…

作者头像 李华