死锁在多线程代码中是非常严重的BUG,一旦代码中出现死锁就会导致线程卡死。
当单个线程连续两次对同一个对象进行加锁操作时,如果该锁是不可重入锁就会发生死锁(线程卡死)
两个线程两把锁,如果出现这种情况也是会发生死锁:线程t1已经获取了锁A,线程t2已经获取了锁B,此时t1想要获取锁B,t2想要获取锁A。
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(()->{
synchronized(lock1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized(lock2) {
}
}
});
Thread t2 = new Thread(()->{
synchronized(lock2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized(lock1) {
}
}
});
t1.start();
t2.start();
//让主线程等待 2 秒
Thread.sleep(2000);
//此时t1和t2两个线程会因为互相争对方的锁,而导致死锁
System.out.println(t1.getState());
System.out.println(t2.getState());
如果此时有N个线程M把锁(N,M>>2),就更加容易发生死锁的情况了。
一个非常经典的N个线程M把锁的问题:哲学家就餐问题。
假设有5名哲学家围在一张桌子上吃面,现在桌子上有5根筷子。(哲学家会做两件事:思考和吃面(吃面必须要拿到两根筷子,吃完后会将筷子放回原处)。且做这两件事的时间是完全随机的,同一时间只能做一件事)
大多数情况下是不会出现问题的但也会出现一些极端情况:现在所有的哲学家都想吃面,他们同时拿起了自己左手边的筷子,此时每位哲学家手里都有且仅有一只筷子,此时每位哲学家都在等待另一支筷子就会发生死锁。
那么该如何解决死锁问题呢?首先我们先要了解死锁的必要条件,然后根据这些条件来修改。
引发死锁的必要条件
- 互斥(锁的基本特性);当一个线程获取到锁后,如果另一个线程也想获取该锁就会阻塞等待。
- 不可抢占(锁的基本特性);当线程A获取到锁后,如果线程B也想获取该锁只能等待A将该锁释放后再获取,不能直接抢。
- 请求保持(代码结构);一个线程获取了A锁后继续获取B锁……且前面获取的锁不进行释放。
- 循环等待/环路等待(代码结构);等待的依赖关系形成了环。
一个死锁代码一定会满足上述四种情况,任意一个不满足都不会形成死锁。
死锁的解决方法
解决死锁的情况只要破坏上述条件中的任意一个就行了。
上述四条中1和2都是锁的基本特性,所以无法改变。
对于3,在代码中尽量避免出现锁嵌套的情况,但是这种情况很难避免,因为实际代码中的嵌套往往都是这样的,很难发现和避免:
public void fun1(){
synchronized (this) {
fun2();
}
}
public void fun2(){
fun3();
}
public void fun3(){
fun4();
}
public void fun4(){
synchronized (this) {
}
}
所以解决4就显得非常重要了,那么4这种情况该如何避免呢?
有一个非常简单的方法那就是约定加锁的顺序。
例如对于上文中的哲学家就餐的问题:
现在给每支筷子进行编号,约定每位哲学家拿筷子的顺序都是必须先拿面前编号较小的然后再拿编号较大的。
B拿1号筷子->C拿2号筷子->D拿3号筷子->E拿4号筷子->A拿1号筷子但是1号筷子此时在B手中所以A会阻塞等待,此时E拿到5号筷子吃完后放下筷子->D拿到4号筷子吃完后放下筷子->C拿到3号筷子吃完后放下筷子->B拿到2号筷子吃完后放下筷子。
此时就完美避免了死锁问题的发生。