在最近阿里的面试中被问到了线程死锁的问题,然而回答的并不是很好,之前一直觉着线程死锁是比较简单的问题,但在面试中却恍然失措,不知道怎样用专业术语去表达且描述的也不够完善,因此借这篇博文我也顺便复习一些操作系统基础知识及Java多线程死锁相关问题。首先,我们来了解一下死锁的定义~
死锁:多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种互相等待,若无外力作用,这些进程都将无法向前推进。
通过上面的描述我们知道了什么是死锁,那么为什么会形成死锁呢~
1)竞争资源。当系统中供多个进程共享的资源如打印机、公用队列等,其数目不足以满足诸进程的需要时会引起诸进程对资源的竞争而产生死锁。2)进程推进顺序不当。进程在运行过程中,请求和释放资源的顺序不当,也同样会产生进程死锁。
通过上述形成死锁原因,我们可以抽象出产生死锁的必要条件~
1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求该资源,则请求者只能等待资源被释放。4)循环等待条件:指在发生死锁时,必然存在一个进程-资源的环形链,即进程集合(P0,P1,P2,…,Pn)中的P0正在等待一个P1占用的资源;P1正在等待一个P2占用的资源,……,Pn正在等待已被P0占用的资源。
下面我们来看一个死锁实例:
public class DeadLock {
public static void main(String[] args) {
dead_lock();
}
private static void dead_lock() {
// 两个资源
final Object resource1 = "resource1";
final Object resource2 = "resource2";
// 第一个线程,想先占有resource1,再尝试着占有resource2
Thread t1 = new Thread() {
public void run() {
// 尝试占有resource1
synchronized (resource1) {
// 成功占有resource1
System.out.println("Thread1 1:locked resource1");
// 休眠一段时间
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试占有resource2,如果不能占有,该线程会一直等到
synchronized (resource2) {
System.out.println("Thread1 1:locked resource2");
}
}
}
};
// 第二个线程,想先占有resource2,再占有resource1
Thread t2 = new Thread() {
public void run() {
// 尝试占有resource2
synchronized (resource2) {
// 成功占有resource2
System.out.println("Thread 2 :locked resource2");
// 休眠一段时间
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试占有resource1,如果不能占有,该线程会一直等到
synchronized (resource1) {
System.out.println("Thread1 2:locked resource1");
}
}
}
};
// 启动线程
t1.start();
t2.start();
}
}
通过这个实例我想大家对死锁已经有了一定的认识,我们可以试着用产生死锁的必要条件来判断此程序是否会形成死锁,通过分析我们发现该程序完全符合产生死锁的必要条件。知道了什么样的情况下会产生死锁,接下来预防死锁及解决死锁就会简单很多了,既然死锁必须满足这四种条件,那么反之只要破坏其中任何一个条件死锁都不会再产生,这样既可避免死锁,下面我们来看一下预防死锁的方法有哪些~
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
如果一个线程如Thread3需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。如Thread2和Thread3只有在获取了锁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 (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 (57 millis) before retrying.
在上述实例中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁。需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多。
3)死锁检测:死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等)进行记录。除此之外每当有线程请求锁,也需要记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。若线程B确实有这样的请求,那么就发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。
上面介绍了怎样去预防死锁的产生,那么如果死锁已经形成我们又应该怎么办呢~
4)连续抢占资源直到不存在死锁。
对于1而言,这是最简单暴力的方式,对于2而言这需要操作系统的一些机制,不幸的是这种恢复方式可能再次面临死锁。对于3和4都需要一个标准(每次取消代价最小的进程),并且重新调用检测算法,测试是否依旧有死锁存在。对于4还要回滚到获得资源之前的某个状态。上述四种解决死锁的方法都有各自的优缺点,如果将进程分类,并为每一类进程配备一个适合的死锁处理算法,这样既能保证进程正常运行又能提升系统效率。
关于多线程死锁问题大致就介绍到了这里,借着这篇博文自己也学到了很多死锁相关知识,相信大家也对多线程死锁问题有了新的认识,多线程死锁问题不管在Java编程还是操作系统中都占据着一个特别大的分量,因此希望大家都能熟悉掌握死锁方面的相关知识,也真切地希望这篇博文能给大家提供一定的帮助哈~