多线程 2 - 死锁问题
死锁
死锁,是多线程代码中的一类经典问题。加锁能够解决线程安全问题,但如果加锁方式不当,就很可能产生死锁。
出现死锁的三种场景
1、一个线程一把锁
就像上篇文章讲过的,如果对同一个线程上了两把锁,而且上的锁是不可重入锁的话,就会出现死锁问题。
2、两个线程两把锁
线程1获取锁A,同时线程2获取锁B,接下来,线程1尝试获取锁B,线程2尝试获取锁A。此时,就同样出现死锁了,一旦出现死锁,进程就会卡住,无法继续工作。死锁,是属于进程中的最严重的一种bug!!!
代码示例:
public class ThreadDemo22 {public static void main(String[] args) {Object A = new Object();Object B = new Object();Thread t1 = new Thread(()->{//sleep一下,是为了给t2时间,让t2也获取到锁synchronized (A){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//不释放A尝试获取Bsynchronized (B){System.out.println("t1获得了两把锁");}}});Thread t2 = new Thread(()->{synchronized (B){//给t1时间,让t1也获取到锁try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//尝试获取A,没有释放Bsynchronized (A){System.out.println("t2获得了两把锁");}}});t1.start();t2.start();}
}
点击运行,可以看到什么都没有打印:
通过jconsole进行观察 :
Thread-0被Thread-1锁住了,状态为BLOCKED;Thread-1也被Thread-0锁住了,状态为BLOCKED。
3、N个线程M把锁
这种情况就会涉及到“哲学家就餐”的问题。
有五个哲学家围坐在一张圆桌旁边,他们需要做的事就是思考和进餐。圆桌上有五根筷子,每两个哲学加之间放一根。当科学家需要进餐时,必须要同时拿起左右两边的筷子。如果筷子已经被其他科学家占用,那么当前要进餐的科学家必须等待,直到筷子可用。
也就是说,当某个哲学家在吃面条的过程中,他旁边的两位科学家,就需要进行阻塞等待(五个筷子就相当于五把锁,每个科学家,就相当于一个进程)。当线程拿到锁的时候,就会一直持有这把锁,除非它释放了锁(哲学家吃完了,主动放下了筷子)。在这个线程拿到这把锁的时候,其他线程不能获取到这把锁(哲学家都是有身份的人,不能硬抢筷子)。
虽然筷子数量并不充裕,但是,每个科学家,除了吃面条之外,还需要思考人生,在思考人生时,科学家是会放下筷子的。
由于每个哲学家,什么时候吃面条,什么时候思考人生,这个事情是不确定的(随机调度) ,绝大多数情况下,上述模型都是能够正常工作的。
但是,有一些极端的特殊情况,是无法正常工作的。
假设同一时刻,所有哲学家,都想吃面条,同时拿起了左手的筷子,这时,他们尝试拿右边的筷子。诶,发现拿不起来了(因为右边的筷子给别的科学家拿了)。
此时,由于所有的哲学家,都不想放下已经拿起来的筷子,就要等待旁边的人放下筷子,但是由于没有人吃到面,也就没有人放下筷子(当前每个线程锁里的任务还没有完成,也就没有线程释放锁,此时也就变成死锁了)。
产生死锁的四个必要条件
1、互斥使用:获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想获取就需要阻塞等待。
2、不可抢占:一个线程拿到锁之后,只能主动解锁,不能让别的线程,强行把锁抢走。
3、请求保持:一个线程在拿到A之后,在持有A的前提下,尝试获取B。
4、循环等待/环路等待: 就像上面的情况,五个哲学家同时想吃饭的时候,同时拿起了左边的筷子。
解决死锁问题
解决死锁问题就需要破坏上述四种必要条件的其中一个。
1、互斥使用。这是锁的基本特性,我们很难破坏。
2、不可抢占。这也是锁的特性,我们很难破坏。
3、请求保持。这个操作是取决于代码结构的,不一定可以破坏,要看实际代码需求。
4、循环等待/环路等待。这个是对比其他三个,最好破坏的。
我们可以通过指定加锁顺序,针对五把锁和五个哲学家,都进行编号,约定当每个哲学家(线程)拿筷子时候(获取锁的时候),必须要先获取编号小的筷子(锁),再获取编号大的筷子(锁)。
如果我们指定了加锁顺序,也就相当于指定了哲学家拿筷子的顺序,哲学家只能拿自己面前的两只筷子,且要先拿编号小的筷子。
那么,从2号哲学家开始,当他拿筷子的时候,眼前的1、2两只筷子,他需要先拿1筷子:
2号哲学家拿完后,轮到3号哲学家,因为我们指定要拿编号小的筷子,所以他要拿的是2号筷子 同理,4、5号这学家要拿3、4号筷子。
但轮到1号哲学家时,因为我们指定要先拿编号小的筷子,即1号哲学家,要先获取1号筷子,但是此时1号筷子在2号哲学家手上,1号哲学家就只能等着了(阻塞等待)。此时5号筷子时空闲的,因此5号哲学家,就可以拿5号筷子,和4号筷子组成一双筷子吃面了!!!当5号哲学家吃完后,放下4、5号筷子,4号哲学家就可以吃面了,依此类推,3号、2号、1号哲学家都能吃上面了。
因此,对于刚才出现死锁的代码,我们只需要让t2线程先获取A锁再获取B锁就可以解决死锁问题了。
代码如下:
public class ThreadDemo22 {public static void main(String[] args) {Object A = new Object();Object B = new Object();Thread t1 = new Thread(()->{//sleep一下,是为了给t2时间,让t2也获取到锁synchronized (A){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//不释放A尝试获取Bsynchronized (B){System.out.println("t1获得了两把锁");}}});Thread t2 = new Thread(()->{synchronized (A){//给t1时间,让t1也获取到锁try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//尝试获取A,没有释放Bsynchronized (B){System.out.println("t2获得了两把锁");}}});t1.start();t2.start();}
运行结果:
那我们能不能指定编号小的哲学家先吃面呢?(编号小的线程先上锁)
这是不可行的,因为线程都是“随机调度”、“抢占式执行的”,想办法让某个线程先加锁,违背了“随机调度的原则”,可行性是不高的。而约定加锁顺序,在写代码的时候是非常容易做到的。
解决死锁的其他方法
1、引入额外的筷子:引入额外的锁。
2、去掉一个线程。
3、引入计数器,限制最多同时存在多少个线程。
上面三种方案,其实现并不难,但它们的适用性不高。
4、刚才讲过的引入加锁顺序的方法(普适性较高,且容易实现)。
5、学校的操作系统课中会有一种“银行家算法”,这个方案,确实可以可以解决死锁问题,但我们在实际开发中,一般不会这么做。因为这种方法实在太复杂了,为了解决死锁问题,实现“银行家算法”……死锁解没解决不确定,搞不好,我们在实现“银行家算法”的过程中,就bug满天飞了!!!(也就是说这种方法,理论上可行,实际中并不推荐)。