Java程序员-你真的了解死锁吗
💕"i need your breath"💕
作者:Mylvzi
文章主要内容:死锁的成因和必要条件
一.什么是死锁
死锁:就是多个线程/进程因为相互等待而使得各自持有的资源无法继续执行,这就叫做死锁。
我们以可重入锁为例,引入今天要学习的死锁问题
二.可重入锁
1.概念
可重入锁指的是:一个线程针对同一把锁连续加锁两次,而不死锁,就说这个锁具有可重入性;反之,则不具有可重入性
synchronnized(locker) {
// 对locker对象再次加锁
synchronized(locker){}
}
如果此时我们的锁不具有可重入性,则这个代码就会出现死锁的情况
2.Java的synchronized具有可重入性
被synchronized修饰的对象具有可重入性,也就是一个线程能够针对同一个对象连续加锁两次,而不发生死锁
之所以不会发生死锁 ,是因为锁会记录是被哪一个线程持有的,在下一次有线程尝试对该所进行加锁时,先判断该线程是否是持有该锁的线程,如果是,直接加锁成功,这样就避免了死锁的出现
3.释放锁的时机
synchronnized(locker) {
// 对locker对象再次加锁
synchronized(locker){
}// 2处
// ......未执行完的代码
}
对于上述代码,在执行到2处的时候会unlock一次,但是需要释放整个锁吗?或者说能释放整个锁吗?答案显然是不能,因为该线程实际上还在持有这个锁,该线程还没有完全执行完毕,如果在2处直接释放锁,则其他线程就可以对该对象进行修改访问,出现线程安全问题,所以需要等到整个代码执行完毕才能释放锁
也就是说对于上述嵌套加锁的代码来说,无论加锁多少次,必须等到最外围结束才能释放锁
三.死锁常见的几种情况
1.一个线程 一把锁
一个线程连续对同一把锁加锁两次,如果是不可重入的,就会发生死锁(synchronized不会发生)
2.两个线程 两把锁
现在有两个线程tA,tB,还有两把锁locker1和lcoker2,先让tA对locker1进行加锁,tB对locker2加锁,保证两个线程各自持有一把锁;然后再让tA对locker2加锁,tB对locker1加锁,由于locker1和locker2都还没有被释放,且释放的条件是两个线程都被执行完毕,而要想执行完毕就必须对新的对象进行加锁,由于这种矛盾,导致两个线程都会一直处于阻塞状态,发生死锁
// 定义两个用于加锁的对象
private static Object locker1 = new Object();
private static Object locker2 = new Object();
// 创建两个线程
Thread t1 = new Thread(() -> {
// 线程1对locker1进行加锁
synchronized(locker1) {
try {
// sleep非常关键 必须先让两个线程都拥有一把锁,再去交叉加锁 这样才会发生死锁
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 线程1对locker2进行加锁
synchronized(locker2) {
System.out.println("t1 加锁locker2成功!!!")
})
Thread t2 = new Thread(() -> {
// 线程2对locker2进行加锁
synchronized(locker2) {
try {
// sleep非常关键 必须先让两个线程都拥有一把锁,再去交叉加锁 这样才会发生死锁
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 线程2对locker1进行加锁
synchronized(locker1) {
System.out.println("t2 加锁locker1成功!!!")
})
打印结果:
此时就发生了死锁,两个线程因为相互等待对方持有锁的释放而都处于阻塞状态,导致各自代码无法继续运行,产生死锁!!!
3.N个线程,M把锁
N个线程,M把锁的情况可以看作情况2的一个扩展,而对于这种情况的解释有一个很好的例子–哲学家就餐问题
现在有5个哲学家围在一个餐桌旁边,但是餐桌上只有5根筷子(不是5双筷子),只有当一个哲学家拿到两根筷子的时候他才能就餐,对于每一个哲学家来说,他只干两件事:
- 思考人生
- 就餐
就餐规则如下:
- 哲学家什么时候思考人生,什么时候就餐是随机的
- 哲学家如果正在就餐,其结束就餐的时机是随机的
- 当哲学家思考人生的时候,不会拿取一根筷子
- 当哲学家就餐的时候,会拿起左右两侧的两根筷子
其写到这里大家其实也能发现,这里的哲学家就是线程,筷子就是锁
在一般情况下,每个哲学家就餐持有两个筷子的时候,如果其他哲学家也需要其中的一个筷子,则其他的哲学家就要阻塞等待,等待上一个哲学家就餐完毕,他才能使用,但是如果有那么一瞬间,每个哲学家同时都拿起了自己左侧的筷子,此时他们还无法就餐,因为他们还需要右侧的筷子,当他们去寻找右侧的筷子的时候,发现其他哲学家已经拿走了,那他就要等待持有右侧筷子的哲学家就餐完毕,但是每个哲学家因为都只有一根筷子,就都无法就餐完毕,导致每个哲学家都在循环等待,每个哲学家都处于阻塞状态,发生了死锁
综上,死锁其实就是一种bug,死锁的存在会导致线程的崩溃,资源的的浪费,那如何解决死锁呢?要想解决死锁,先要了解死锁形成的四个必要条件
四.死锁形成的四个必要条件
必要条件是指四个条件都满足,才会发生死锁,死锁形成的四个必要条件大致可以分为两部分
- 锁的基本特性
- 代码结构导致死锁出现
下面来看死锁形成的四个必要条件:
- 互斥使用(锁的基本特性):当一个线程已经拥有一个锁时,另一个线程也想要持有这个锁,就要阻塞等待
- 不可强占(锁的基本特性):当一个锁已经被一个线程持有时,其他线程无法强制强占该锁(总不能我正吃着饭你把我筷子抢走吧,那我吃啥!)
- 请求保持(代码结构):当一个线程已经持有一个锁的时候,尝试去持有另一个锁(tA已经持有locker1了,还想持有locker2)这是典型的吃着碗里的,看着锅里的
- 循环等待(代码结构):每一个线程都在等待其他线程结束来使自己持有锁,等待形成了依赖关系(就是上面的哲学家就餐问题)
只有当上述四个条件都满足的时候才会发生死锁,所以说要想形成死锁,其实也是一件不简单的事~
那如何避免死锁呢?对于必要条件1,2来说,这是锁的基本特性,是由synchronized决定的,你无法更改,可以更改的条件只有3,4
对于3来说,我们可以尝试避免嵌套结构的出现,但是有些业务场景之下我们必须要进行嵌套的调用又该怎么办?其实,有一个方法可以很好的解决3,4这两种场景,即规定锁的使用顺序
比如对于3来说,我们规定每个线程获取锁的顺序是固定的,只能先获取locker1,再获取locker2,不能直接获取locker2,这样就避免了一个线程在持有锁的前提下再去持有其他锁
对于4来说,就相当于规定了哲学家拿取筷子的方式,比如只能拿哲学家左右两侧编号较小的筷子
五.总结
本文主要讲述了死锁的成因,由可重入锁引入什么是死锁,再讲述了死锁形成的三种常见情况,最后又讲述了死锁形成的四个必要条件–互斥使用,不可强占,请求等待,循环等待,欢迎大家补充,交流