前言
在上一篇中,我们介绍了线程通信的一些原理。包括使用共享对象进行通信、忙等待、Object的wait()
/notify()
/notifyAll()
方法、自旋锁、管程对象的注意事项等等。我们需要重点记忆的事情是,wait()
/notify()
/notifyAll()
这三个方法必须在同步代码块中调用,否则会抛出IllegalMonitorStateException
异常,此外,不能将字符串常量或者全局变量设置为管程对象。
1. 死锁
1.1 死锁
死锁是指两个或者是更多的线程阻塞着等待其它处于死锁状态的线程的锁,死锁通常发生在多个线程同时但并不是同一顺序请求同一组锁。
例如,线程1锁住了对象A,然后尝试给对象B加锁,但此时线程2锁住了对象B,并尝试获得对象A,这时死锁就发生了,这个例子中,两个线程永远无法成功给对象A、B加锁,并且它们也并不会知道这种情况。
我们举一个TreeNode的实例:
public class ConcurrentTreeNode {
public ConcurrentTreeNode parent=null;
List children=new ArrayList();
public synchronized void addChild(ConcurrentTreeNode child){
if (this.children.contains(child)){
this.children.add(child);
child.setParentOnly(this);
}
}
public synchronized void addChildOnly(ConcurrentTreeNode child){
if (this.children.contains(child)){
this.children.add(child);
}
}
public synchronized void setParent(ConcurrentTreeNode parent){
this.parent=parent;
parent.addChildOnly(this);
}
public synchronized void setParentOnly(ConcurrentTreeNode parent) {
this.parent=parent;
}
}
在这个例子中,如果线程1调用了parent.addChild(child)的同时有另一个线程2调用了child.setParent(parent),这里的两个线程中的parent是同一个对象,child也是同一对象,这时就会发生死锁。以下是这个过程的伪码描述:
Thread 1: parent.addChild(child); //locks parent
--> child.setParentOnly(parent);
Thread 2: child.setParent(parent); //locks child
--> parent.addChildOnly()
需要记住的是,上述过程除非是在两个线程同时调用方法时才会发生死锁,所以,程序应该在运行一段时间之后才会出现死锁,但死锁并不能预料。死锁也并非只在两个线程之间发生,多个线程之间发生相互之间等待的情况,会造成更为复杂的死锁。
我们总结一下死锁发生的四个必要条件:
- 1.互斥条件:指线程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个线程占用。如果此时还有其它线程请求资源,则请求者只能等待,直至占有资源的线程用毕释放;
- 2.请求和保持条件:指线程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它线程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放;
- 3.不剥夺条件:指线程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放;
- 4.环路等待条件:指在发生死锁时,必然存在一个线程——资源的环形链,即线程集合{T0,T1,T2,···,Tn}中的T0正在等待一个T1占用的资源;T1正在等待T2占用的资源,……,Tn正在等待已被T0占用的资源。
1.2数据库中的死锁
复杂的死锁场景发生在数据库事务中。一个数据库事务可能由多个SQL请求组成,在一个事务中更新一条记录,这条记录就会被锁住避免其他事务的更新请求,直到第一个事务结束。同一个事务中每一个更新请求都可能会锁住一些记录。
当多个事务同时需要对一些相同的记录做更新操作时,就很有可能发生死锁。
2.避免死锁
在有些情况下死锁是可以避免的。本文将展示三种用于避免死锁的技术:1. 加锁顺序;2. 加锁时限;3. 死锁检测
2.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.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.
需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。
超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。
这种机制存在一个问题,在 Java 中不能对 synchronized 同步块设置超时时间。你需要创建一个自定义锁,或使用 Java5 中 java.util.concurrent 包下的工具。
2.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 自己持有着。这是它就知道发生了死锁。
接下来的问题是,我们发现了死锁,但是我们要怎么样解决这个问题:
一种可行的方案是释放掉所有锁,回退,并且等待一段时间后重试,这个和简单的加锁时限是类似的,区别在于这里只在死锁的情况下进行回退,但如果请求同一批锁的线程太多,还是会发生死锁,因为这并不从根本上解决资源的竞争;
另一种更好的方法是,为线程设置优先级,让某些线程回退,另一些线程则保持它们的锁,如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。