Java 中的死锁是指两个或多个线程永久阻塞的情况,因为每个线程持有另一个同一组中的线程所需的锁。没有线程能够继续执行,没有锁被释放,JVM 默认不提供恢复机制。死锁是生产环境中最难重现的 bug 之一:它们在负载下间歇性出现,在应用日志中不留下异常或堆栈跟踪,并导致进程静默挂起。
阅读本教程后,您将能够从线程转储输出中识别死锁,在 synchronized 和 ReentrantLock 代码中重现导致死锁的条件,并应用预防策略,使给定代码路径中结构上不可能发生死锁。要理解本次讨论的基础线程模型,请参阅 Java Thread class 和 multithreading fundamentals 以及 multithreading in Java。
关键要点
- 死锁仅在满足所有四个 Coffman 条件时发生:互斥、持有并等待、无抢占和循环等待。如果破坏其中任何一个,死锁就无法形成。
synchronized块和ReentrantLock都容易发生死锁——问题不在于语法,而在于获取锁的方式。- 使用带超时的
tryLock()是一种避免死锁的方法,尽管它会增加重试的额外逻辑——您需要根据生产需求来考虑这一点。 jstack、JConsole、VisualVM 和ThreadMXBean等工具都能以各自的方式检测死锁。选择合适的工具可以在排查问题时节省时间。- 死锁和活锁都会导致线程无法取得进展,但有一个关键区别:活锁线程持续运行并使用 CPU (RUNNABLE),而死锁线程则在锁上等待 (BLOCKED)。
- 如果您在 JDK 21+ 中使用 virtual threads,请注意使用
synchronized时可能出现的“pinning”。为避免问题,请在 virtual thread 内阻塞的任何代码中使用ReentrantLock。
Java 中的死锁是什么?
从外部看,死锁表现为一个健康的进程停止了所有活动:CPU 使用率降至接近零,没有新的日志输出出现,请求要么无限排队,要么开始超时。没有异常、没有错误消息,也没有来自 JVM 的信号。应用仍在运行;它只是没有取得进展。
死锁的最小设置需要两个线程和两个共享资源。线程 A 持有资源 1 并等待资源 2;线程 B 持有资源 2 并等待资源 1。在生产环境中,死锁通常涉及更多线程和更复杂的依赖图,这也是为什么它们在测试环境中难以重现的原因:锁获取的确切交错必须在正确时刻发生。
慢应用与死锁应用的不同在于,慢应用继续产生日志输出且 CPU 至少部分活跃。死锁应用不产生新的日志输出,CPU 保持平稳,且 jstack 在每次连续转储中显示相同的线程处于 BLOCKED 状态。这种组合——平稳 CPU、无日志进展和稳定的 BLOCKED 线程集——是区分死锁与普通缓慢的诊断信号。
要更广泛地了解 Java 如何管理对共享状态的并发访问,请参阅 thread safety in Java 教程。
死锁的四个 Coffman 条件
只有当所有四个 Coffman 条件同时满足时,死锁才会发生。这对于预防死锁很重要:移除任何一个条件都会使该代码路径不可能发生死锁。
互斥
资源一次只能由一个线程持有。synchronized 块和 ReentrantLock 通过设计强制执行互斥并发。如果资源可以被多个线程并发访问而无需独占所有权,则此条件不成立,也就无法形成死锁。
持有并等待
一个线程在阻塞等待获取另一个锁时,至少持有一个锁。嵌套的 synchronized 块会直接创建此条件。在下面的三线程示例中,线程 t1 持有 obj1 的 monitor,同时等待 obj2 的 monitor。
不可抢占
JVM 无法强制从线程中移除锁。持有 monitor 的线程在退出 synchronized 块或调用 wait() 之前不会释放它。没有调度器机制可以代表等待线程回收已持有的锁。
循环等待
线程在等待关系中形成一个循环。线程 t1 等待由 t2 持有的锁,线程 t2 等待由 t3 持有的锁,而 t3 等待由 t1 持有的锁。这种资源分配循环使得情况永久化:循环中的任何线程都无法获取所需的锁,因为持有它的线程本身也在等待。
下表将每个 Coffman 条件映射到下一节三线程 synchronized 示例中的具体代码行:
| 条件 | 代码中出现的位置 |
|---|---|
| 互斥 | synchronized (obj1) — 一次只有一个线程持有 monitor |
| 持有并等待 | 线程在阻塞等待 synchronized (obj2) 时持有 obj1 monitor |
| 不可抢占 | JVM 无法在 t1 执行块内部时从 t1 撤销 obj1 |
| 循环等待 | t1 等待 obj2(由 t2 持有),t2 等待 obj3(由 t3 持有),t3 等待 obj1(由 t1 持有) |
使用 synchronized 的 Java 死锁示例
使用 synchronized 的 Java 中最直接的死锁示例涉及嵌套锁和循环获取顺序。三个线程各自获取一个对象监视器,然后尝试获取第二个,形成循环等待条件。
package com.example.threads;
public class ThreadDeadlock {
public static void main(String[] args) throws InterruptedException {
Object obj1 = new Object();
Object obj2 = new Object();
Object obj3 = new Object();
// t1 持有 obj1,想要 obj2
// t2 持有 obj2,想要 obj3
// t3 持有 obj3,想要 obj1 <-- 完成循环等待条件
Thread t1 = new Thread(new SyncThread(obj1, obj2), "t1");
Thread t2 = new Thread(new SyncThread(obj2, obj3), "t2");
Thread t3 = new Thread(new SyncThread(obj3, obj1), "t3");
t1.start();
Thread.sleep(5000);
t2.start();
Thread.sleep(5000);
t3.start();
}
}
class SyncThread implements Runnable {
private final Object obj1;
private final Object obj2;
public SyncThread(Object o1, Object o2) {
this.obj1 = o1;
this.obj2 = o2;
}
@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name + " acquiring lock on " + obj1);
synchronized (obj1) { // 互斥;从这里开始持有并等待
System.out.println(name + " acquired lock on " + obj1);
work();
System.out.println(name + " acquiring lock on " + obj2);
synchronized (obj2) { // 循环等待:每个线程的 obj2 都被下一个线程持有
System.out.println(name + " acquired lock on " + obj2);
work();
}
System.out.println(name + " released lock on " + obj2);
}
System.out.println(name + " released lock on " + obj1);
System.out.println(name + " finished execution.");
}
private void work() {
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 预期输出(程序不会终止):
t1 acquiring lock on java.lang.Object@6d9dd520
t1 acquired lock on java.lang.Object@6d9dd520
t2 acquiring lock on java.lang.Object@22aed3a5
t2 acquired lock on java.lang.Object@22aed3a5
t3 acquiring lock on java.lang.Object@218c2661
t3 acquired lock on java.lang.Object@218c2661
t1 acquiring lock on java.lang.Object@22aed3a5
t2 acquiring lock on java.lang.Object@218c2661
t3 acquiring lock on java.lang.Object@6d9dd520
三个线程各自获取第一个锁后,都尝试获取第二个锁。由于每个第二个锁都被另一个线程持有,所有线程都无限期阻塞。程序永远不会打印“finished execution.”。
读取线程转储
对阻塞进程运行 jstack <pid> 会生成线程转储。识别死锁的部分如下所示:
Found one Java-level deadlock:
=============================
"t3":
waiting to lock monitor 0x00007fb0a1074b08 (object 0x000000013df2f658, a java.lang.Object),
which is held by "t1"
"t1":
waiting to lock monitor 0x00007fb0a1010f08 (object 0x000000013df2f668, a java.lang.Object),
which is held by "t2"
"t2":
waiting to lock monitor 0x00007fb0a1012360 (object 0x000000013df2f678, a java.lang.Object),
which is held by "t3"
Found 1 deadlock.
关键字段是 waiting to lock 和 which is held by。每个条目都指明了阻塞的线程以及持有它所需锁的线程。沿着链条追踪会揭示循环:t1 等待 t2,t2 等待 t3,t3 等待 t1。循环中的每个线程在其状态行中都显示 BLOCKED (on object monitor)。
使用 ReentrantLock 的死锁示例
ReentrantLock 来自 java.util.concurrent.locks,它提供与 synchronized 相同的互斥保证,但需要显式调用 lock() 和 unlock()。显式调用使得锁获取顺序在代码中可见,这有助于审阅者发现不安全的顺序,但仅靠可见性并不能防止死锁。如果两个线程以相反顺序获取同一对锁,无论如何都会形成死锁。
当你需要定时锁尝试(tryLock)、可中断获取(lockInterruptibly)或多个条件队列(newCondition())时,优先选择 ReentrantLock 而非 synchronized。对于简单的互斥,synchronized 更简洁,且忘记调用 unlock() 的风险更小。
使用 ReentrantLock 的死锁场景
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDeadlock {
private static final ReentrantLock lockA = new ReentrantLock();
private static final ReentrantLock lockB = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
lockA.lock();
try {
System.out.println(Thread.currentThread().getName() + " acquired lockA");
sleep(100);
lockB.lock(); // 在这里无限期阻塞:Thread-2 持有 lockB
try {
System.out.println(Thread.currentThread().getName() + " acquired lockB");
} finally {
lockB.unlock();
}
} finally {
lockA.unlock();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
lockB.lock();
try {
System.out.println(Thread.currentThread().getName() + " acquired lockB");
sleep(100);
lockA.lock(); // 在这里无限期阻塞:Thread-1 持有 lockA
try {
System.out.println(Thread.currentThread().getName() + "如何在 Java 中检测死锁
检测死锁需要使用能够检查 JVM 运行时线程状态的工具,或者查询运行时线程管理 API 的代码。
jstack
jstack 是 JDK 中包含的命令行实用工具。它通过 PID 附加到运行中的 JVM 进程,并将完整的线程转储打印到 stdout。
jstack <pid>
// 预期输出(转储末尾的相关部分):
Found one Java-level deadlock:
=============================
"t3":
waiting to lock monitor 0x00007fb0...658 (a java.lang.Object),
which is held by "t1"
...
Found 1 deadlock.
滚动到输出的末尾,查找 Found N Java-level deadlock 块。每个条目标识了被阻塞的线程、它正在等待的锁地址,以及当前持有该锁的线程。循环中的每个线程在其状态行显示 BLOCKED (on object monitor)。循环读取每个 which is held by 链可以揭示完整的循环等待。
jstack 可以检测 synchronized monitor 和扩展 AbstractOwnableSynchronizer 的 java.util.concurrent locks(包括 ReentrantLock)上的死锁。它不需要对应用程序进行代码更改。
JConsole
JConsole 是随 JDK 提供的图形化 JMX 监控工具。使用 JConsole 检测死锁的步骤:
- 在终端启动 JConsole:
jconsole
- 从本地进程列表连接到运行中的 Java 进程。
- 打开 Threads 选项卡。
- 点击面板底部的 Detect Deadlock 按钮。
Name: Thread-1
State: BLOCKED
Blocked on: java.lang.Object@6d9dd520 (owned by Thread-2)
Name: Thread-2
State: BLOCKED
Blocked on: java.lang.Object@22aed3a5 (owned by Thread-1)
Deadlock detected.
这个面板直接映射到 jstack 转储中的 waiting to lock 和 which is held by 字段,因此两种工具通过不同的界面产生等效的诊断信息。
如果存在死锁,JConsole 会将相关线程高亮为红色,并显示每个线程的堆栈跟踪,以及它正在等待的锁和持有该锁的线程名称。该报告等同于 jstack 的死锁部分,但以可视化列表形式呈现,这在需要快速在多个被阻塞线程之间导航而无需解析原始文本时非常有用。
VisualVM
VisualVM 提供线程时间线,可以一目了然地显示死锁模式。连接到进程后:
- 打开 Threads 选项卡。
- 查找显示为红色的线程(阻塞状态)。在三线程死锁中,三个红色条同时出现并保持红色,没有向前移动。
- 点击任何被阻塞的线程查看其堆栈跟踪和它正在等待的锁。
- VisualVM 还在线程视图中显示 Deadlock detected! 通知横幅,当它自动识别循环时。
Thread-1 [BLOCKED] waiting for lock held by Thread-2
Thread-2 [BLOCKED] waiting for lock held by Thread-1
Deadlock detected!
VisualVM 的线程时间线显示线程进入 BLOCKED 状态的时刻,这有助于识别死锁是持久的还是间歇性的。
线程时间线特别适用于区分死锁(线程永久红色)和临时锁争用(线程短暂红色然后恢复绿色)。
ThreadMXBean:程序化检测
来自 java.lang.management 的 ThreadMXBean 允许您从运行中的应用程序内部检测死锁。这对于监控端点、后台看门狗线程或长时间运行服务中的自动化健康检查非常有用。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class DeadlockDetector {
public static void main(String[] args) throws InterruptedException {
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread-1 acquired lockA");
sleep(100);
synchronized (lockB) {
System.out.println("Thread-1如何在 Java 中防止死锁
预防策略通过使至少一个 Coffman 条件无法满足来实现。以下每种策略针对特定条件,并解释其工作原理。
避免嵌套锁
同时获取多个锁会产生持有并等待条件。消除嵌套锁获取可以完全移除该代码路径的死锁风险。
下面重构后的 run() 方法在获取 obj2 之前释放 obj1,因此没有线程会同时持有两者:
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name + " acquiring lock on " + obj1);
synchronized (obj1) {
System.out.println(name + " acquired lock on " + obj1);
work();
}
System.out.println(name + " released lock on " + obj1);
System.out.println(name + " acquiring lock on " + obj2);
synchronized (obj2) {
System.out.println(name + "Deadlock 与 Livelock:关键区别
livelock 与 deadlock 的关键区别在于线程状态:在 livelock 中,线程处于 RUNNABLE 状态并消耗 CPU;在 deadlock 中,线程处于 BLOCKED 状态且不消耗 CPU。在 livelock 中,线程处于活跃执行状态,并对彼此的状态变化做出响应。它们不断根据对方线程的行为改变自身状态,但这些反应相互抵消,导致没有工作完成。线程并未阻塞;它们忙于无所事事。
下面的示例使用两个 Worker 对象,每个对象都有一个 active 标志。当一个 worker 检测到另一个处于活跃状态时,它会将自己的标志设置为非活跃,短暂等待,然后重新设置为活跃并重试。由于两个 worker 完全镜像此行为并同时重新激活,双方均无法取得进展。示例中设置了 6 次迭代上限,以便程序终止并观察到该模式;若无此上限,循环将永不退出。
public class LivelockExample {
static class Worker {
private volatile boolean active = true;
private final String name;
Worker(String name) { this.name = name; }
void work(Worker other) {
int count = 0;
// 没有这个上限,循环将永不退出:每次这个 worker 让步时,
// 另一个已经重新激活并再次阻塞进展。
while (count < 6) {
if (other.active) {
System.out.println(name + ": other worker is active, stepping aside");
this.active = false;
sleep(50);
this.active = true;
count++;
} else {
System.out.println(name + ": path is clear, proceeding");
return;
}
}
System.out.println(name + ": gave up after " + count + " attempts");
}
}
public static void main(String[] args) throws InterruptedException {
Worker w1 = new Worker("Worker-1");
Worker w2 = new Worker("Worker-2");
Thread t1 = new Thread(() -> w1.work(w2), "t1");
Thread t2 = new Thread(() -> w2.work(w1), "t2");
t1.start();
t2.start();
t1.join();
t2.join();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
// 预期输出(livelock:线程不断相互让步;
// 顺序因调度器而异):
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
...
Worker-1: gave up after 6 attempts
Worker-2: gave up after 6 attempts
与 deadlock 不同,在整个 livelock 过程中,两个线程均处于 RUNNABLE 状态并持续消耗 CPU;没有线程处于 BLOCKED 状态等待 monitor。
Deadlock
Livelock
定义
线程永久阻塞,每个线程等待另一个线程持有的锁
线程活跃运行但无进展;每个线程对对方的状态做出反应
线程状态
BLOCKED
RUNNABLE
CPU 使用率
接近零(线程未执行)
高(线程持续执行但无有效工作)
检测方法
jstack, JConsole, VisualVM, ThreadMXBean.findDeadlockedThreads()
无 JVM 级检测器;需通过性能分析或手动分析线程活动
解决策略
打破四个 Coffman 条件之一
添加随机性或指数退避以打破同步响应模式
Java 中的死锁与 Project Loom
JDK 21 中的虚拟线程引入了一种称为 pinning 的故障模式,它会耗尽 carrier thread pool 并使应用程序停止,而不会从 jstack 生成 blocked-thread report。虚拟线程是轻量级的、由 JVM 管理的线程,通常在阻塞于 I/O 或锁时会从其底层平台线程(carrier thread)卸载,从而释放 carrier 以运行其他虚拟线程。Pinning 会破坏这一机制:当虚拟线程持有 synchronized monitor 时,它会保持挂载在 carrier 上而无法让出,直到锁被释放,carrier 在此期间无法用于其他虚拟线程。
虚拟线程 Pinning
当虚拟线程持有通过 synchronized 获取的 monitor 时,它会被 pinned 到其 carrier thread。在此期间,carrier thread 无法被重新分配给其他虚拟线程。如果许多虚拟线程同时被 pinned,所有 carrier thread 都可能被等待在 synchronized 块内的线程占用,导致没有新的虚拟线程能够运行。这与死锁具有相同的外部症状:应用程序停止前进并停止接受新工作,日志中没有明显的异常。
这种 pinning 行为意味着,在传统线程代码中无害的 synchronized 块,如果锁定的代码段包含任何阻塞操作,在虚拟线程上下文中可能会成为严重的可用性问题。
在虚拟线程代码中使用 ReentrantLock
对于在虚拟线程上运行且包含阻塞调用(网络 I/O、数据库访问、Thread.sleep() 或任何 wait())的代码,请将 synchronized 替换为 ReentrantLock。ReentrantLock 不会将虚拟线程 pinned 到其 carrier;虚拟线程在等待锁时会正常卸载。
import java.util.concurrent.locks.ReentrantLock;
public class VirtualThreadSafeService {
private final ReentrantLock lock = new ReentrantLock();
public void doBlockingWork() {
lock.lock();
try {
// 此处的阻塞 I/O 是安全的:虚拟线程在等待时从 carrier 卸载
// 将此注释替换为实际的阻塞调用。
performNetworkCall();
} finally {
lock.unlock();
}
}
private void performNetworkCall() {
// 阻塞 I/O 的占位符
}
}
// 预期行为:虚拟线程在等待锁或执行 performNetworkCall() 期间从 carrier 卸载;
// 不会发生 carrier thread pinning。
// 在等待期间,carrier thread 可以自由运行其他虚拟线程。
不要在虚拟线程上运行的代码中使用 synchronized 来保护阻塞操作。Pinning 行为可能会耗尽 carrier thread pool,导致应用程序停止接受新工作,产生与死锁相同的外部效果,但 jstack 不会生成 blocked-thread report。
检测 Pinning
JVM 标志 -Djdk.tracePinnedThreads=full 会在虚拟线程被 pinned 到其 carrier 时向 stdout 打印堆栈跟踪。在开发和负载测试期间使用此标志来查找需要转换的 synchronized 块。
java -Djdk.tracePinnedThreads=full -jar your-application.jar
// 检测到 pinning 时的预期输出:
Thread[#23,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.example.VirtualThreadSafeService.doBlockingWork(VirtualThreadSafeService.java:9) <== monitors:1
标记为 <== monitors:1 的帧标识了导致 pinning 的 synchronized 块。将该特定块重构为使用 ReentrantLock 以解决问题。
FAQ
Java 中的死锁四必要条件是什么?
四个条件是:互斥(同一时间只有一个线程持有资源)、持有并等待(一个线程持有了一个锁,同时等待另一个锁)、不可抢占(JVM 无法强制从线程中夺取锁)和循环等待(线程在等待关系中形成循环)。死锁发生时必须同时满足所有四个条件。移除其中任何一个条件即可防止死锁。
Java 中死锁的一个简单示例是什么?
两个线程,每个线程持有了一个 synchronized 锁并试图获取另一个线程的锁,这会产生 Java 中最简单的死锁。Thread-1 获取了 lockA 并等待 lockB;Thread-2 获取了 lockB 并等待 lockA。两者都无法继续执行,因此程序无限挂起。
如何解决 Java 中的死锁?
在运行的程序中无法解决死锁,除非终止其中一个相关线程。正确的做法是预防:设计锁策略,使至少一个 Coffman 条件无法满足。使用一致的锁排序顺序,尽可能避免嵌套锁获取,或者使用带超时的 tryLock(),让线程在失败时退让并重试,而不是永远阻塞。
处理死锁的四种方法是什么?
四种方法是:预防(设计代码使 Coffman 条件无法满足)、避免(使用如银行家算法等算法拒绝可能导致死锁的资源分配)、检测与恢复(运行时使用 ThreadMXBean 检测循环并终止一个线程来打破它)和忽略(接受系统中的死锁足够罕见,通过重启进程来处理)。生产环境中的 Java 应用最常用预防,在无法完全消除死锁风险的系统中,使用检测结合自动恢复。
Java 中死锁和活锁有什么区别?
在死锁中,线程处于 BLOCKED 状态且不消耗 CPU。在活锁中,线程处于 RUNNABLE 状态并持续消耗 CPU,但它们持续相互反应,导致无法完成任何工作。两种情况产生相同的外部症状(无进展),但原因不同、线程状态不同,检测方法也不同。
如何在运行的 Java 应用中检测死锁?
运行 jstack <pid> 并查看输出末尾的 Found N Java-level deadlock 块。从 JConsole 中,转到 Threads 选项卡并点击 Detect Deadlock。在 VisualVM 中,打开 Threads 选项卡并查找死锁通知横幅和红色线程条。程序化地,调用 ManagementFactory.getThreadMXBean().findDeadlockedThreads() 并检查是否返回非 null 值。
ReentrantLock 能防止 Java 中的死锁吗?
不能。ReentrantLock 可以通过与 synchronized 相同的机制产生死锁:两个线程以相反顺序获取相同的两个锁。ReentrantLock 提供的是 tryLock(),它允许你尝试带超时的锁获取,并在尝试失败时退让。这是死锁预防的工具,但需要有意使用。仅仅从 synchronized 切换到 ReentrantLock 并不能消除死锁风险。
Project Loom 中的虚拟线程如何影响死锁风险?
虚拟线程引入了一种称为 pinning 的故障模式:当虚拟线程持有 synchronized monitor 时,它无法从其 carrier thread 上卸载。如果足够多的虚拟线程同时被 pinning,所有 carrier thread 都会被占用,无法运行新的虚拟线程,这与死锁具有相同的外部效果。在虚拟线程代码的阻塞部分使用 ReentrantLock 而非 synchronized,并在开发期间使用 -Djdk.tracePinnedThreads=full 来定位 pinning 点。
结论
死锁可以通过设计来预防,而不仅仅是在事后检测。四种 Coffman 条件是确切的机制:因为死锁需要这四种条件同时成立,从代码路径中移除其中任何一个,都会使该路径在结构上无死锁。本文中的检测工具为您提供了一种确认实时死锁并在重启前保留证据的方法,但真正目标是设计一种锁机制,使得这些工具很少被需要。
通过所有三个层次的工作,理解您的锁策略满足了哪种 Coffman 条件,当死锁出现时从线程转储中诊断死锁,并应用消除该条件的预防策略,将您从被动调试转向 deliberate concurrency design。对于希望深入了解 Java 并发(超越死锁)的读者,Java concurrency interview guide tutorial 涵盖了生产 Java 系统中的更广泛的并发模式、权衡取舍和设计决策。