CodeGym /课程 /JAVA 25 SELF /死锁(Deadlock):原因、示例与排除

死锁(Deadlock):原因、示例与排除

JAVA 25 SELF
第 53 级 , 课程 0
可用

1. 深入理解死锁

死锁(“互相阻塞”)是指两个或多个线程彼此无限期等待:每个线程都持有某个资源,同时尝试获取另一个已被其他线程占有的资源。结果是谁也无法继续执行,程序就“卡死”。这就像两辆车在窄桥上迎面相遇:除非有一辆倒车,否则谁也过不去。

在 Java 中,死锁并不是一个异常Exception),而是程序的“卡住”。线程不会结束、也不会抛错,它们只是无限期地彼此等待。因此这类问题十分阴险:往往只在“某些特定情况下”才会暴露。

死锁产生的四个必要条件

  • 1. 互斥(Mutual Exclusion)
    资源在同一时刻只能被一个线程占用(例如对象监视器、文件)。
  • 2. 占有并等待(Hold and Wait)
    线程已经持有一个资源,同时在不释放已持有资源的情况下继续请求另一个资源。
  • 3. 不可剥夺(No Preemption)
    资源不能被强行剥夺:只有持有该资源的线程自己释放。
  • 4. 循环等待(Circular Wait)
    存在一个闭环链路,链中每个线程都在等待下一个线程所持有的资源。

只有同时满足以上全部条件,才可能发生死锁。只要破坏其中任意一个,互相阻塞就无法出现。

2. 死锁代码示例

示意

  • 有两个资源:lock1lock2(普通对象)。
  • 线程 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

当事先无法确定会用到哪些资源时,使用 ReentrantLocktryLock 方法,避免无限期等待。

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”按钮。

在转储中重点关注:

  • 状态 BLOCKEDWAITING
  • 类似 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” 的链条。

1
任务
JAVA 25 SELF, 第 53 级, 课程 0
已锁定
机器人之间的接力棒传递:死锁风险 🤖
机器人之间的接力棒传递:死锁风险 🤖
1
任务
JAVA 25 SELF, 第 53 级, 课程 0
已锁定
从死锁中拯救:带超时的聪明工程师 🛠️
从死锁中拯救:带超时的聪明工程师 🛠️
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION