1. 深入理解死锁
死锁(“互相阻塞”)是指两个或多个线程彼此无限期等待:每个线程都持有某个资源,同时尝试获取另一个已被其他线程占有的资源。结果是谁也无法继续执行,程序就“卡死”。这就像两辆车在窄桥上迎面相遇:除非有一辆倒车,否则谁也过不去。
在 Java 中,死锁并不是一个异常(Exception),而是程序的“卡住”。线程不会结束、也不会抛错,它们只是无限期地彼此等待。因此这类问题十分阴险:往往只在“某些特定情况下”才会暴露。
死锁产生的四个必要条件
- 1. 互斥(Mutual Exclusion)
资源在同一时刻只能被一个线程占用(例如对象监视器、文件)。 - 2. 占有并等待(Hold and Wait)
线程已经持有一个资源,同时在不释放已持有资源的情况下继续请求另一个资源。 - 3. 不可剥夺(No Preemption)
资源不能被强行剥夺:只有持有该资源的线程自己释放。 - 4. 循环等待(Circular Wait)
存在一个闭环链路,链中每个线程都在等待下一个线程所持有的资源。
只有同时满足以上全部条件,才可能发生死锁。只要破坏其中任意一个,互相阻塞就无法出现。
2. 死锁代码示例
示意
- 有两个资源:lock1 和 lock2(普通对象)。
- 线程 A 先获取 lock1,然后尝试获取 lock2。
- 线程 B 先获取 lock2,然后尝试获取 lock1。
示例(请勿在实际代码中照做!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// 线程 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("线程 1:已获取 lock1");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("线程 1:正在尝试获取 lock2");
synchronized (lock2) {
System.out.println("线程 1:已获取 lock2");
}
}
});
// 线程 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("线程 2:已获取 lock2");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("线程 2:正在尝试获取 lock1");
synchronized (lock1) {
System.out.println("线程 2:已获取 lock1");
}
}
});
thread1.start();
thread2.start();
}
}
运行时会发生什么?
- 线程 1 获取了 lock1,线程 2 获取了 lock2。
- 二者都在 100 毫秒的睡眠中,已成功拿到第一个锁。
- 随后各自尝试获取第二个锁,但它已被对方持有。
- 两个线程彼此无限等待——形成了死锁。
控制台将看到:
线程 1:已获取 lock1
线程 2:已获取 lock2
线程 1:正在尝试获取 lock2
线程 2:正在尝试获取 lock1
为什么会发生死锁?详细解析
- 互斥:每把锁在同一时间只能被一个线程持有。
- 占有并等待:线程 1 持有 lock1 并等待 lock2;线程 2 持有 lock2 并等待 lock1。
- 不可剥夺:没人能从外部“抢走”锁——只能通过退出 synchronized 块释放。
- 循环等待:等待在两个线程之间形成闭环。
3. 如何避免和预防死锁
以相同顺序获取资源
首要原则:如果多个线程需要获取多个资源——请始终以相同的顺序获取。例如:所有代码位置都先 lock1,再 lock2。
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // 统一顺序 "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// 同时操作两个资源
}
}
}
在统一顺序下不会出现循环等待——死锁条件被破坏。
使用带超时的 tryLock(ReentrantLock)
当事先无法确定会用到哪些资源时,使用 ReentrantLock 的 tryLock 方法,避免无限期等待。
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 临界区
} finally {
lock2.unlock();
}
} else {
System.out.println("未能获取 lock2,回退");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("未能获取 lock1,回退");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
优点:可以回退操作并稍后重试。缺点:代码更复杂,但可避免死锁。
尽量缩短持锁时间
尽可能缩短持锁时间。在 synchronized/lock 内只做必要的事;其他逻辑移出临界区。
避免嵌套锁
嵌套的 synchronized/lock 越少,发生死锁的风险越低。如果可以——为相关资源使用同一把锁。
优先使用现成的线程安全结构
标准库已为许多场景提供了解决方案:例如 ConcurrentHashMap 以及 java.util.concurrent 中的其他类,能降低死锁风险并简化代码。
4. 死锁诊断
Thread Dump 与 jstack
Thread Dump 是 JVM 中所有线程状态的快照。
从控制台获取:
jstack <pid>
其中 <pid> 是 Java 进程的标识(可通过 jps 获取)。
在 IDE 的调试面板中通常有“Thread Dump”按钮。
在转储中重点关注:
- 状态 BLOCKED 或 WAITING。
- 类似 waiting to lock ... 与 locked ... 的消息。
- JVM 的提示:"Found one Java-level deadlock:" —— 表示检测到了死锁。
转储片段示例:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
这就是死锁:线程彼此“持有对方需要的资源并相互等待”。
VisualVM 等工具
- VisualVM —— 用于分析线程并发现死锁的免费工具。
- Java Mission Control / Flight Recorder —— 高级监控与性能剖析。
在 VisualVM 中可以查看线程树、它们的状态,并在检测到死锁时获得提示。
表格:“如何避免陷入死锁”
| 死锁原因 | 如何避免 |
|---|---|
| 以不同顺序获取资源 | 始终遵循统一的资源获取顺序 |
| 嵌套的 synchronized | 尽量减少嵌套,能用一把锁就不用多把 |
| 持锁时间过长 | 减少临界区内的工作量 |
| 同时使用多把锁 | 使用 tryLock(带超时)并实现回退逻辑 |
| 非线程安全的集合 | 使用并发集合(如 ConcurrentHashMap 等) |
5. 处理死锁时的常见错误
错误 1:以不同顺序获取资源。 最常见的原因是不同线程以不同顺序加锁。即便“看起来更快”,也要坚持统一顺序——否则循环等待几乎不可避免。
错误 2:不必要的嵌套 synchronized。 过度嵌套会提升死锁风险并增加代码复杂度。尽量简化加锁模型。
错误 3:忽视 tryLock 与超时。 使用 synchronized 会让线程无限等待。如果没有把握,请使用带超时的 tryLock 并实现回退逻辑。
错误 4:在持锁范围内执行耗时操作。 网络请求、I/O 和重度计算都不应在持锁时进行,否则大幅提升问题概率。将它们移出临界区。
错误 5:不分析 Thread Dump。 Thread Dump 是定位互相阻塞的最佳帮手。使用 jstack,分析 BLOCKED/WAITING 状态以及 “holding/waiting to lock” 的链条。
GO TO FULL VERSION