死锁 当多个线程同时需要同一个锁,但是以不同的方式获取它们。
例如,如果线程1持有锁A,然后请求锁B,线程2已经持有锁B,然后请求锁A,这样一个死锁就发生了。线程1永远也得不到锁B,线程2永远也得不到锁A。它们永远也不知道这种情况。
public class TreeNode {
TreeNode parent = null;
List children = new ArrayList();
public synchronized void addChild(TreeNode child){
if(!this.children.contains(child)) {
this.children.add(child);
child.setParentOnly(this);
}
}
public synchronized void addChildOnly(TreeNode child){
if(!this.children.contains(child){
this.children.add(child);
}
}
public synchronized void setParent(TreeNode parent){
this.parent = parent;
parent.addChildOnly(this);
}
public synchronized void setParentOnly(TreeNode parent){
this.parent = parent;
}
}
如果一个线程(1)调用parent.addChild(child)的同时其他线程(2)在同一个parent和child实例上调用child.setParent(parent)方法,就会发生死锁。 下面是说明这个问题的一些伪代码:
Thread 1: parent.addChild(child); //locks parent --> child.setParentOnly(parent); Thread 2: child.setParent(parent); //locks child --> parent.addChildOnly()
首先,线程1调用parent.addChild(child),因为addChild()是同步的,所以线程1会锁住parent对象,防止其他线程获得。
然后,线程2调用child.setParent(parent),因为setParent()是同步的,所有线程2会锁住child对象,防止其他线程获得。
现在,parent和child对象被这两个不同的线程锁住。接下来,线程1尝试调用child.setParentOnly()方法,但是child对象被线程2锁住了,因此这个调用就会阻塞在那。线程2也尝试调用parent.addChildOnly()方法,但是parent对象被线程1锁住了。线程2也会阻塞在这个方法的调用上。现在两个线程都在等待获取被其他线程持有的锁。
线程确实需要同时获得锁。例如,如果线程1早线程2一点点,获得了锁A和B,然后,线程2就会在尝试获取锁B时,阻塞在那。这样就不会有死锁发生。由于,线程调度是不确定的,所以,我们无法准确预测什么时候会发生死锁。
更复杂的死锁
Thread 1 locks A, waits for B Thread 2 locks B, waits for C Thread 3 locks C, waits for D Thread 4 locks D, waits for A
线程1等待线程2,线程2等待线程3,线程3等待线程4,线程4等待线程1.
常见的数据库死锁
一个更复杂的死锁发生场景,就是数据库事务。一个数据库可能包含许多SQL更新请求。在一个事务中,要更新一条记录,但这条记录被来自其它事务的更新请求锁住了,知道第一个事务完成。在数据库中,同一个事务内的每条更新请求可能都会锁住一些记录。
如果多个事务同时运行,并且更新相同的记录。这就会有发生死锁的风险。
例如:
Transaction 1, request 1, locks record 1 for update Transaction 2, request 1, locks record 2 for update Transaction 1, request 2, tries to lock record 2 for update. Transaction 2, request 2, tries to lock record 1 for update.
一个事务事先并不知道所有的它将要锁住的记录,所有在数据库中检测和预防死锁变得更加困难。
重入锁死
重入锁死是一种类似于死锁和嵌套管程失败的情景。
如果一个线程重入获得了一个非重入的锁,读写锁或者一些其他的同步器就会发生重入锁死。重入意味着一个线程已经持有了一个锁可以再次持有它。Java的同步块是可以冲入的。因此,下面这段代码执行将不会出现问题。
public class Reentrant{
public synchronized outer(){
inner();
}
public synchronized inner(){
//do something
}
}
outer和inner方法都被声明为synchronized,这等同于一个synchronized(this)块。如果一个线程在outer()方法里面调用inner()方法将不会出现问题,因为这两个方法都被同步在同一个管程对象"this"上。如果一个线程已经持有了一个管程对象上的锁,它就可以访问同一个管程对象上所有的同步块。这被称作可重入。
下面Lock的实现是不可重入的:
public class Lock{
private boolean isLocked = false;
public synchronized void lock()throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
如果一个线程两次调用lock()方法而在两次调用之间没有调用unlock(),第二次调用lock()将会阻塞。一个重入锁死就发生了。
要避免重入锁死你有两种选择:
-
编写代码避免获取已经持有的锁
-
使用可重入锁
使用哪种方法更适合于你的程序取决于具体的情景。可重入锁的性能常常不如非重入锁,而且更难实现,可重入锁通常没有不可重入锁那么好的表现,而且实现起来复杂,但这些情况在你的项目中也许算不上什么问题。无论你的项目用锁来实现方便还是不用锁方便,可重入特性都需要根据具体问题具体分析。
如何预防死锁 呢?
锁排序 当多个线程获取同一个锁但是以不同的顺序,就会发生死锁。
如果你确保所有的锁一直以相同的顺序被其他线程获取,死锁就不会发生。看下面这例子:
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持有锁A,线程2或者线程3首先必须等待直到锁A被释放。然后,在它们能够获得锁B或者C之前,必须成功获得锁A。
锁排序是一个简单但很有效的预防死锁的机制。但是,它仅适用于你事先知道所有的锁的情况下。它并不适用于所有的场景。
锁超时
另一个预防死锁的机制是在请求锁时设置超时时长,也就说一个线程在设置的超时时长内如果没有获得锁就会放弃。如果一个线程在给定时长内没有成功获取所有必要的锁,它将会回退,释放所有的锁请求,随机等待一段时间,然后重试。随机等待的过程中给了其他线程获取这个锁的一个机会,因此,这也可以让程序在没有锁的情况下继续运行。
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将会在线程之前大约200毫秒重试去获得锁,所以,大体上将会获得所有的锁。已经在等待的线程A一直在尝试获取锁A。当线程2完成时,线程1也将会获得所有的锁。
我们需要记住一个问题,上面提到的仅仅是因为一个锁超时了,而不是说线程发生;了死锁,这也仅仅是说这个线程获取这个锁花费了多少时间去完成任务。
另外,如果线程足够多,尽管设置了超时和重试,也是会有发生死锁的风险。2个线程各自在重试前等待0~500毫秒也许不会发生死锁,但如果10或者20个线程情况就不同了。这种情况发生死锁的概率要比两个线程的情况要高得多。
锁超时机制存在的一个问题是在Java中在进入一个同步代码块时设置时长是不可能的。你不得不创建一个自定义的锁相关的类或者使用在Java5中java.util.concurrency包中的并发结构之一。
死锁检测
死锁检测是一个重量级的死锁预防机制,主要用于在锁排序和锁超时都不可用的场景中。
当一个线程请求一个锁当时请求被禁止时,这个线程可以遍历锁图(lock graph)检查是否发生了死锁。例如,如果一个线程A请求锁7,但是锁7被线程B持有,然后,线程A可以检测线程B是否有请求任何线程A持有的锁。如果有,就会发生一个死锁。
当然,一个死锁场景可能比两个对象分别持有对方的锁要复杂的锁。线程A可能等待线程B,线程B等待线程C,线程C等待线程D,线程D等待线程A。为了检测死锁,线程A必须一次测试所有的被线程B请求的锁。从线程B的锁请求线程A到达线程C,然后又到达线程D,从上面的检测中,线程A找到线程A自身持有的一个锁。这样,线程A就会知道发生了死锁。
下面是一个被四个线程持有和请求锁的图。类似于这样的一个数据结构可以用来检测死锁。
那么,如果检测到一个死锁,这些线程可以做些什么?
一个可能的做法就是释放所有的锁,回退,随机等待一段时间然后重试。这种做法与锁超时机制非常相似除了只有发生死锁时线程才会回退(backup)。而不仅仅是因为锁请求超时。然而,如果大量的线程去请求同一个锁,可能重复的发生死锁,尽管存在回退和等待机制。
一个更好的做法就是为这些线程设置优先级,这样一来,就会只有一个或者一些线程在遇到死锁时发生回退。剩下的线程继续请求锁假如没有死锁再发生。如果赋予线程的优先级是固定的,同样的线程总是拥有更高的优先级。为了避免这种情况,我们可以在发生死锁时,随机的为线程设置优先级。