HOOOS

手动分析jstack线程堆栈:一步步识别死锁循环等待

0 7 码农小A jstack死锁线程堆栈
Apple

当系统出现无响应或性能急剧下降时,死锁(Deadlock)往往是罪魁祸首之一。在缺乏高级可视化工具的场景下,我们通常只能依赖原始的线程堆栈信息,例如jstack的输出,进行手动分析。面对海量文本,如何抽丝剥茧,定位死锁的循环等待链呢?本文将提供一个结构化的方法论,一步步引导你完成这个任务。

什么是死锁?

在多线程环境中,死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们将永远无法推进。死锁的四个必要条件:

  1. 互斥条件:至少有一个资源是独占的,即一次只能被一个线程使用。
  2. 持有并等待条件:线程已经持有至少一个资源,但又在等待获取其他被别的线程持有的资源。
  3. 不可剥夺条件:线程已经获得的资源在未使用完之前,不能被强行剥夺。
  4. 循环等待条件:存在一个线程链,每个线程都等待下一个线程所持有的资源。

我们主要关注通过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文件中,搜索线程状态关键词:BLOCKEDWAITING。这些状态表明线程正在等待某个资源。

一个典型的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/notifyAlljoin):

"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"
  • 线程状态BLOCKEDWAITING
  • 等待的资源:通常在- waiting for <0x...> (a ...)- waiting on <0x...> (a ...)之后,<0x...>是资源的内存地址(通常是锁对象)。
  • 持有的资源:通常在- locked <0x...> (a ...)之后,<0x...>是该线程当前持有的锁的内存地址。

将这些信息记录下来,形成一个初步的待分析列表。

步骤四:构建资源等待图(手动绘制或列表记录)

这一步是核心。我们需要找出哪些线程持有什么资源,又在等待什么资源。

  1. 遍历所有BLOCKED/WAITING线程:对每个线程,提取其“等待的资源”和“持有的资源”。
  2. 建立映射关系
    • 线程 -> 持有资源:记录该线程当前持有的所有锁的地址。
    • 线程 -> 等待资源:记录该线程当前正在等待获取的锁的地址。

为了清晰,你可以使用一个表格来记录:

线程ID/名称 状态 等待的资源地址(waiting for/on 持有的资源地址(locked
Thread-A BLOCKED 0xLockY 0xLockX
Thread-B BLOCKED 0xLockZ 0xLockY
Thread-C BLOCKED 0xLockX 0xLockZ
... ... ... ...

步骤五:追踪等待链,发现循环

从任意一个BLOCKEDWAITING线程开始,沿着等待的资源路径进行追踪。

追踪过程

  1. 选择一个起始线程:例如Thread-A,它等待0xLockY
  2. 找出持有0xLockY的线程:在你的记录中查找哪个线程“持有的资源”包含0xLockY。假设是Thread-B
  3. 继续追踪Thread-BThread-B又等待0xLockZ
  4. 找出持有0xLockZ的线程:假设是Thread-C
  5. 继续追踪Thread-CThread-C又等待0xLockX
  6. 找出持有0xLockX的线程:发现是最初的Thread-A

此时,你就形成了一个循环等待链:Thread-A -> 0xLockY <- Thread-B -> 0xLockZ <- Thread-C -> 0xLockX <- Thread-A
这就确诊了一个死锁!

复杂情况处理

  • 多条链:可能存在多个独立的死锁循环。
  • 非死锁阻塞:有些线程阻塞可能只是短暂的,或者等待的资源最终会被释放(例如,等待连接池中的连接,等待队列中的数据)。多份堆栈有助于区分瞬时阻塞和永久性死锁。如果某个等待链最终没有形成循环,那它就不是死锁。
  • ReentrantLock和StampedLockjstacksynchronized关键字实现的Monitor锁有很好的支持。对于java.util.concurrent.locks包下的锁,例如ReentrantLockjstack不会直接显示locked <0x...>信息,而是通过线程的堆栈信息中调用LockSupport.park()AbstractQueuedSynchronizer相关方法来体现其等待状态。此时,你需要结合代码逻辑,理解哪个线程正在调用lock()方法并等待,以及哪个线程可能持有该锁。尽管如此,BLOCKEDWAITING状态以及调用栈依然是重要线索。

步骤六:分析调用栈,定位代码问题

一旦确定了死锁的线程和资源,接下来就是查看这些线程的完整堆栈信息。
例如,在上面的Thread-AThread-BThread-C的堆栈中,分析它们分别在哪个方法、哪一行代码尝试获取锁或持有锁。
这有助于你理解:

  • 为什么这些锁会被获取?
  • 它们在什么业务场景下被获取?
  • 是否存在不合理的锁顺序?(这是死锁最常见的原因之一,例如Thread A先获取Lock X再获取Lock Y,而Thread B先获取Lock Y再获取Lock X)。

通过这些信息,你就可以在代码层面定位问题并进行修复,例如调整锁的获取顺序、缩小锁的范围、使用无死锁的并发工具等。

总结

手动分析jstack输出以识别死锁是一个需要耐心和细致观察的过程。虽然没有可视化工具直观,但通过上述结构化的方法论,你依然可以有效地从海量日志中定位问题:

  1. 获取多份jstack快照。
  2. 优先检查jstack自带的死锁报告。
  3. 筛选出BLOCKEDWAITING状态的线程,提取其等待和持有的资源。
  4. 构建线程-资源等待映射表。
  5. 追踪等待链,直到发现循环。
  6. 结合代码调用栈,分析死锁的根本原因。

掌握这个方法,即使在最“原始”的调试环境中,你也能成为一名死锁侦探。

点评评价

captcha
健康