多线程死锁的产生以及如何避免死锁

一、死锁的定义

多线程以及多进程改善了系统资源的利用率并提高了系统的处理能力。然而,并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

生活中,两个人一起吃饭但是只有一双筷子,二人轮流吃(同时拥有两只筷子才能吃)。某一个时候,一个拿了左筷子,一人拿了右筷子,两个人都同时占用一个资源,等待另一个资源,这个时候甲在等待乙吃完并释放它占有的筷子,同理,乙也在等待甲吃完并释放它占有的筷子,这样就陷入了一个死循环,谁也无法继续吃饭。

在计算机系统中也存在类似的情况。例如,某计算机系统中只有一台打印机和一台输入设备,进程 P1 正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程 P2 所占用,而 P2 在未释放打印机之前,又提出请求使用正被 P1 占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。

二、死锁产生的原因

1️⃣系统资源的竞争
通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。

2️⃣进程推进顺序非法
进程在运行过程中,请求和释放资源的顺序不当,同样会导致死锁。例如,并发进程 P1、P2 分别保持了资源 R1、R2,而进程 P1 申请资源 R2,进程 P2 申请资源 R1 时,两者都会因为所需资源被占用而阻塞。

信号量使用不当也会造成死锁。进程间彼此相互等待对方发来的消息,结果也会使得这些进程间无法继续向前推进。例如,进程 a 等待进程 b 发的消息,进程 b 又在等待进程 a 发的消息,可以看出进程 a 和 b 不是因为竞争同一资源,而是在等待对方的资源导致死锁。

3️⃣死锁产生的必要条件
产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

①互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

②不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。

③请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

④循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中 Pi 等待的资源被 P(i+1) 占有 (i=0, 1, …, n-1),Pn 等待的资源被 P0 占有,如图:

直观上看,循环等待条件似乎和死锁的定义一样,其实不然。按死锁定义构成等待环所要求的条件更严,它要求 Pi 等待的资源必须由 P(i+1) 来满足,而循环等待条件则无此限制。 例如,系统中有两台输出设备,P0 占有一台,PK 占有另一台,且 K 不属于集合 {0, 1, …, n}。

Pn 等待一台输出设备,它可以从 P0 获得,也可能从 PK 获得。因此,虽然 Pn、P0 和其他一些进程形成了循环等待圈,但 PK 不在圈内,若 PK 释放了输出设备,则可打破循环等待,如图所示。因此循环等待只是死锁的必要条件:

资源分配图含圈而系统又不一定有死锁的原因是同类资源数大于 1。但若系统中每类资源都只有一个资源,则资源分配图含圈就变成了系统出现死锁的充分必要条件。

三、一个简单的死锁

线程 a 和线程 b 相互等待对方持有的锁导致程序无限死循环下去,就会导致死锁。真正理解什么是死锁,几个步骤:

  1. 两个线程里面分别持有两个 Object 对象:lock1 和 lock2。这两个 lock 作为同步代码块的锁;
  2. 线程 A 的 run() 中同步代码块先获取 lock1 的对象锁,Thread.sleep(xxx),时间不需要太多,3000 毫秒差不多了,然后接着获取 lock2 的对象锁。这么做主要是为了防止线程 A 启动一下子就连续获得了 lock1 和 lock2 两个对象的对象锁;
  3. 线程 b 的 run() 中同步代码块先获取 lock2 的对象锁,接着获取 lock1 的对象锁,当然这时 lock1 的对象锁已经被线程 a 锁持有,线程 b 肯定是要等待线程 a 释放 lock1 的对象锁的。

这样,线程 a “睡觉”睡完,线程 b 已经获取了 lock2 的对象锁了,线程 a 此时尝试获取 lock2 的对象锁,便被阻塞,此时一个死锁就形成了。

例一:

public class DeadLock {
	public static String obj1 = "obj1";
	public static String obj2 = "obj2";
	public static void main(String[] args){
		Thread a = new Thread(new Lock1());
		Thread b = new Thread(new Lock2());
		a.start();
		b.start();
	}   
}
class Lock1 implements Runnable{
	@Override
	public void run(){
		try{
			System.out.println("Lock1 running");
			while(true){
				synchronized(DeadLock.obj1){
					System.out.println("Lock1 lock obj1");
					Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
					synchronized(DeadLock.obj2){
						System.out.println("Lock1 lock obj2");
					}
				}
			}
		}catch(Exception e){
			e.printStackTrace();
		}
	}
}
class Lock2 implements Runnable{
	@Override
	public void run(){
		try{
			System.out.println("Lock2 running");
			while(true){
				synchronized(DeadLock.obj2){
					System.out.println("Lock2 lock obj2");
					Thread.sleep(3000);
					synchronized(DeadLock.obj1){
						System.out.println("Lock2 lock obj1");
					}
				}
			}
		}catch(Exception e){
			e.printStackTrace();
		}
	}
}

