Java 并发编程解析:死锁成因、可重入锁与解决方案
引言
在多线程中,“锁” 是保证线程安全的核心工具 —— 它能避免多个线程同时操作共享资源导致的数据错乱。
但在实际开发中,不合理的加锁逻辑往往会引发新的问题,其中 “死锁” 就是最棘手的场景之一。在前一篇博客中,我们已经梳理了锁的基本概念与作用,今天就来聊一聊死锁。
单线程连续加锁:可重入锁如何避免 “自阻塞”
我们首先来观察下面这些代码:
class Counter2{private int count=0;public void add(){synchronized (this){count++;}}public int get(){return count;}
}public class Demo4 {public static void main(String[] args) throws InterruptedException {Counter2 counter=new Counter();Thread t1=new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (counter){synchronized (counter) {synchronized (counter){counter.add();}}}}});t1.start();t1.join();System.out.println("count="+ counter.get());}
}
运行结果如下:
当我们第一次进行加锁操作的时候(锁没有人使用)是可以执行成功的。
当第二次进行加锁操作的时候,这时的锁对象已经是处于被占用的状态,当我们再尝试进行加锁的时候,就会触发阻塞等待。
此时我们要是想解除阻塞,需要往下执行才可以,但是不要忘记的是,要想往下执行,需要等到第一把锁被释放。
我们把这样的问题就称为“死锁”。
为了解决上述问题,Java中的synchronized就引入了“可重入”的概念,接下来我们来细说一下这个概念。
可重入
所谓可重入就是当一个线程针对某一个锁加锁成功之后,后续该线程再次针对这把锁进行加锁,不会触发阻塞等待,而是会接着往下进行。
但是如果这时候其他线程尝试进行加锁操作,就会正常触发阻塞等待。
因为当前这把锁就是被这个线程所持有的~~~
这里要明确两个关键点是,必须是同一个线程针对同一把锁。
可重入的实现原理,关键在于让锁对象内部保存两个关键信息:持有锁的线程和重入次数。
这样在每次有线程尝试对这把锁进行加锁的时候,会先进行对比,看看这个锁持有者的线程和当前要加锁的线程是否是同一个。
若是同一个,直接让“重入次数+1”,无需阻塞等待;
若不是,则会触发阻塞等待,直到锁被释放。
当线程释放锁时,会让 “重入次数 - 1”,直到重入次数为 0 时,才真正释放锁(其他线程可获取)
所以,“可重入”,本质是让锁能识别 “当前持有者”。
多线程多锁竞争
public static void main(String[] args) throws InterruptedException {Object locker1=new Object();Object locker2=new Object();Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}});Thread t2=new Thread(()->{synchronized (locker2){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1){System.out.println("t2线程将两把锁都拿到");}}});t1.start();t2.start();t1.join();t2.join();}
这里需要注意的细节是,必须是拿到第一把锁之后,再尝试拿起第二把锁(前提是不可以释放第一把锁)
下面我们来思考这样一个问题:
如果我们把这两个休眠操作去掉,还会出现死锁的情况吗?
答案是不一定~~~
我们这里添加了2个休眠操作是为了确保t1拿到locker1并且t2拿到了locker2,
然后再让t1去拿locker2,t2去拿locker1。
如果取消了休眠操作,那么很有可能一开始t1就拿到了这两把锁,此时t2还没启动。
那么这时就不会出现死锁的情况了
N个线程,M把锁
我们以哲学家就餐问题来说明这种情况:
假设现在有5个哲学家围在桌子前,桌子上有5个筷子(不是5双),还有5碗面。
每个哲学家(线程)要想吃面,得同时拿到左右两根筷子(两把锁)才能开始吃。
正常情况(大部分时候能运转)
比如哲学家 1 先拿到左边筷子(锁 1),接着顺利拿到右边筷子(锁 2),吃完后放下筷子(释放锁);哲学家 2 也能按类似逻辑,拿到自己的左右筷子,正常就餐。
此时多个线程(哲学家)按需获取锁(筷子),不会互相卡住。
死锁情况(极端场景)
如果同一时刻,所有哲学家都想吃饭,且都先拿起了左手边的筷子:
- 哲学家 1 拿着筷子 1(锁 1),等筷子 2(锁 2);
- 哲学家 2 拿着筷子 2(锁 2),等筷子 3(锁 3);
- ……
- 哲学家 5 拿着筷子 5(锁 5),等筷子 1(锁 1)。
这时每个线程(哲学家)都握着一把锁(一根筷子),又都在等其他线程(哲学家)手里的锁(另一根筷子),这就形成了循环等待。
这就是N个线程(5 个)争夺M把锁(5 把)导致的死锁。
核心逻辑总结
当多个线程(N个)需要按顺序获取多把锁(M把),如果线程间出现循环等待锁的情况(你等我的锁,我等他的锁,最后又绕回来等你的锁),就会触发死锁。
哲学家就餐问题是 “N个线程 + M把锁” 引发死锁的经典案例。
下面我们来总结一下构成死锁的四个必要条件:
构成死锁的四个必要条件
通过上面的案例,我们可以总结出死锁的四个必要条件—— 只要同时满足这四个条件,死锁就一定会发生;反之,只要破坏其中任意一个,就能避免死锁。
- 锁是互斥的:当线程A拿到锁Z之后,线程B再尝试获取锁Z,就会阻塞等待。
( 同一时间,一把锁只能被一个线程持有) - 锁是不可抢占(剥夺)的:当线程A拿到锁X之后,线程B也想尝试获取锁Z,那么线程B就必须阻塞等待,不可以直接把锁抢过来。
(线程持有锁后,其他线程不能 “强制剥夺” 这把锁,只能等待持有者主动释放) - 请求和保持:当线程A拿到锁Z之后,在不释放锁Z的情况下,就尝试获取锁X。
(线程持有已获取的锁后,不释放它,同时去请求其他锁) - 循环等待:多个线程,多把锁之间的等待过程,形成了“循环”。比如:A等待B,B等待C,C等待A。
(多个线程之间形成 “你等我、我等他、他等你” 的循环等待链)
如何避免死锁?
1.破坏 “请求与保持条件”:不嵌套加锁,释放后再请求
下面是修改之前构成死锁的代码:
Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}});
将 “持有 A 锁时请求 B 锁” 的嵌套逻辑,改为 “释放 A 锁后再请求 B 锁”。所以上述代码可修改为:
Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}});
这种方式的核心是:每次只持有一把锁,请求新锁前必须释放已持有的锁 —— 直接破坏 “请求和保持”,自然不会触发死锁。
2. 破坏 “循环等待条件”:约定统一的加锁顺序
让所有线程都按 “固定顺序” 获取多把锁,避免循环等待。以上述代码为例,我们可以让这两个线程都先拿locker1再拿locker2。
// t1线程:先locker1,再locker2
Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}
});// t2线程:同样先locker1,再locker2(统一顺序)
Thread t2=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t2线程将两把锁都拿到");}}
});
总结
死锁是并发编程中的 “隐形陷阱”,但并非无法预防。
核心在于理解它的四个必要条件,并通过 “避免嵌套加锁”“约定加锁顺序” 等方式破坏这些条件。