目录
·前言
一、一个线程,一把锁
1.问题介绍
2.可重入锁
二、两个线程,两把锁
1.问题介绍
2.解决方式
三、N个线程,M把锁
1.哲学家就餐问题
2.解决方式
·结尾
·前言
“死锁”是多线程代码中一类常见的问题,加锁是能解决线程安全的问题,但是如果加锁的方式不当,就可能产生“死锁”,本篇文章就会对“死锁”的三个比较常见的场景进行介绍。
一、一个线程,一把锁
1.问题介绍
当你在编程过程中,使用的锁是不可重入锁,并且在同一个线程中对同一把锁进行两次加锁,此时就可能面临“死锁”的状况,代码如下,只不过在Java语言中,锁都是可重入锁,所以并不能演示出“死锁”的效果,不过相关的逻辑可以用Java代码进行表示,如下图所示:
上面这段代码,如果锁是不可重入锁就会出现“死锁”,因为第二次对locker进行加锁时由于locker还没有被第一次加锁操作释放所以会进入阻塞等待,然而想要释放locker必须执行完第一个synchronized中的代码,此时就会进入“死锁”,也就是卡住,利用生活中的例子就像你把钥匙锁在了屋里一样,想要开门就需要钥匙,可是钥匙确被门锁了起来。此时想要解决这种情况,很简单,不在同一个线程中对同一把锁重复加两次就好了,或者使用可重入锁,都可以解决这种问题。
2.可重入锁
Java中的锁是可重入锁,在这里再对可重入锁进行一个简单介绍。
对于可重入锁来说,它的内部会持有两个信息:
- 当前这个锁是哪个线程持有的
- 记录加锁次数的计数器
下面再利用上面演示的代码,对Java中可重入锁的执行过程进行详细介绍: 在上述对同一把锁进行第二次加锁时,会先判断当前加锁的线程是否是持有锁的线程,如果不是同一个线程,那么就会进行阻塞,如果是同一个线程就只会进行计数器+1的操作,没有其他的操作了。由于Java中的锁是可重入锁,所以上述代码在Java中不会出现死锁的情况。
二、两个线程,两把锁
1.问题介绍
当你创建了两个线程,线程一获取到了锁A,线程二获取到了锁B,接下来线程一尝试获取锁B,线程2尝试获取锁A,这时就会出现“死锁”,一旦出现“死锁”,线程就会“卡住了”,就无法再继续工作,这属于一个严重的bug,下面利用代码对这种情况进行演示:
public class Test2 {public static void main(String[] args) {Object A = new Object();Object B = new Object();Thread t1 = new Thread(()->{synchronized (A) {try {// sleep 一下,是给 t2 时间,让 t2 获取到 BThread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// 尝试获取 B, 但没有释放 Asynchronized (B) {System.out.println("线程 1 拿到了两把锁");}}});Thread t2 = new Thread(()->{synchronized (B) {// sleep 一下, 是给 t1 时间,让 t1 获取到 Atry {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// 尝试获取到 A, 并没有释放 Bsynchronized (A) {System.out.println("线程 2 拿到了两把锁");}}});t1.start();t2.start();}
}
运行结果如下: 此时,线程一等线程二释放锁B,线程二等线程一释放锁A,两个线程互不想让,就出现了“死锁”这种状况,这就好比,你把家的钥匙锁在了车里,然而车的钥匙却锁在了家里。
2.解决方式
面对上述的问题,最好的解决方式就是规定加锁的顺序,我们约定先对A加锁,然后在对B加锁,此时问题就会得到解决,按照这个方案对代码进行修改,代码如下:
public class Test2 {public static void main(String[] args) {Object A = new Object();Object B = new Object();Thread t1 = new Thread(()->{synchronized (A) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// 尝试获取 B, 但没有释放 Asynchronized (B) {System.out.println("线程 1 拿到了两把锁");}}});Thread t2 = new Thread(()->{synchronized (A) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// 尝试获取到 B, 并没有释放 Asynchronized (B) {System.out.println("线程 2 拿到了两把锁");}}});t1.start();t2.start();}
}
运行结果:
三、N个线程,M把锁
1.哲学家就餐问题
N个线程,M把锁这种情况有一个非常经典的问题,这就是哲学家就餐问题,下面来简单介绍一下这个问题,如下图所示:
话说从前有五个哲学家,他们坐在桌子前,在他们两两之间放着一根筷子,这里规定每个哲学家只能拿起双手边的两根筷子, 此时当一个哲学家要吃锅包肉的时候,坐在他旁边的两个哲学家就需要阻塞等待,只有当这个哲学家吃完的时候,主动放下筷子,他旁边的两个哲学家才能拿到他手里的筷子,这里虽然筷子的数量不充裕,但是也还好,因为每个哲学家除了吃锅包肉以外还要做一件事,就是“思考人生”,这时哲学家就会放下筷子,由于每个哲学家什么时候吃锅包肉,什么时候“思考人生”是不确定的,所以这个模型在一个特殊情况下是不可以正常工作的。
假设,在同一时刻,所有的哲学家都想吃锅包肉了,他们同时抄起了左手的筷子,这个时候他们再想拿右手的筷子,就拿不到了,因为右手的筷子被别的哲学家给拿了,此时,由于所有的哲学家都不想放下已经拿起来的筷子,就要等旁边的哲学家放下筷子,可是没有哲学家吃到锅包肉,也就没有哲学家放下筷子,这样就出现了“死锁”,如下图所示:
在上述问题中,每个哲学家就相当于一个线程,五个筷子就相当于五把锁,哲学家们啥时候吃锅包肉,啥时候“思考人生”这属于“随机调度”,绝大部分情况下是可以正常工作,但是出现上图情况是就会出现“死锁”。
2.解决方式
解决“死锁”的问题,方案有很多种,在解决上述问题前,先解释一下“死锁”产生的四个必要条件(全部具备才可以,缺一不可):
- 互斥使用:获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想获取这把锁就需要阻塞等待。
- 不可抢占:一个线程拿到了锁之后,只能主动解锁,不能让别的线程强行把锁抢走。
- 请求保持:一个线程拿到了锁A之后,在持有锁A的前提下尝试获取锁B。
- 循环等待/环路等待:这是一种代码结构。
解决死锁问题,核心思路,破坏上述的必要条件,破坏一个就可以解决, 那么这个问题可以从哪里入手呢?首先,第一个互斥使用和第二个不可抢占,这是锁的基本特性,不好破坏,其次,第三个请求保持,这个要看实际代码与实际需求,最后,第四个循环等待,关于代码结构,破坏起来是最容易的,所以我们解决这个问题从第四个必要条件开始。
这里我们只有指定一定的规则,就可以有效的避免循环等待,比如,我们指定加锁的顺序:
针对五把锁,都进行编号,约定每个线程获取锁的时候,一定要先获取编号小的锁,再获取编号大的锁。
这种解决方案,可以如下图所示(为了表示方便,这里对哲学家们也进行了编号):
图中由于一号哲学家获取了一号筷子,导致二号哲学家想获取编号小的筷子时只能等一号哲学家放下筷子,由于规定必须先获取编号小的筷子后获取编号大的筷子,所以二号哲学家不会获取三号筷子,这样就保证在餐桌上至少会有一位哲学家正在进餐,上述图中表示的只是一种情况,规定完拿筷子的顺序之后就解决了哲学家进餐的问题。
解决哲学家就餐问题其实有很多种方案:
- 多添置一些额外的筷子
- 减少一个哲学家
- 引入计数器,限制最多多少人吃锅包肉
- 引入拿筷子的规则
- 利用“银行家算法”
这些方案中,1~3方案虽然不复杂,但是普适性不高,在一些特定需求中不可以使用,第5个方案,确实可以解决“死锁”问题,但是个人并不推荐,因为比较复杂,容易引入新的问题。
·结尾
本篇文章到这就快要结束了,在进行多线程编程时,线程安全是一个让我们感到复杂的问题,“死锁”只是其中问题之一,上述的所有内容都是在对“死锁”的问题进行介绍与解决,如果文章有问题欢迎在评论区进行讨论,如果感觉文章讲述还不错的话,也希望大家能给个三连支持一下~~你们的支持就是我最大的动力,我们下一篇文章再见吧┏(^0^)┛