前言
前面我们介绍了synchronized加锁能解决线程安全问题,但是不正确的加锁也会导致线程不安全,死锁就是不正确加锁导致的线程不安全问题.
1.死锁的三种经典场景
1.一个线程一把锁
如果锁是不可重入锁,并且一个线程对这把锁加锁了两次就会出现死锁问题.这就相当于把钥匙所在屋里了.
第二次加锁需要第一次加的锁解锁,但是第二次加锁不成功正在阻塞
2.两个线程两把锁
synchronized锁具有互斥性,即一个线程对一个对象加锁成功,另一个线程对该对象加锁就会发生阻塞.
设想一下这样一个场景,t1线程需要对A,B对象加锁,t2线程也需要对A,B对象加锁.如果t1先对A加锁在对B加锁,t2先对B加锁在对A加锁,但是执行过程中t1对A加锁成功,在对B加锁,但是B的锁已经被t2线程拿到了,t1就会发生阻塞,这时t2线程对A线程加锁也会失败,因为A对象已经被t1线程加锁成功了.这个问题就是死锁问题.
举个例子,这就相当于你家里有两套房子,一套房子A,一套房子B.房子A的钥匙被锁在了B房子中,B房子的钥匙被锁在了A房子中.这时你想要进入任何一个房子都打开另一个房子的大门,这种问题就是死锁问题.
下面看一个代码案例
package blogs;
public class Five {
private static Object lockerA = new Object();
private static Object lockerB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockerB){
System.out.println("t1拿到了两把锁!");
}
}
});
Thread t2 = new Thread(()->{
synchronized (lockerB) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockerA) {
System.out.println("t2拿到了两把锁!");
}
}
});
t1.start();
t2.start();
}
}
从运行结果上我们可以看到进程一直在等待,这就是典型的死锁问题.
3.N个线程M把锁
这个问题有一个典型的例子就是哲学家就餐问题
有一个桌子,围着一群哲学家,哲学家两两之间放着一根筷子,而哲学家只会做两件事:吃面(获取到锁,执行后续代码),思考人生(阻塞等待).
筷子就是锁对象,只有拿起两根筷子才能吃饭.当哲学家思考人生的时候,才会放下左右手的筷子.
如果发生极端情况,五个哲学家同时都拿起左手边的筷子,或者同时都拿起右手边的筷子,就会发现另一边的筷子被别人拿走了,都要等别的哲学家把另一边的筷子放下.由于每个哲学家都想别的哲学家放下筷子,此时就会形成死锁现象.
2.避免死锁问题
1.产生死锁死锁的四个必要条件
1.互斥使用:t1线程拿到锁,t2线程如果也想拿就必须阻塞等待,等t1线程释放.
2.不可抢占:一个线程拿到锁之后,只能主动解锁,别的线程不能强制抢占.
3.请求保持:一个线程拿到A锁之后,在获取B锁,拿到B锁之后不会释放A锁.
4.循环等待:当t1线程尝试获取锁A和B,t2线程尝试获取锁B和A.如果t1线程拿到了A锁,t2线程拿到了B锁,此时t1线程就会等待t2线程释放锁B,t2线程也会等待t1释放锁A.
这4个条件之中前三个都是都是synchronized锁的特性,都修改不了唯一能修改的只有第四条
2.打破循环等待
打破循环等待就需要对代码结构做出调整,就像前面举的例子,把t1线程和t2线程都改为先对A加锁在对B加锁,那么就能解决这个问题.
package blogs;
public class Five {
private static Object lockerA = new Object();
private static Object lockerB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockerB){
System.out.println("t1拿到了两把锁!");
}
}
});
Thread t2 = new Thread(()->{
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockerB) {
System.out.println("t2拿到了两把锁!");
}
}
});
t1.start();
t2.start();
}
}
这样就能避免出现死锁问题.
对于哲学家就餐问题也是一样的
我们做出规定,每个哲学家只能先拿自己左右两边编号小的筷子,在拿编号大的筷子
这样有一个线程开始执行了之后,等它执行完就会把锁资源释放出来,那么就能被其他线程获取到,别的线程就不会一直死等,死锁问题也就自然被避免了.
3.银行家算法
这种方法解决死锁问题在实际开发中,并不推荐使用,比起上述方法更加复杂,也更容易出错,所以更推荐上述方法!
如果对银行家算法感兴趣的,可以自行查询相关资料.