目录
一、死锁场景
场景1:1个线程1个锁
场景2:2个线程2个锁
场景3:N个线程M个锁
二、出现死锁的四个必要条件
1)锁的互斥性(Mutual Exclusion)
2)锁的不可抢占性(Non-preemption)
3)请求保持(Hold and Wait)
4)循环等待(Circular Wait)
三、避免死锁的方式
1.锁的互斥性(不可干预)
2.锁的不可抢占性(不可干预)
3.请求锁时,不允许持有锁
4.打破循环依赖
一、死锁场景
为了能够加深对死锁产生原因的理解,我们先来看看产生死锁的几个经典场景:
场景1:1个线程1个锁
对这个线程进行重复上锁:
public class Threads {
static int count = 0;
//实现加锁
private synchronized static void add() {
count++;
}
public static Object object=new Object();
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
//第一次上锁
synchronized (object){
//程序会永远停留在这里
/*第二次上锁,需要等待第二次解锁,但是第二次解锁需要等待第二次上锁成功。
* */
//第二次上锁
synchronized(object){
}
}
});
t.start();
t.join();
}
}
注意:
在JAVA的synchronized关键字中,不会出现这中情况,因为synchronized关键字会自动识别,只对同一个线程的同一个对象上锁一次。
那么synchronized是怎么完成这一操作的呢?
底层使用的是引用计数:
这样,不论加几次锁,实际的锁只有一个。
场景2:2个线程2个锁
我先用一个形象的例子说明:
假如有两个小朋友A和B分别在一个独木桥的两端,他们都要过这个一个独木桥。
当他们在独木桥相遇的时候,出现了这样一个情况,两个人都相互谦让:
最后结果就是两个人都停留在那里,都走不了。
用代码演示:
public class Threads {
static int count = 0;
//实现加锁
private synchronized static void add() {
count++;
}
public static Object object1=new Object();
public static Object object2=new Object();
public static void main(String[] args) throws InterruptedException {
Thread threadA=new Thread(()->{
synchronized (object1){
System.out.println("线程A对1上锁");
try {
Thread.sleep(1000);//休眠确保线程B对1 2都上锁,然后在执行A线程接下来的程序
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(object2){
System.out.println("线程A在对2上锁");
}
}
});
Thread threadB=new Thread(()->{
synchronized(object2){
System.out.println("线程B对2上锁");
synchronized(object1){
System.out.println("线程B对1上锁");
}
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
运行结果:
打印完这两句话后进入死循环。
注意:
出现这种死锁必须保证这两个线程是在持有一个锁的基础上,又去加另一个锁,比如A线程在上了1锁的前提下(synchronized代码块内),有取加了2的锁:
线程B,也必须如此:
场景3:N个线程M个锁
场景三是一个很经典的模型:
假如说下面五位都要吃到桌上的食物,每个人需要用一双筷子才行:
上面的五位哥哥就是五个线程,五支筷子就是五个不同对象的锁。
通常情况下,都能吃到里面的鸡汤(一个哥哥用完一双筷子,把筷子留给其他哥哥用),但是有时候程序运行时,会出现这样一个特殊的情况:
也就是他们同时拿其同一侧的一只筷子的时候,每一位哥哥都拿了一支筷子,但是每一个哥哥都尝不到里面的鸡汤,因为必须持有一双筷子才能尝到鸡汤的鸡肉,于是每位各个都一直干等着,这样就形成了死锁。
二、出现死锁的四个必要条件
1)锁的互斥性(Mutual Exclusion)
可以把锁想象成一个很小的单间,如果有多个线程加了这把锁,那么同一时刻只能有一个线程能够进到这个房间,既这把锁只能被一个线程持有并使用,保证持有这把锁的线程对资源独占访问,从而避免线程安全问题。
这是锁的基本特性,无法干预
2)锁的不可抢占性(Non-preemption)
还是刚才的单间例子,在一个线程抢到到这个小房间的时候(持有这把锁),其他线程不能把这个线程强制拖出来自己占有。
这是锁的基本特性,通常无法干预
3)请求保持(Hold and Wait)
最典型的例子就是场景1和场景2了。
与代码结构有关,可干预。
在持有一把锁且不释放的情况下,又去拿锁。
4)循环等待(Circular Wait)
顾名思义,线程之间在相互等待解锁,形成一个逻辑上的死循环。
与代码结构有关,可干预。
三、避免死锁的方式
想要解决死锁问题,只需要破坏一下四个条件中的其中一个就能成功。
1.锁的互斥性(不可干预)
锁的互斥性(Mutual Exclusion)是锁的基本特性,它是保证线程资源独占访问的关键,是无法干预的。
2.锁的不可抢占性(不可干预)
锁的不可抢占性(Non-preemption)也是锁的基本特性,它保证线程在持有资源的时候不会被其他线程打断,保证数据的一致性,所以也是不可干预的。
3.请求锁时,不允许持有锁
请求保持(Hold and Wait):这个条件我们是可以干预的。
对典型的就是上面2个线程2把锁这个例子,倘若两个线程想要拿另一把锁的时候,把当前的锁释放掉,再去拿另一把锁,死锁就不会发生:
public class Threads {
static int count = 0;
//实现加锁
private synchronized static void add() {
count++;
}
public static Object object1=new Object();
public static Object object2=new Object();
public static void main(String[] args) throws InterruptedException {
Thread threadA=new Thread(()->{
synchronized (object1){
System.out.println("线程A对1上锁");
try {
Thread.sleep(1000);//休眠确保线程B对1 2都上锁,然后在执行A线程接下来的程序
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//出了object1对象的锁,再去压object2的锁,其他代码不变
synchronized(object2){
System.out.println("线程A在对2上锁");
}
});
Thread threadB=new Thread(()->{
synchronized(object2){
System.out.println("线程B对2上锁");
synchronized(object1){
System.out.println("线程B对1上锁");
}
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
运行结果:
注意:
这个解决方案不是万能的,因为有时候代码就是要写成保持请求的状态。
4.打破循环依赖
产生循环依赖,这个当然是可以干预的。想办法打破循环条件即可。
最简单高效的方式就是给锁编号,比如上文谈到的N个线程M把锁:
我们把五枝筷子(锁)进行编号1、2、3、4、5
规定,每一位各个拿筷子的时候只能先拿起编号小的筷子,后拿起编号大的筷子:
这是我们发现,循环依赖实际上已经打破了,因为因为一号哥哥不会去大号(5号)筷子,所以五号哥哥可以拿到5号筷子,等五号哥哥炫完鸡汤后,他会把4、5号筷子都放下,然后三号哥哥就可以开炫了,以此类推直到所有线程完成任务。
另外银行家算法也可以解决循环依赖的问题,但是本身这个算法就很复杂,容易出bug,所以这里就不做过多讨论了,感兴趣的同学可以自己去查阅学习。