1. 先分清两种"僵尸":OS层面的僵尸和Java层面的假死
1.1 真正意义上的僵尸进程长什么样
很多人第一次听到"僵尸进程"这个词,脑子里浮现的是ps输出里那个Z或者defunct标记。这个理解没错,但它是操作系统层面的概念,跟Java Web应用里遇到的那种"进程还在、服务不动"完全是两码事。操作系统里的僵尸进程,成因非常明确:子进程执行完了、退出了,但父进程没有调用wait()或者waitpid()去回收它的退出状态,于是内核保留了这个子进程的task_struct和PID,进程表项一直挂着,直到父进程回收或者父进程自己也退出。
这种进程的特征很鲜明:状态列显示Z,ps里带<defunct>,用kill -9杀不掉——因为它本身已经死了,没有可以杀的执行体;不占CPU,几乎不占内存(只留了极少量内核结构)。排查手法也是固定的,ps -ef | grep defunct找到它,看它的PPID,然后去检查父进程为什么没有做回收,比如父进程的信号处理写错了、或者父进程阻塞在某个系统调用上迟迟不返回。真正处理时要么修父进程代码,要么重启父进程,僵尸自然消失。
所以,如果线上有人喊"我们服务出现僵尸进程了",第一步不是上去就翻代码,而是先跑一句ps -ef | grep -i defunct或者ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'。如果输出是空的,那这事跟OS僵尸进程一点关系都没有,别再往SIGCHLD、waitpid那个方向使劲了。这个前置判断看起来很基础,但我见过太多次,一帮人围着"僵尸"两个字讨论了半天Linux进程模型,最后发现是线程池卡死了——方向从一开始就错了,浪费的时间比排查本身还多。
1.2 Java Web的"假死":进程活着,服务已经断气
我说的这类"僵尸",准确叫法是应用假死。表现是:ps能看到Java进程,PID正常,jps也列得出来,netstat -anp | grep 8080能看到端口还在LISTEN,甚至curl过去TCP三次握手都能建立成功,但请求就是没有响应,客户端一直转圈直到超时,最后抛个Read timed out。更迷惑的是,进程的CPU占用可能很低——top看过去只有百分之几,一副"我很闲,我只是不想理你"的样子。
这就是最坑的地方:CPU不高,容易被误判成"没压力、没问题"。实际上恰恰相反,CPU低+请求无响应,往往意味着线程全都堵在等待上了,没有一个线程在干活,所以CPU自然就闲。top看到的高CPU,很多时候反而是好事——至少说明还有线程在跑,只是跑错了地方(死循环、频繁GC、疯狂重试)。真正的"死透"状态,CPU是安静得可怕的。
这类假死的成因可以归成几大类:线程池被打满、所有工作线程都阻塞在某个没有超时的外部调用上;GC出现长时间Stop-The-World,或者Full GC连续触发导致应用几乎没机会执行业务代码;数据库连接池耗尽,业务线程全在等连接;死锁或者锁竞争极端严重;线程数耗尽导致连Tomcat的Acceptor都拿不到线程;还有一类比较少被提及但很致命的——进程进入不可中断睡眠(D状态),通常挂在磁盘IO或网络文件系统上。
我自己的经验是,先按"线程"和"GC"两条线分头查,五分钟内基本能定性。因为这两类占了线上假死的绝大多数,而且它们的现场特征差异极大,jstack和jstat一跑,答案基本就浮出来了。
1.3 三种"死法"的特征对照
为了后面排查的时候不来回翻笔记,我把三类情况的现场特征整理成一张表。这张表是我自己贴在运维手册第一页的东西,出问题时扫一眼就能大致判断方向:
| 维度 | OS僵尸进程 | 线程池假死 | GC假死 |
|---|---|---|---|
| 进程状态 | Z/defunct | S(睡眠)或R | S或R |
| CPU占用 | 0 | 通常很低 | 可能很高,也可能低 |
| 端口监听 | 无(进程已死) | 正常LISTEN | 正常LISTEN |
| 请求响应 | 完全不通 | 握手成功但无响应 | 全站变慢或完全无响应 |
jstack是否可用 | 不适用 | 正常输出,能看到大量阻塞线程 | 可能卡住不返回(VM Thread占着) |
| 关键证据 | PPID未回收 | 线程栈停在WAITING/BLOCKED | jstatFGC计数飞涨、FGCT占比高 |
| 典型修复 | 修父进程/重启父进程 | 加超时、改队列、扩池 | 改堆参数、修内存泄漏 |
有一点值得单独说:jstack是否能正常返回,本身就是个很强的信号。如果jstack <pid>敲下去卡了十几秒甚至几十秒才出结果,或者干脆超时,那基本可以锁定GC或者安全点(safepoint)问题——因为jstack需要让JVM进入安全点才能拿到一致的线程快照,安全点进不去,说明VM线程正忙着别的事。这个技巧不需要任何额外工具,靠手感就能判断,比装一堆监控还快。
2. 定位第一现场:三分钟区分线程问题还是GC问题
2.1 用top -Hp锁定热点线程
拿到PID之后,我的第一动作几乎固定是top -Hp <pid>。加-H的意义是把视角从"进程"切到"线程",每个线程单独一行,能看到各自的CPU和状态。Java是天生多线程的,一个进程里几十上百个线程很正常,只看进程整体CPU会把问题糊掉,切到线程级才能看出到底是哪几个线程在消耗。
# 找到Java进程 jps -l # 或者 ps -ef | grep java | grep -v grep # 线程级视角,按CPU排序 top -Hp 12345 # 交互模式下按 P 按CPU排,按 M 按内存排top -Hp里看到的PID列,其实是线程ID(TID)。Java线程栈里的nid是十六进制的,所以需要转一下:
printf '0x%x\n' 12377 # 输出 0x3059然后在jstack输出里搜这个十六进制值:
jstack -l 12345 > /tmp/stack_$(date +%s).txt grep -A 30 'nid=0x3059' /tmp/stack_xxx.txt这个流程看着笨,但极其可靠。真正高频出问题的线程,往往就那么一两个,抓出来看栈就明白它在干什么了。我遇到最多的是两种:一种是卡在java.net.SocketInputStream.socketRead0上,说明在等一个网络响应且没设超时;另一种是栈顶在java.util.concurrent.locks.LockSupport.park,配合下面的调用链就能看出是在等锁、等队列、还是等Future。
还有个小细节:top -Hp里如果某个线程的CPU稳定在单核100%附近,基本就是死循环。这时候看栈会看到它在某个while里转,或者在做正则匹配、序列化、加密这类计算密集的操作。反过来,如果所有线程CPU都很低,那就往"等待"方向查,重点看WAITING和TIMED_WAITING的数量。
2.2jstat三十秒看出GC是不是在拖后腿
判断GC问题,jstat是最轻量的工具,几乎零开销,可以放心在生产环境上跑。我一般用-gcutil,每秒刷一次,连刷三十次,看两分钟的趋势:
jstat -gcutil 12345 1000 30输出里几个关键列:S0/S1是两个Survivor区的使用率,E是Eden,O是老年代,M是元空间,YGC/YGCT是年轻代GC次数和总耗时,FGC/FGCT是Full GC次数和总耗时,GCT是总GC时间。
判断逻辑很直接:
FGC在持续增长,同时O一直贴着100%下不来,说明老年代回收不掉,典型的内存泄漏或者大对象持续涌入。这种情况下应用基本处于"GC—停顿—再GC"的循环里,业务线程拿不到多少执行时间,表现出来就是假死。FGC不怎么涨,但FGCT单次很大,比如一次Full GC花了十几秒。堆越大这个问题越明显,几十G的堆做一次全量标记压缩,停顿时间长到客户端早就超时了。YGC频率高得离谱,比如每秒好几次,YGCT占比超过10%,说明新生代太小或者对象创建速率太高,虽然每次停顿短,但累积起来吞吐量掉得厉害。M(元空间)持续上涨不回落,这个要警惕。元空间泄漏往往来自动态生成类,比如反射代理、脚本引擎、热部署残留,最后触发Metaspace的Full GC,而这类GC往往回收效果很差。
我的判断标准比较粗暴:GCT占总运行时间的比例超过5%就要看,超过10%必须处理。另外,连续观察时如果发现FGC在几分钟内涨了几十次,别犹豫,直接按内存泄漏处理,先留现场(jmap -dump)再考虑重启止血。
注意:
jmap -histo:live和jmap -dump:live都会触发一次Full GC,堆大的时候这个动作本身就可能让服务卡死几十秒。生产上要么在低峰做,要么先jcmd <pid> GC.heap_info看看情况,别上来就dump。
2.3jstack的三种读法,分别对应三类病
jstack拿到手,很多人第一反应是从上往下逐行读,读到第一百个线程就晕了。我的做法是先做统计,再定点深挖。
第一种读法是状态分布统计,一条命令就能看出整体健康度:
jstack 12345 | grep 'java.lang.Thread.State' | sort | uniq -c | sort -rn正常情况下,大部分线程应该是RUNNABLE或者TIMED_WAITING(空闲线程池线程会停在park上,属于WAITING,也很正常)。如果BLOCKED数量突然冒出几十个,那基本就是锁竞争;如果WAITING数量异常多,而且栈都指向同一个方法,那就是某个资源成了瓶颈。
第二种读法是找统一点,也就是大量线程卡在同一个调用栈的同一个位置。用grep把栈顶几行抽出来统计:
jstack 12345 | grep -A 3 '"http-nio' | grep 'at ' | sort | uniq -c | sort -rn | head -20如果看到几十个线程都停在com.zaxxer.hikari.pool.HikariPool.getConnection,那答案很明确——连接池不够或者有连接泄漏。如果都停在java.net.SocketInputStream.socketRead0,那就是外部调用没有超时。
第三种读法是看死锁,jstack自己会给出提示:
jstack -l 12345 | grep -A 20 'Found one Java-level deadlock'加了-l参数会额外输出Locked ownable synchronizers信息,对排查ReentrantLock、Condition这类锁很有用。真正的Java级死锁,jstack会直接打印出环路,省得自己画图。
还有一点必须提:jstack连续抓三次,间隔五秒。单次快照只能看到一瞬,有些线程恰好在切换状态,容易误判。三次快照对比,如果某个栈位置一直不变,那就是真卡住了;如果三次都不一样,说明线程在正常流动,问题可能在别处。
3. 线程池把服务拖死的五种典型姿势
3.1 无界队列配慢下游:池子的最大线程数形同虚设
这是我最常遇到的一种。代码大概长这样:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), // 注意这里 new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(), new ThreadPoolExecutor.AbortPolicy() );看起来配了maximumPoolSize = 64,好像能扛64并发。实际上永远只有8个线程在跑。原因在ThreadPoolExecutor的执行逻辑里写得很清楚:核心线程满了之后,任务优先入队列;只有队列满了,才会去创建新线程直到maximumPoolSize。而new LinkedBlockingQueue<>()的默认容量是Integer.MAX_VALUE,这辈子都填不满,所以扩容那段代码永远不会被执行。
这个坑的隐蔽性在于,它平时表现得挺好——任务排队、慢慢处理,QPS不高的时候完全看不出问题。一旦下游某个接口变慢,任务在队列里堆积,队列越来越长,内存跟着涨,延迟从几十毫秒涨到几十秒,最后要么客户端超时,要么直接OOM。而这时候maximumPoolSize那个64还安安静静躺在配置里,给人一种"我已经扩过容了"的错觉。
修法有两条,看业务性质选。对延迟敏感的,换SynchronousQueue或者小容量有界队列(比如ArrayBlockingQueue(200)),让池子能真正扩到最大线程数,同时配CallerRunsPolicy做背压。对吞吐敏感的,就老实承认队列深度,把容量设成一个有意义的固定值(比如按"能容忍的最大排队时间 × 处理速率"算),并且一定要配监控,队列长度超过阈值就告警。
线程数怎么定?用那个经典的估算公式:N = CPU核数 × (1 + 等待时间/计算时间)。比如8核机器,任务平均耗时100ms,其中80ms在等数据库、20ms在算,那N = 8 × (1 + 4) = 40。这只是起点,实际上还要看下游能不能扛住这么多并发,别把数据库压垮了。
3.2 池子里套池子:自己等自己,等到天荒地老
第二种更阴险。外层线程池的线程里,提交任务到内层线程池,然后future.get()同步等结果。如果内外两层用的是同一个池,或者内层的池被别的业务占满了,就会形成"自己等自己"的局面。
// 反例:同一线程池内嵌套提交并同步等待 ExecutorService pool = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { pool.submit(() -> { Future<String> inner = pool.submit(() -> callRemoteService()); return inner.get(); // 10个线程全在这里等,内层任务永远排不上 }); }十个线程全被外层任务占了,每个都在等内层任务完成,而内层任务在队列里等着线程空出来。经典的线程饥饿死锁,jstack里会看到所有线程都停在FutureTask.get,队列里还压着一堆任务。CPU全程接近0,端口正常LISTEN,请求全部超时,完美符合"僵尸"的所有外观。
同样的问题还会出现在分库分表并行查询、批量任务并行处理、微服务聚合调用这些场景里。只要代码里出现"池子里再往池子里提交并同步等待",就有这个风险。
实操建议:给内层任务单独建池,并且和外层池做硬隔离,数量上留足余量。更稳妥的做法是全程异步化,用
CompletableFuture组合而不是阻塞等待;如果确实要等,一定带超时future.get(3, TimeUnit.SECONDS),超时就降级返回,绝不允许无限期挂住。
3.3 线程泄漏:看着没几个任务,线程就是不够用
线程泄漏跟内存泄漏是一对孪生兄弟。表现是池子里的线程数一直顶着上限,但实际在处理的业务量很小。常见成因有这么几个:
一是任务本身永久阻塞。比如调外部HTTP接口没设connectTimeout和readTimeout,对方网络半死不活,TCP连接挂在中间状态,socketRead0永不返回。线程就这么被占着,池子一个个被啃光。这种情况jstack一秒就能看出来,栈顶全是socketRead0。
二是不设超时的数据库查询。Statement没设queryTimeout,一条慢SQL跑了半小时,连接和线程全占着。更糟的是连接池的connectionTimeout(HikariCP默认30秒)会把等连接的线程也堵住,形成连锁反应。
三是**ThreadLocal在线程池里不清理**。这个不直接导致线程泄漏,但会让每个线程持有一份越来越大的对象,最终OOM。而OOM之后如果没有正确释放资源,也会间接引发假死。
四是日志同步写阻塞。这个坑特别隐蔽,因为没人会把日志当性能瓶颈。但如果logback配的是同步FileAppender,磁盘IO一旦抖动(尤其是容器里共享盘),所有打日志的线程都卡在FileOutputStream.writeBytes上。我在一个项目里见过,磁盘打满之后,业务线程全堵在日志写入,jstack里几十个线程停在write调用,排查时第一眼完全没往日志上想,绕了一大圈。
3.4 拒绝策略选错:异常被吞,调用方原地等
ThreadPoolExecutor有四种内置拒绝策略,选错了会造成很诡异的假死:
| 策略 | 行为 | 风险 |
|---|---|---|
AbortPolicy | 抛RejectedExecutionException | 默认策略,异常不处理会丢任务 |
CallerRunsPolicy | 调用线程自己执行 | 背压有效,但可能阻塞Tomcat线程 |
DiscardPolicy | 静默丢弃 | 任务凭空消失,调用方永远等不到结果 |
DiscardOldestPolicy | 丢队列最老的,再提交 | 老任务被丢,结果对不上 |
DiscardPolicy和DiscardOldestPolicy是最危险的两个。如果业务层用了Future去拿结果,任务被丢掉之后Future永远不会完成,get()又没设超时,那个线程就永远挂在那里。表面上线程池还在正常工作,日志也不报错,但调用方的线程在一点点被吞噬,最后全线卡死。
我的原则很简单:业务线程池禁用DiscardPolicy。要么AbortPolicy配合上层捕获降级,要么CallerRunsPolicy做自然背压。如果非要用丢弃型策略,那这个任务的语义必须是"丢了也无所谓",绝不能有人等它的结果。
3.5 那些藏在细节里的锁
除了上面几类,还有一批"隐形锁"值得单独列出来。synchronized锁住一个被所有请求共享的对象、ConcurrentHashMap的computeIfAbsent里做了耗时操作、Collections.synchronizedMap包了大对象、缓存组件的全局锁——这些在压测时都可能变成瓶颈。
jstack判断锁竞争看两块:一是线程状态为BLOCKED,二是下面的- waiting to lock <0x...>指向同一个对象地址。如果几十个线程都在等同一个地址,那就是它了。进一步可以用jstack -l找到持有该锁的线程,看它卡在哪儿。
还有一种情况是偏向锁撤销引发的性能抖动,不过这个在JDK 15之后默认关闭了偏向锁,新版本基本不用管。老版本JDK上如果看到大量safepoint停顿且伴随偏向撤销计数飙升,可以考虑关掉-XX:-UseBiasedLocking。
4. GC假死的成因拆解与参数调优
4.1 Full GC频繁触发的几个真实来源
GC假死的第一种形态是频率型:Full GC一次又一次地跑,每次都回收不了多少,应用的有效执行时间被切得稀碎。jstat上看就是FGC飞速增长,FGCT跟着涨,O一直在高位。这种八成是内存泄漏,或者数据结构用得不对。
常见的泄漏源:全局static Map做缓存但没有淘汰策略;ThreadLocal在线程池里不remove;监听器注册了不注销;数据库连接、流、Channel忘了关;集合类里放了重写了hashCode但值会变的对象导致HashMap节点堆积。还有一类是堆外内存泄漏,比如DirectByteBuffer、Netty的ByteBuf没release,这类在heap dump里看不出来,得看Native Memory Tracking或者pmap的anon段变化。
第二种形态是单次长停顿型:Full GC次数不多,但每次都很长。堆设得特别大(比如32G以上)且用的是Parallel或者CMS的时候尤其明显。全量标记压缩要遍历整个堆,大堆上单次停顿十几秒甚至几十秒都不稀奇。这种问题堆参数调优空间有限,根本上还是要换低延迟收集器。
第三种是元空间触发型:M一直涨,涨到MaxMetaspaceSize触发Full GC,但类卸载效果很差,于是反复触发。这类问题查起来要dump类加载器,看是哪个ClassLoader在不停生成类。反射、动态代理、脚本引擎、模板编译、热部署是重灾区。
4.2 停顿时间的估算与收集器参数
先算一下量级,心里有个数。G1的Young GC停顿大致跟Eden区大小和存活对象量成正比,粗略经验值是每GB Eden区几毫秒到十几毫秒。如果-Xmn给了4G,Young GC停顿跑到几十毫秒很正常,MaxGCPauseMillis=200是个目标值,不是保证值。
Full GC的停顿估算更粗糙:串行标记压缩速度大约在几百MB/s到1GB/s之间,堆越大越慢(因为要处理跨代引用和卡表)。20G老年代做一次Full GC,保守估计20秒以上,这个数字基本能解释为什么客户端会超时。
调优这块,我给一组我自己常用的G1起步参数:
-Xms16g -Xmx16g # 固定堆大小,避免动态扩缩容带来的抖动 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 目标停顿,G1会据此调整年轻代大小 -XX:G1HeapRegionSize=8m # Region大小,堆越大可以适当调大 -XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的阈值,默认45 -XX:G1ReservePercent=10 # 预留空间,防止晋升失败 -XX:+ParallelRefProcEnabled # 并行处理引用,减少Remark停顿 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m几个参数值得说明。-Xms和-Xmx设成一样,是为了避免堆扩容时的停顿——很多人不知道堆动态调整本身也是有代价的。InitiatingHeapOccupancyPercent默认45%,如果发现并发标记还没跑完老年代就满了,导致晋升失败触发Full GC,可以往低调,比如40。G1ReservePercent是给"复制存活对象"留的余量,晋升失败频繁的时候往上调。
JDK 8上如果还在用CMS,得注意CMS已经在新版本里被移除了,而且它有"并发模式失败"和"晋升失败"两个经典坑,一旦退化成串行Full GC,停顿时间直接爆炸。有条件的话尽早迁到G1,JDK 11之后G1已经是默认收集器了。
4.3 堆外内存和元空间:看不见的那部分
堆内看得很清楚,堆外往往被忽略。DirectByteBuffer分配的直接内存,受-XX:MaxDirectMemorySize限制(不设的话默认约等于堆大小)。Netty、NIO、某些RPC框架大量使用直接内存,泄漏了在heap dump里只能看到一堆DirectByteBuffer对象持有Cleaner引用,真正占的内存不在堆里。
排查手段是开-XX:NativeMemoryTracking=summary,然后用jcmd <pid> VM.native_memory summary对比基线。如果Other或者Internal段持续上涨,基本就是堆外泄漏。另一个快糙猛的办法是pmap -x <pid> | tail -1看RSS变化,和堆大小对比,差距越拉越大就是有问题。
元空间这边,-XX:MetaspaceSize是首次触发GC的阈值(不是初始大小,这个容易搞混),-XX:MaxMetaspaceSize是上限,不设的话默认无上限,最终会把机器内存吃光。生产上建议显式设置上限,比如-XX:MaxMetaspaceSize=512m,让问题尽早暴露成OutOfMemoryError: Metaspace而不是整机卡死。
4.4 用GC日志和dump交叉验证
jstat只能看趋势,要定位具体原因还得靠GC日志。JDK 9以上的统一日志格式上面已经给了,JDK 8的写法是:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause \ -Xloggc:/data/logs/gc-%t.log -XX:+UseGCLogFileRotation \ -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M-XX:+PrintGCCause这个参数很有用,它会打印出触发GC的原因,比如GCLocker Initiated GC、Allocation Failure、Metadata GC Threshold、Ergonomics。看到Metadata GC Threshold频繁出现,直接奔元空间去查;看到GCLocker Initiated GC,说明有JNI临界区在阻塞GC,通常是某个native库的问题。
如果确认是内存泄漏,jmap -dump:format=b,file=/tmp/heap.hprof <pid>导出来,用MAT或者JProfiler分析。看的时候别只看"谁占得多",重点看支配树(Dominator Tree)里谁真正持有这些对象,以及到GC Roots的引用链。很多时候占内存最多的是byte[],但根因是某个缓存容器没设上限。
5. 复现与验证:搭一个最小可复现的假死现场
5.1 用固定线程池加慢服务复现线程饥饿
光看文字没感觉,自己复现一遍印象最深。下面这段代码三十行,能稳定复现"端口在听、请求不回"的状态:
import java.util.concurrent.*; public class FakeDeadLockDemo { // 池子里只有2个线程,但提交了4个互相等待的任务 private static final ExecutorService POOL = Executors.newFixedThreadPool(2); public static void main(String[] args) throws Exception { for (int i = 0; i < 4; i++) { final int id = i; POOL.submit(() -> { try { // 模拟下游服务慢:永远不返回 Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("task-" + id + " done"); }); } // 主线程保持存活 Thread.currentThread().join(); } }跑起来之后用jstack看,两个线程停在Thread.sleep,队列里还压着两个任务。换成真实的HTTP调用不设超时,效果完全一样。这时候top看CPU接近0,curl过去没反应,netstat看到端口正常——教科书级别。
GC假死的复现更简单,写个死循环往static List里塞大对象:
public class GcHangDemo { private static final java.util.List<byte[]> LEAK = new java.util.ArrayList<>(); public static void main(String[] args) { while (true) { LEAK.add(new byte[1024 * 1024]); // 每次1MB,永远不释放 try { Thread.sleep(5); } catch (InterruptedException ignored) {} } } }启动时加-Xmx256m,几十秒后就开始Full GC循环,jstat上能看到FGC飙升,应用响应时间从毫秒级跳到秒级。
5.2 现场采集的先后顺序很讲究
真出问题的时候,采集顺序错了会毁掉证据。我踩过的坑:先jmap -dump:live,结果这个动作本身触发了Full GC,把原本卡在GC循环里的现场"冲"掉了,导出来的堆看着还挺正常。后来我固定按这个顺序来:
- 先
top -Hp和pidstat -p <pid> 1 5,记录CPU和线程分布,这个开销最小,先留个底。 - 再
jstat -gcutil <pid> 1000 60跑一分钟,看GC趋势。 - 然后
jstack -l连抓三次,间隔5秒,分别存文件。 jcmd <pid> GC.heap_info看一眼堆的概况,这个比jmap -histo轻。- 最后才是
jmap -dump,而且尽量在问题已经复现、且能承受一次Full GC的时候做。 - 如果是容器环境,顺手
cat /sys/fs/cgroup/memory/memory.limit_in_bytes看看cgroup限制,容器里JVM看到的"内存"和实际限制经常对不上,这是另一大类假死的来源。
提醒一句:容器化部署时如果没配
-XX:MaxRAMPercentage或者-Xmx,JVM可能按宿主机内存来算堆大小,容器一超限就被内核OOM Killer干掉,或者行为诡异。这类"假死"其实是资源限制问题,不是线程也不是GC,但外观很像。
5.3 修复之后怎么验证确实好了
改完不能就看一眼"好像不卡了",得有量化对比。我的验证清单一般是这几项:
- 压测相同QPS,
TP99从秒级回到百毫秒级; jstat观察30分钟,GCT占比降到1%以下,FGC次数为0或者个位数;jstack统计里BLOCKED线程数为0,WAITING线程都停在空闲线程池的park上;- 故意制造下游超时,验证熔断和降级能生效,不会再把线程拖死;
- 队列长度监控曲线在压测期间不持续单调上升。
最后一条特别重要。队列长度持续上升是假死的前兆,比响应时间变慢还要早出现。把这个指标接进监控,等于给自己装了个提前预警。
6. 常见问题速查与线上操作禁忌
6.1 症状、证据、结论对照表
排查时最耗时间的不是分析,是想不起来该看哪个指标。这张表可以直接当速查卡用:
| 症状 | 关键证据 | 大概率结论 | 处置方向 |
|---|---|---|---|
| 端口通,请求无响应,CPU低 | jstack大量线程停在同一方法 | 线程池耗尽/外部调用无超时 | 加超时、隔离线程池 |
| 全站变慢后完全无响应 | jstatFGC 飞涨,O接近100% | 内存泄漏导致GC循环 | 留dump、重启止血、修泄漏 |
jstack卡住不返回 | 安全点日志显示VM线程等待 | 长STW或safepoint问题 | 查GC日志、调堆参数 |
| 只有某个接口卡死 | 该接口线程栈停在锁上 | 锁竞争/死锁 | 缩小锁粒度、改无锁结构 |
| 线程数持续增长不回落 | jstack线程数逐次增加 | 线程泄漏 | 查未关闭资源、未设超时 |
| 偶发超时,重启就好 | 无持久证据 | 连接池泄漏/句柄耗尽 | 查lsof -p句柄数 |
| 容器里反复重启 | cgroup内存限制与Xmx不匹配 | 被OOM Killer杀 | 配MaxRAMPercentage |
进程处于D状态 | psSTAT列为D | 磁盘/NFS IO阻塞 | 查挂载点、换本地盘 |
6.2 线上操作的几条硬规矩
第一,不要在问题现场直接做jmap -dump:live。这个操作会触发Full GC,堆大的时候能让服务彻底停住几十秒。如果非要dump,先用-XX:+HeapDumpOnOutOfMemoryError让它自动在OOM时导,或者先摘流量再导。
第二,jstack要连抓三次。单次快照的误判率比想象中高,尤其在有短时任务的时候。
第三,重启之前一定要留证据。至少把jstack、jstat输出、GC日志这三样存下来。很多团队重启完就说"好了",过两周同样的问题再来一遍,还是从零排查。留证据的成本是几分钟,省下的可能是几天。
第四,改线程池参数要有依据。别看到队列长了就把corePoolSize从8调到100,下游数据库可能直接被打挂。线程数、队列容量、超时时间三者是联动的,得一起算。
第五,-XX:+HeapDumpOnOutOfMemoryError必须常开。这个参数几乎零成本,但OOM发生的那一刻的堆快照,价值无可替代。我曾经因为没开这个参数,花了两周才定位到一个偶发的泄漏点。
6.3 监控和预案,比事后排查更值钱
排查能力再强,也不如让问题不发生。我现在经手的项目,上线前会强制过一遍这几项检查:
线程池必须有名字前缀(ThreadFactoryBuilder设nameFormat),这样jstack里一眼能认出是哪个池,不用去翻代码对线程名。线程池必须有监控埋点,活跃线程数、队列长度、拒绝次数三个指标接进监控,队列长度设阈值告警。所有外部调用必须有超时,HTTP客户端、数据库、缓存、RPC一个不落,超时时间按下游TP99的两到三倍设。连接池必须配connectionTimeout和leakDetectionThreshold,后者能主动暴露没归还的连接。GC日志必须开并且落盘,别只输出到stdout,容器一重启就没了。
还有一条容易被忽略:准备一个降级开关。当线程池队列打满时,能一键把非核心功能关掉(比如推荐、埋点、非关键查询),把资源让给主链路。这个开关平时用不上,真出事的时候能救命。我见过一次大促前的故障,就是因为没有这个开关,只能整体限流,把正常用户也挡在外面了。
最后分享一个我自己的习惯:每次排查完,不管问题大小,都写一段简短的复盘,记清楚"现象—工具—证据—根因—修复"五件事,扔进团队的知识库。攒到二三十条之后,你会发现绝大多数"僵尸"都是熟面孔,下次再遇到,jstack扫一眼就知道往哪儿走。