Java并发——死锁

在多线程编程中,死锁(Deadlock)是一个经典而又棘手的问题。它如同程序的"交通堵塞",让多个线程互相等待对方释放资源,最终谁也无法继续执行,导致系统挂起。理解死锁的原理、学会避免和排查死锁,是每一位并发程序员的必修课。

本文将从死锁的定义开始,通过生动的代码示例演示死锁的产生,剖析其产生的四个必要条件,并给出多种预防和检测方法,助你远离死锁的困扰。

一、什么是死锁?

死锁是指两个或两个以上的线程,在执行过程中因争夺资源而造成的一种互相等待的现象。若无外力干预,它们都将无法推进下去。简单来说,就是:

线程 A 持有资源 1,等待资源 2;线程 B 持有资源 2,等待资源 1。双方都得不到想要的资源,于是永远阻塞。

二、死锁的四个必要条件

死锁的发生必须同时满足以下四个条件,缺一不可:

  1. 互斥条件:资源一次只能被一个线程占用,如果其他线程请求该资源,只能等待直到释放。

  2. 持有并等待条件:线程已经持有了至少一个资源,又在等待其他资源,且不释放已持有的资源。

  3. 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由自己主动释放。

  4. 循环等待条件:存在一组线程的循环等待链,即 T0 等待 T1 持有的资源,T1 等待 T2 持有的资源,......,Tn 等待 T0 持有的资源。

只要破坏其中任何一个条件,死锁就不会发生。

三、死锁代码示例

下面是一个经典的死锁示例,两个线程分别持有一个锁,并试图获取对方的锁:

java 复制代码
public class DeadlockDemo {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("线程1 获得 lock1,等待 lock2...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock2) {
                    System.out.println("线程1 同时持有 lock1 和 lock2");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("线程2 获得 lock2,等待 lock1...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock1) {
                    System.out.println("线程2 同时持有 lock2 和 lock1");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

运行结果:

java 复制代码
线程1 获得 lock1,等待 lock2...
线程2 获得 lock2,等待 lock1...

之后程序便停滞不前,两个线程互相等待,形成死锁。

四、如何避免死锁?

根据死锁的四个必要条件,我们可以采取相应的预防策略:

1. 破坏"持有并等待"

方法 :一次性申请所有资源,如果申请失败则释放已持有的所有资源。在 Java 中,可以使用 ReentrantLocktryLock 方法尝试获取锁,超时则放弃。

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class AvoidDeadlock {
    private static final ReentrantLock lock1 = new ReentrantLock();
    private static final ReentrantLock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (true) {
                if (lock1.tryLock()) {
                    try {
                        if (lock2.tryLock()) {
                            try {
                                System.out.println("线程1 成功获取两把锁");
                                break;
                            } finally {
                                lock2.unlock();
                            }
                        }
                    } finally {
                        lock1.unlock();
                    }
                }
                // 短暂等待后重试
                try { Thread.sleep(10); } catch (InterruptedException e) {}
            }
        });
        // 线程2 类似
        // ...
    }
}

2. 破坏"不可剥夺"

如果线程在申请新资源时失败,可以主动释放已持有的资源。上面 tryLock 的方式实际上也体现了这一思想。

3. 破坏"循环等待"

最常用的方法:对所有资源进行统一排序,规定线程必须按顺序申请资源。例如,总是先获取 lock1,再获取 lock2,这样就不会出现循环等待。

java 复制代码
synchronized (lock1) {
    synchronized (lock2) {
        // 安全
    }
}

4. 使用更高层次的并发工具

例如使用 java.util.concurrent 包中的 SemaphoreCountDownLatchBlockingQueue 等,它们内部已经处理好资源竞争,减少了手写锁导致死锁的机会。

五、如何检测死锁?

即使代码编写时注意了预防,死锁仍可能因复杂逻辑而偶然出现。我们需要学会在运行时检测死锁。

1. jstack 命令

使用 jstack <pid> 可以打印出 Java 进程的线程堆栈,其中会明确提示死锁信息:

复制代码
Found one Java-level deadlock:
=============================
"线程2":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "线程1"
"线程1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "线程2"

2. JConsole / VisualVM

这些图形化工具可以直接连接到 JVM,在"线程"选项卡中点击"检测死锁"按钮,即可快速定位。

3. 编程方式检测

通过 ThreadMXBean 可以在程序中主动检测死锁:

复制代码
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    // 有死锁发生,可以记录日志或尝试恢复
}

六、死锁恢复策略

一旦发生死锁,通常的恢复手段有:

  • 重启应用(最直接,但可能导致数据丢失)。

  • 杀死其中一个线程(强行释放资源),但 Java 并没有提供直接杀死线程的安全方法,通常通过中断来协作。

  • 使用超时机制 :如 tryLock(long, TimeUnit) 在等待一定时间后放弃,然后释放已持有锁,让其他线程有机会执行。

七、总结

死锁是多线程编程中难以完全避免的问题,但通过理解其产生的四个必要条件,我们可以有针对性地采用预防策略:按顺序申请资源、使用超时锁、减少锁持有时间等。同时,掌握 jstack、JConsole 等工具能帮助我们在问题发生后快速定位。

相关推荐
9523621 小时前
MyBatis
后端·spring·mybatis
FQNmxDG4S1 天前
Java多线程编程:Thread与Runnable的并发控制
java·开发语言
前端老石人1 天前
HTML 字符引用完全指南
开发语言·前端·html
matlab_xiaowang1 天前
Redux 入门:JavaScript 可预测状态管理库
开发语言·javascript·其他·ecmascript
虹科网络安全1 天前
艾体宝干货|数据复制详解:类型、原理与适用场景
java·开发语言·数据库
axng pmje1 天前
Java语法进阶
java·开发语言·jvm
rKWP8gKv71 天前
Java微服务性能监控:Prometheus与Grafana集成方案
java·微服务·prometheus
老前端的功夫1 天前
【Java从入门到入土】28:Stream API:告别for循环的新时代
java·开发语言·python
qq_435287921 天前
第9章 夸父逐日与后羿射日:死循环与进程终止?十个太阳同时值班的并行冲突
java·开发语言·git·死循环·进程终止·并行冲突·夸父逐日
小江的记录本1 天前
【Kafka核心】架构模型:Producer、Broker、Consumer、Consumer Group、Topic、Partition、Replica
java·数据库·分布式·后端·搜索引擎·架构·kafka