当系统出现无响应或性能急剧下降时,死锁(Deadlock)往往是罪魁祸首之一。在缺乏高级可视化工具的场景下,我们通常只能依赖原始的线程堆栈信息,例如jstack的输出,进行手动分析。面对海量文本,如何抽丝剥茧,定位死锁的循环等待链呢?本文将提供一个结构化的方法论,一步步引导你完成这个任务。
什么是死锁?
在多线程环境中,死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们将永远无法推进。死锁的四个必要条件:
- 互斥条件:至少有一个资源是独占的,即一次只能被一个线程使用。
- 持有并等待条件:线程已经持有至少一个资源,但又在等待获取其他被别的线程持有的资源。
- 不可剥夺条件:线程已经获得的资源在未使用完之前,不能被强行剥夺。
- 循环等待条件:存在一个线程链,每个线程都等待下一个线程所持有的资源。
我们主要关注通过jstack输出中的“持有并等待”及“循环等待”条件来识别死锁。
jstack输出中的死锁识别方法论
步骤一:获取jstack线程堆栈快照
首先,你需要一个正在运行的Java进程的线程堆栈快照。通常使用以下命令:
jstack <pid> > thread_dump.txt
<pid>是你的Java进程ID。建议在问题发生时,多采集几份(例如间隔5-10秒)堆栈快照,有助于观察线程状态的变化,排除瞬时阻塞。
步骤二:初步筛选:检查jstack报告的“死锁”信息
jstack工具在某些情况下,会非常智能地直接检测并报告Java级别的死锁。
在thread_dump.txt文件中,搜索关键词deadlock。
如果找到类似以下内容,恭喜你,jstack已经帮你定位了死锁:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting for monitor entry for 0x0000000780f2d6c8 (object is a java.lang.Object), which is held by "Thread-0"
"Thread-0":
waiting for monitor entry for 0x0000000780f2d6f8 (object is a java.lang.Object), which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:23)
- waiting for <0x0000000780f2d6c8> (a java.lang.Object)
- locked <0x0000000780f2d6f8> (a java.lang.Object)
at DeadlockDemo$$Lambda$2/1531885590.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
"Thread-0":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:16)
- waiting for <0x0000000780f2d6f8> (a java.lang.Object)
- locked <0x0000000780f2d6c8> (a java.lang.Object)
at DeadlockDemo$$Lambda$1/824009085.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
此信息清晰地列出了参与死锁的线程、它们正在等待的资源地址以及被哪个线程持有。
步骤三:如果无明确报告,手动识别“BLOCKED”和“WAITING”线程
如果jstack没有直接报告死锁,我们需要自己查找。
在thread_dump.txt文件中,搜索线程状态关键词:BLOCKED和WAITING。这些状态表明线程正在等待某个资源。
一个典型的BLOCKED线程状态示例:
"HTTP-Thread-1" #23 prio=5 os_prio=0 tid=0x00007f3d9c021800 nid=0x26c0 waiting for monitor entry [0x00007f3da3b48000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyService.methodB(MyService.java:50)
- waiting for <0x0000000780d60c38> (a java.lang.Object) // 想要获取的锁
at com.example.MyService.methodA(MyService.java:30)
- locked <0x0000000780d60c88> (a java.lang.Object) // 已经持有的锁
一个典型的WAITING线程状态示例(可能在等待notify/notifyAll或join):
"Thread-2" #25 prio=5 os_prio=0 tid=0x00007f3d9c022800 nid=0x26c1 in Object.wait() [0x00007f3da3a47000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x0000000780d60d38> (a java.lang.Object) // 正在等待的资源
at com.example.AnotherService.doSomething(AnotherService.java:70)
...
关键信息提取:
- 线程名称/ID:例如
"HTTP-Thread-1"。 - 线程状态:
BLOCKED或WAITING。 - 等待的资源:通常在
- waiting for <0x...> (a ...)或- waiting on <0x...> (a ...)之后,<0x...>是资源的内存地址(通常是锁对象)。 - 持有的资源:通常在
- locked <0x...> (a ...)之后,<0x...>是该线程当前持有的锁的内存地址。
将这些信息记录下来,形成一个初步的待分析列表。
步骤四:构建资源等待图(手动绘制或列表记录)
这一步是核心。我们需要找出哪些线程持有什么资源,又在等待什么资源。
- 遍历所有BLOCKED/WAITING线程:对每个线程,提取其“等待的资源”和“持有的资源”。
- 建立映射关系:
- 线程 -> 持有资源:记录该线程当前持有的所有锁的地址。
- 线程 -> 等待资源:记录该线程当前正在等待获取的锁的地址。
为了清晰,你可以使用一个表格来记录:
| 线程ID/名称 | 状态 | 等待的资源地址(waiting for/on) |
持有的资源地址(locked) |
|---|---|---|---|
| Thread-A | BLOCKED | 0xLockY | 0xLockX |
| Thread-B | BLOCKED | 0xLockZ | 0xLockY |
| Thread-C | BLOCKED | 0xLockX | 0xLockZ |
| ... | ... | ... | ... |
步骤五:追踪等待链,发现循环
从任意一个BLOCKED或WAITING线程开始,沿着等待的资源路径进行追踪。
追踪过程:
- 选择一个起始线程:例如
Thread-A,它等待0xLockY。 - 找出持有
0xLockY的线程:在你的记录中查找哪个线程“持有的资源”包含0xLockY。假设是Thread-B。 - 继续追踪
Thread-B:Thread-B又等待0xLockZ。 - 找出持有
0xLockZ的线程:假设是Thread-C。 - 继续追踪
Thread-C:Thread-C又等待0xLockX。 - 找出持有
0xLockX的线程:发现是最初的Thread-A。
此时,你就形成了一个循环等待链:Thread-A -> 0xLockY <- Thread-B -> 0xLockZ <- Thread-C -> 0xLockX <- Thread-A。
这就确诊了一个死锁!
复杂情况处理:
- 多条链:可能存在多个独立的死锁循环。
- 非死锁阻塞:有些线程阻塞可能只是短暂的,或者等待的资源最终会被释放(例如,等待连接池中的连接,等待队列中的数据)。多份堆栈有助于区分瞬时阻塞和永久性死锁。如果某个等待链最终没有形成循环,那它就不是死锁。
- ReentrantLock和StampedLock:
jstack对synchronized关键字实现的Monitor锁有很好的支持。对于java.util.concurrent.locks包下的锁,例如ReentrantLock,jstack不会直接显示locked <0x...>信息,而是通过线程的堆栈信息中调用LockSupport.park()或AbstractQueuedSynchronizer相关方法来体现其等待状态。此时,你需要结合代码逻辑,理解哪个线程正在调用lock()方法并等待,以及哪个线程可能持有该锁。尽管如此,BLOCKED或WAITING状态以及调用栈依然是重要线索。
步骤六:分析调用栈,定位代码问题
一旦确定了死锁的线程和资源,接下来就是查看这些线程的完整堆栈信息。
例如,在上面的Thread-A、Thread-B、Thread-C的堆栈中,分析它们分别在哪个方法、哪一行代码尝试获取锁或持有锁。
这有助于你理解:
- 为什么这些锁会被获取?
- 它们在什么业务场景下被获取?
- 是否存在不合理的锁顺序?(这是死锁最常见的原因之一,例如Thread A先获取Lock X再获取Lock Y,而Thread B先获取Lock Y再获取Lock X)。
通过这些信息,你就可以在代码层面定位问题并进行修复,例如调整锁的获取顺序、缩小锁的范围、使用无死锁的并发工具等。
总结
手动分析jstack输出以识别死锁是一个需要耐心和细致观察的过程。虽然没有可视化工具直观,但通过上述结构化的方法论,你依然可以有效地从海量日志中定位问题:
- 获取多份
jstack快照。 - 优先检查
jstack自带的死锁报告。 - 筛选出
BLOCKED和WAITING状态的线程,提取其等待和持有的资源。 - 构建线程-资源等待映射表。
- 追踪等待链,直到发现循环。
- 结合代码调用栈,分析死锁的根本原因。
掌握这个方法,即使在最“原始”的调试环境中,你也能成为一名死锁侦探。