文章目录
一、什么是死锁?
- 死锁是指两个或者两个以上的进程(线程)在执行过程中,因争夺资源而造成一种互相等待的现象。这些进程(线程)在等待彼此释放已经占用的资源,它们都无法继续执行下去。死锁的产生可能使系统进入假死状态,无法响应任何输入。
二、不可重入(Java中可重入)
- 一个线程在执行一个临界区时,需要访问一个共享资源,例如一个共享变量或函数。但是,如果在该临界区内部再次请求访问该共享资源,则可能会导致死锁。因为此时该共享资源已经被该线程占用,无法再次获得访问权,而其他线程也无法访问该资源,从而产生死锁。
如下操作,锁对象都是this,注意:修饰方法就是给当前的实例对象加锁(也就是this对象)
执行进入add方法时,这个线程给this第一次加锁,可以加锁成功,然后遇到代码块,尝试再次加锁。但是呢,在this看来,自己已经被第一个线程加过锁了,所以此时尝试加锁的线程就会阻塞等待,但其实两次加锁的线程是同一个,这就是不可重入导致死锁。
synchronized public void add() {
synchronized(this) {
count++;
}
}
如果两次加锁的线程不是同一个,那不可重入,只是让后一个加锁的线程阻塞等待,不会死锁。但是同一个线程对同一个对象加两次锁,就会死锁。为了防止这种死锁,在java中,synchronized、ReentrantLock加锁时,我们给线程开了个后门,让线程可重入。就是说:同一个线程给同一个对象加锁时,可以反复加锁。(但是在c++,python,操作系统原生的锁是不可重入的)。
那么怎么实现呢?我可以在这个锁对象上记录下来,当前的锁是属于哪个线程的,如果再次加锁的线程是记录在所对象中的线程,就直接放过去,不阻塞,(两次加锁时,锁对象是同一个)。如果不是同一个线程,那就让后加锁的线程阻塞等待。
三、死锁的3个典型情况
1、一个线程一把锁,连续加锁两次
在上面已经说过,在java中,synchronized河ReentrantLock都是可重入的,所以一个线程一把锁,连续加锁两次,在java中是不会死锁的,但是其它语言中就不一样了比如Python,C++,操作系统原生的锁都是不可重入的,都会造成死锁。
synchronized public void add() {
synchronized(this) {
count++;
}
}
2、两个线程两把锁,同时获取对方的锁
在下面代码中给了一个例子:线程一有钱,要拿去换货,而线程2有货,拿货去换钱。但是在交换时,线程1不肯先把钱给线程2,而线程二也不肯先把货交给线程一,这就让两个线程无法继续往下执行,导致死锁。
// 死锁演示:开始时,线程1拿到钱,线程2拿到货,但是交换时谁都不肯先给对方,所以就死锁了
public class Main {
public static void main(String[] args) {
Object qian = new Object();
Object huo = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (qian){
// sleep为了确保线程2先拿到货,而不是由线程1拿到钱和货
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (huo){
System.out.println("线程1拿到了钱和货");
}
}
}
});
Thread t2 = new Thread(){
@Override
public void run(){
synchronized (huo){
// sleep为了确保线程1先拿到钱,而不是由线程2先拿到货和钱
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (qian){
System.out.println("线程2拿到了货和钱");
}
}
}
};
t1.start();
t2.start();
try {
// 确保t1和t2在main之前执行
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
3、N个线程M把锁
典型例子:哲学家就餐问题
问题描述:
五个哲学家围坐在一张圆桌前,每个哲学家面前都有一碗米饭和一只筷子。哲学家只会做两件事情:思考和就餐。当哲学家想要就餐时,他必须同时拿到他左右两边的筷子。如果他只拿到了一只筷子,他就不能就餐。当哲学家就餐时,他会占用两只筷子,其他哲学家就不能就餐了。每个哲学家思考的时间是随机的,就餐的时间也是随机的。当所有哲学家都就餐完毕后,程序结束。
实现方式:
为了避免死锁问题,可以让其中一个哲学家先拿右边的筷子,而其他哲学家则先拿左边的筷子,这样就可以避免所有哲学家同时拿起左边的筷子,而导致死锁问题的发生。其实也就是给筷子编号,每个哲学家只能先拿小的筷子,如果不理解可以看以下图示:
四、死锁的4个必要条件
1、互斥
线程1拿到锁,线程2就得阻塞等待。
2、不可抢占
线程1拿到锁,线程2就得阻塞等待,等线程1释放锁后,线程2才能尝试获取锁。
3、请求和保持
线程1拿到锁 A后,再尝试获取 B,A这把锁还是保持的,不会因为要获取 B就把 A释放了。
4、循环等待
线程1有锁 A,尝试获取锁 B。线程2有锁 B,尝试获取锁 A。即线程1和线程2都在等待对方释放拥有的锁,然后尝试自己获取到。
五、如何破除死锁
1、破除情况1
在Java中,synchronized和ReentrantLock都是可重入锁,同一个线程对同一个对象加两次锁,不会死锁。所以,不需要考虑。至于其它语言,以后学到了再说。
2、破除情况2、3
方法:给对象编号,按照顺序,加锁和释放锁。
比如情况2中
,给钱编号为1,给货编号为2,按编号加锁。两个线程获取时,只能都先获取钱的锁,当前程1获取到钱的锁时,线程2只能等待线程1释放钱的锁,而线程1可以获取到货的锁了,等它都获取到锁后执行完后,把货和钱的锁都释放了。那此时线程2就可以尝试获取钱的锁,获取到后,再去尝试获取货的锁。
在代码上来看的话,两个线程都应该是外面是给qian加锁,里面是给huo加锁,如下示例。
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (qian){
// sleep为了确保线程2先拿到货,而不是由线程1拿到钱和货
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (huo){
System.out.println("线程1拿到了钱和货");
}
}
}
});
在情况3中
,我们可以给每个筷子编号,每个哲学家只能先拿编号小的筷子,那这样的话,即使是极端情况下也不会出现死锁,具体情况见下图。