结果:

Lock1 running
Lock1 lock obj1
Lock2 running
Lock2 lock obj2

可以看到,Lock1 获取 obj1,Lock2 获取 obj2。但是它们都没有办法再获取另外一个 obj,因为它们都在等待对方先释放锁,这时就是死锁。

如果只运行 Lock1 呢?修改 main 方法,把线程 b 注释掉。
结果:

Lock1 running
Lock1 lock obj1
Lock1 lock obj2
Lock1 lock obj1
Lock1 lock obj2
Lock1 lock obj1
Lock1 lock obj2
.
.
.

由于没有其它线程和 Lock1 争夺 obj1 和 obj2,Lock1 可以不断地循环获取并释放它们,这时没有死锁。

例二:

public class DeadLock{
	public static void main(String[] args) {
		Thread tA = new Thread(new MyThread(true));
		Thread tB = new Thread(new MyThread(false));
		tA.start();
		tB.start();
	}
}

class MyThread implements Runnable{
	boolean lockFormer;
	static Object lock1 = new Object();
	static Object lock2 = new Object();
	MyThread(boolean lockFormer){
		this.lockFormer = lockFormer;
	}
	@Override
	public void run() {
		if(this.lockFormer){
			synchronized (lock1) {
				try {
					while(true){
						System.out.println("true获取了lock1");
						Thread.sleep(3000);
						synchronized (lock2) {
							System.out.println("true获取了lock2");
						}
					}
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}else{
			synchronized (lock2) {
				try {
					while(true){
						System.out.println("false获取了lock2");
						Thread.sleep(3000);
						synchronized (lock1) {
							System.out.println("false获取了lock1");
						}
					}
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
}

结果:

true获取了lock1
false获取了lock2

注释掉 tB,结果如下:

true获取了lock1
true获取了lock2
true获取了lock1
true获取了lock2
true获取了lock1
true获取了lock2
true获取了lock1
.
.
.

四、避免死锁的三种技术

①加锁顺序(线程按照一定的顺序加锁)
②加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
③死锁检测

1️⃣加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。看下面这个例子:

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C (when A locked)

Thread 3:
   wait for A
   wait for B
   wait for C

如果一个线程(比如线程 3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。

例如,线程 2 和线程 3 只有在获取了锁 A 之后才能尝试获取锁 C (获取锁 A 是获取锁 C 的必要条件)。因为线程 1 已经拥有了锁 A,所以线程 2 和 3 需要一直等到锁 A 被释放。然后在它们尝试对 B 或 C 加锁之前,必须成功地对 A 加了锁。

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要事先知道所有可能会用到的锁(并对这些锁做适当的排序),但总有些时候是无法预知的。

2️⃣加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的例子中,线程 2 比线程 1 早 200 毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程 1 尝试获取锁 A 并且处于等待状态。当线程 2 结束时,线程 1 也可以顺利的获得这两个锁(除非线程 2 或者其它线程在线程 1 成功获得两个锁之前又获得其中的一些锁)。

需要注意的是,由于存在锁的超时,所以不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。

此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为 0 到 500 毫秒之间,这种现象可能不会发生,但是如果是 10 个或 20 个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。
(超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。)

这种机制存在一个问题,在 Java 中不能对 synchronized 同步块设置超时时间。需要创建一个自定义锁,或使用 Java5 中java.util.concurrent包下的工具。

3️⃣死锁检测

死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程 A 请求锁 7,但是锁 7 这个时候被线程 B 持有,这时线程 A 就可以检查一下线程 B 是否已经请求了线程 A 当前所持有的锁。如果线程 B 确实有这样的请求,那么就是发生了死锁(线程 A 拥有锁 1,请求锁 7;线程 B 拥有锁 7,请求锁 1)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程 A 等待线程 B,线程 B 等待线程 C,线程 C 等待线程 D,线程 D 又在等待线程 A。线程 A 为了检测死锁,它需要递进地检测所有被 B 请求的锁。从线程 B 所请求的锁开始,线程 A 找到了线程 C,然后又找到了线程 D,发现线程 D 请求的锁被线程 A 自己持有着。这是它就知道发生了死锁。

下面是一幅关于四个线程(A、B、C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。

那么当检测出死锁时,这些线程该做些什么呢?

一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(原因同超时类似,不能从根本上减轻竞争)。

一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JFS_Study

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值