JVM线程分析与死锁排查实战:从现象到原理
问题背景
某电商系统的订单处理服务突然出现响应缓慢 ,部分请求完全无响应。运维人员发现:
- CPU使用率正常(30%左右)
- 线程数激增
- 接口调用超时
一、问题分析思路
1.1 现象梳理
| 现象 | 可能原因 |
|---|---|
| 线程数激增 | 大量请求堆积,线程池不断创建新线程 |
| CPU使用率正常 | 线程处于等待状态,不占用CPU |
| 响应缓慢/无响应 | 请求被阻塞,无法正常处理 |
1.2 推理过程
核心思考路径:
- 线程数激增 + CPU正常 → 线程大概率在等待(IO等待、锁等待)
- 等待的线程占用内存 → 每个线程都有自己的栈内存
- 内存持续被占用 → 触发Full GC
- Full GC无法回收 → 所有线程都是强引用,仍在运行
- 内存不足 → OOM
- 应用捕获异常 → 进程未退出,但服务已不可用
关键洞察:
线程在等待状态下,占用内存但不占用CPU,这就解释了为什么CPU正常但服务不可用。
二、模拟实验:复现问题场景
2.1 代码设计
为了验证上述推理,我设计了一个死锁模拟程序:
java
// 模拟死锁导致的线程堆积
public class ThreadOOM {
private ThreadPoolExecutor threadPool;
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public ThreadOOM() {
// 创建无限增长的线程池
threadPool = new ThreadPoolExecutor(
10, // 核心线程数
Integer.MAX_VALUE, // 最大线程数(无限制)
60, TimeUnit.SECONDS,
new SynchronousQueue<>() // 直接提交,不缓存
);
}
public void createDeadlockTask() {
// 提交死锁任务
threadPool.execute(() -> {
synchronized(lock1) {
try { Thread.sleep(100); } catch(Exception e) {}
synchronized(lock2) {
System.out.println("线程" + Thread.currentThread().getId() + "执行完成");
}
}
});
threadPool.execute(() -> {
synchronized(lock2) {
try { Thread.sleep(100); } catch(Exception e) {}
synchronized(lock1) {
System.out.println("线程" + Thread.currentThread().getId() + "执行完成");
}
}
});
}
public static void main(String[] args) {
ThreadOOM demo = new ThreadOOM();
// 不断提交死锁任务,造成线程堆积
while(true) {
demo.createDeadlockTask();
try { Thread.sleep(10); } catch(Exception e) {}
}
}
}
2.2 预期效果
- 死锁导致线程无法释放
- 线程池不断创建新线程
- 内存持续增长
- CPU保持低位(线程都在等待锁)
- 最终OOM(如果未捕获异常,进程退出;如果捕获,进程存活但服务不可用)
三、JDK工具实战排查
3.1 第一步:定位Java进程
bash
# 查看所有Java进程
jps -l
# 输出示例
12345 com.example.ThreadOOM
23456 org.apache.catalina.startup.Bootstrap
3.2 第二步:查看系统资源
bash
# 查看进程线程数
top -H -p 12345
# 查看进程内存占用
ps -aux | grep 12345
3.3 第三步:分析线程状态(jstack)
bash
# 打印线程栈信息
jstack -l 12345 > thread_dump.txt
jstack输出关键信息解读:
"pool-1-thread-123" #123 prio=5 os_prio=0 tid=0x00007f8a3400 nid=0x2a3c waiting for monitor entry [0x00007f8a2fbfe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.ThreadOOM.lambda$createDeadlockTask$1(ThreadOOM.java:28)
- waiting to lock <0x000000076b5c8d58> (a java.lang.Object)
- locked <0x000000076b5c8d68> (a java.lang.Object)
"pool-1-thread-124" #124 prio=5 os_prio=0 tid=0x00007f8a3400 nid=0x2a3d waiting for monitor entry [0x00007f8a2fafe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.ThreadOOM.lambda$createDeadlockTask$0(ThreadOOM.java:20)
- waiting to lock <0x000000076b5c8d68> (a java.lang.Object)
- locked <0x000000076b5c8d58> (a java.lang.Object)
Found one Java-level deadlock:
=============================
"pool-1-thread-123":
waiting to lock monitor 0x00007f8a4400 (object 0x000000076b5c8d58, a java.lang.Object),
which is held by "pool-1-thread-124"
"pool-1-thread-124":
waiting to lock monitor 0x00007f8a4400 (object 0x000000076b5c8d68, a java.lang.Object),
which is held by "pool-1-thread-123"
Java stack information for the threads listed above:
===================================================
"pool-1-thread-123":
at com.example.ThreadOOM.lambda$createDeadlockTask$1(ThreadOOM.java:28)
- waiting to lock <0x000000076b5c8d58> (a java.lang.Object)
- locked <0x000000076b5c8d68> (a java.lang.Object)
"pool-1-thread-124":
at com.example.ThreadOOM.lambda$createDeadlockTask$0(ThreadOOM.java:20)
- waiting to lock <0x000000076b5c8d68> (a java.lang.Object)
- locked <0x000000076b5c8d58> (a java.lang.Object)
关键发现:
jstack最后会自动检测并报告死锁!这是排查死锁最直接的方式。
3.4 第四步:监控GC情况(jstat)
bash
# 每1秒打印一次GC情况,共打印10次
jstat -gcutil 12345 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 0.00 85.23 92.45 95.23 87.34 1245 23.456 78 156.234 0 0.000 179.690
0.00 0.00 86.12 93.78 95.23 87.34 1245 23.456 79 158.456 0 0.000 181.912
0.00 0.00 87.45 95.12 95.23 87.34 1245 23.456 80 160.789 0 0.000 184.245
指标解读:
- E(Eden区):使用率持续高位
- O(老年代):不断增长,接近100%
- FGC(Full GC次数):持续增加
- FGCT(Full GC耗时):累计时间不断增长
结论: 内存压力巨大,频繁Full GC,但无法回收(线程强引用)。
3.5 第五步:分析堆内存(jmap)
bash
# 生成堆转储快照
jmap -dump:live,format=b,file=heap.bin 12345
# 查看堆内存概要
jmap -heap 12345
Attaching to process ID 12345, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.201-b09
using thread-local object allocation.
Parallel GC with 8 thread(s)
Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 4294967296 (4096.0MB)
NewSize = 89128960 (85.0MB)
MaxNewSize = 1431306240 (1365.0MB)
OldSize = 179306496 (171.0MB)
Heap Usage:
PS Young Generation
Eden Space:
capacity = 1073741824 (1024.0MB)
used = 989855744 (944.0MB)
free = 83886080 (80.0MB)
92.1875% used
PS Old Generation
capacity = 2147483648 (2048.0MB)
used = 2046820352 (1952.0MB)
free = 100663296 (96.0MB)
95.3125% used
四、JDK工具速查表
| 工具名 | 分类 | 命令示例 | 核心作用 | 适用场景 |
|---|---|---|---|---|
| jps | 进程管理 | jps -l |
列出Java进程 | 获取PID |
| jstack | 线程分析 | jstack -l <pid> |
打印线程栈 | 死锁、线程卡顿 |
| jstat | 性能监控 | jstat -gcutil <pid> 1000 |
GC监控 | 内存压力分析 |
| jmap | 内存分析 | jmap -heap <pid> |
堆内存信息 | 内存泄漏 |
| jinfo | 配置查看 | jinfo <pid> |
JVM参数 | 确认启动参数 |
jstack输出状态解读
| 线程状态 | 含义 | 排查重点 |
|---|---|---|
| RUNNABLE | 正在执行 | 检查是否CPU高占用 |
| BLOCKED | 等待锁 | 查看锁持有者,分析死锁 |
| WAITING | 无限期等待 | 检查是否永远等不到通知 |
| TIMED_WAITING | 限期等待 | 通常正常,除非大量堆积 |
| TERMINATED | 已结束 | 正常状态 |
五、问题根因总结
5.1 问题成因
- 代码层面:存在死锁,线程互相等待对方释放锁
- 线程池配置:最大线程数无限制,导致线程无限创建
- 内存层面:每个线程占用约1MB栈内存,线程数激增耗尽内存
- 异常处理:捕获OOM异常,进程未退出,但服务已不可用
5.2 排查路径复盘
现象观察
↓
CPU正常 + 线程数激增 → 推断线程在等待
↓
jstack确认死锁
↓
jstat确认频繁FGC
↓
jmap确认内存耗尽
↓
定位代码问题
5.3 解决方案
- 修复死锁 :统一锁获取顺序,或使用
tryLock带超时 - 合理配置线程池 :
- 设置合理的最大线程数
- 使用有界队列
- 配置拒绝策略
- 添加监控告警 :
- 线程数监控
- GC频率监控
- 死锁检测
六、面试考点提炼
常见问题
-
Q:CPU正常但服务无响应,可能是什么问题?
A:线程处于等待状态(锁等待、IO等待),不占用CPU但占用内存。
-
Q:jstack如何检测死锁?
A:JVM会定期检查线程间的锁依赖关系,如果形成循环等待链,jstack会打印死锁信息。
-
Q:线程数激增一定会导致CPU升高吗?
A:不一定。如果线程都在等待(BLOCKED/WAITING),CPU使用率可能很低。
-
Q:Full GC频繁但内存无法释放,可能原因?
A:所有对象都是强引用且仍在使用(如存活线程的栈引用),GC无法回收。
七、总结
这次问题排查让我深刻理解了线程状态与资源占用的关系:
- 运行中的线程 → 占用CPU
- 等待中的线程 → 占用内存但不占用CPU
- 死锁的线程 → 永久等待,内存被持续占用
JDK工具是Java开发者的"瑞士军刀",掌握它们能让我们在遇到问题时快速定位根因,而不是靠猜测重启。
最后的小技巧:当服务出问题时,先别急着重启,用jstack看看线程状态,往往能发现意想不到的线索!
这篇题解是我在实际排查过程中的思考和总结,希望能帮助到遇到类似问题的你。如果有更好的思路或建议,欢迎交流讨论!