Java中的死锁

经典的“哲学家进餐”问题很好地描述了死锁状况。5个哲学家去吃中餐,坐在一张圆桌旁。他们有5根筷子(而不是5双),并且每两个人中间放一根筷子。哲学家们时而思考,时而进餐。每个人都需要一双筷子才能吃到东西,并在吃完后将筷子放回原处继续思考。有些筷子管理算法能够使每个人都能相对及时地吃到东西(例如一个饥饿的哲学家会尝试获得两根邻近的筷子,但如果其中一根正在被另一个哲学家使用,那么他将放弃已经得到的那根筷子,并等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。

当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]”),其中多个线程由于存在环路的锁依赖关系而永远地等待下去。(把每个线程假想为有向图中的一个节点,图中每条边表示的关系是:“线程A等待线程B所占有的资源”。如果在图中形成了一条环路,那么就存在一个死锁。)

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务(Transaction)时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死锁,但事实上这种情况并不多见。如果没有外部干涉,那么这些事务将永远等待下去(在某个事务中持有的锁可能在其他事务中也需要)。但数据库服务器不会让这种情况发生。当它检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟它竞争资源的事务都已经完成了。

JVM 在解决死锁问题方面并没有数据库服务那样强大。当一组Java 线程发生死锁时,“游戏”将到此结束——这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。

与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟糕的时候——在高负载情况下。

  锁顺序死锁

程序清单10-1 中的LeftRightDeadlock存在死锁风险。leftRight 和rightLeft 这两个方法分别获得left 锁和right锁。如果一个线程调用了leftRight,而另一个线程调用了rightLeft,并且这两个线程的操作是交错执行,如图10-1所示,那么它们会发生死锁。

在LeftRightDeadlock 中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。如果每个需要锁L和锁M的线程都以相同的顺序来获取L和M,那么就不会发生死锁了。

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。如果只是单独地分析每条获取多个锁的代码路径,那是不够的:leftRight和rightLeft都采用了“合理的”方式来获得锁,它们只是不能相互兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。

synchronized (left){public void leftRight(){

synchronized (right){

doSomething();

}

}

public void rightLeft(){

synchronized (right){

synchronized (left){

doSomethingElse();

}

}

}

}

                                                                        

   动态的锁顺序死锁

有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。考虑程序清单10-2 中看似无害的代码,它将资金从一个账户转入另一个账户。在开始转账之前,首先要获得这两个Account对象的锁,以确保通过原子方式来更新两个账户中的余额,同时又不破坏一些不变性条件,例如“账户的余额不能为负数”。

                              程序清单10-2         动态的锁顺序死锁(不要这么做)            

//注意:容易发生死锁!

synchronized (fromAccount){

synchronized (toAccount){

if (fromAccount. getBalance(). compareTo(amount)<0)

throw new InsufficientFundsException();

else {

fromAccount. debit(amount);

toAccount. credit(amount);

}

}

}

}

                                                                       

在transferMoney中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁:

A:transferMoney(myAccount,yourAccount,10);

B:transferMoney(yourAccount,myAccount,20);

如果执行时序不当,那么A 可能获得myAccount的锁并等待yourAccount的锁,然而B 此时持有yourAccount的锁,并正在等待myAccount的锁。

这种死锁可以采用程序清单10-1中的方法来检查——查看是否存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。

在制定锁的顺序时,可以使用System. identityHashCode方法,该方法将返回由Object.hashCode返回的值。程序清单10-3 给出了另一个版本的transferMoney,在该版本中使用了System. identityHashCode来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的可能性。

                           程序清单10-3通过锁顺序来避免死锁                                 

private static final Object tieLock =new Object();

public void transferMoney(final Account fromAcct,

final Account toAcct,

final DollarAmount amount)

throws InsufficientFundsException {

class Helper {

public void transfer() throws Insufficient FundsException {

if (fromAcct. getBalance(). compareTo(amount)<0)

throw new Insufficient FundsException();

else {

fromAcct. debit(amount);

toAcct. credit(amount);

}

}

}

int fromHash =System. identityHashCode(fromAcct);

int toHash =System. identityHashCode(toAcct);

if (fromHash

synchronized (fromAcct){

synchronized (toAcct){

new Helper(). transfer();

}

}

}else if (fromHash>toHash){

synchronized (toAcct){

synchronized (fromAcct){

new Helper(). transfer();

}

}

}else {

synchronized (tieLock){

synchronized (fromAcct){

synchronized (toAcct){

new Helper(). transfer();

                                                                       

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(Tie-Breaking)”锁。在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类似于在整个程序中只有一个锁的情况),但由于System. identityHashCode中出现散列冲突的频率非常低,因此这项技术以最小的代价,换来了最大的安全性。

如果在Account中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么要制定锁的顺序就更加容易了:通过键值对对象进行排序,因而不需要使用“加时赛”锁。

你或许认为我有些夸大了死锁的风险,因为锁被持有的时间通常很短暂,然而在真实系统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天可能要执行数十亿次获取锁-释放锁的操作。只要在这数十亿次操作中有一次发生了错误,就可能导致程序发生死锁,并且即使应用程序通过了压力测试也不可能找出所有潜在的死锁。在程序清单10-4目中的DemonstrateDeadlock在大多数系统下都会很快发生死锁。

                              程序清单10-4在典型条件下会发生死锁的循环                              

public class DemonstrateDeadlock {

private static final int NUM_THREADS=20;

private static final int NUM_ACCOUNTS=5;

private static final int NUM_ITERATIONS=1000000;

public static void main(String[]args){

final Random rnd =new Random();

final Account{} accounts= new Account{NUM_ACCOUNTS];

for  (int  i =0;i  < accounts. length;i++)

accounts[i]=new Account();

class TransferThread extends Thread {

public void run(){

                  

具有讽刺意味的是,之所以短时间地持有锁,是为了降低锁的竞争程度,但却增加了在测试中找出潜在死锁风险的难度。

②   为了简便,DemonstrateDeadlock 没有考虑账户余额为负数的问题。

for( int i=0;i

int fromAcct= rnd. next Int(NUM_ACCOUNTS);

int toAcct= rnd.nextInt(NUM_ACCOUNTS);

DollarAmount amount -

new DollarAmount(rnd. nextInt(1000));

transferMoney(accounts[fromAcct],

accounts[toAcct], amount);

}

}

}

for( int i=0;i

new TransferThread(). start();

}

}

                                                                     

 在协作对象之间发生的死锁

某些获取多个锁的操作并不像在LeftRightDeadlock或transferMoney中那么明显,这两个锁并不一定必须在同一个方法中被获取。考虑程序清单10-5中两个相互协作的类,在出租车调度系统中可能会用到它们。Taxi代表一个出租车对象,包含位置和目的地两个属性, Dispatcher 代表一个出租车车队。

   程序清单10-5在相互协作对象之间的锁顺序死锁(不要这么做)                                 

//注意:容易发生死锁!


public Taxi(Dispatcher dispatcher){

this. dispatcher =dispatcher;

}

public synchronized Point getLocation(){

return location;

}

public synchronized void setLocation(Point location){

this. location =location;

if (location. equals(destination))

dispatcher. notifyAvailable(this);

}

}

class Dispatcher {

@GuardedBy("this") private final Settaxis;

@GuardedBy("this") private final SetavailableTaxis;

public Dispatcher(){

taxis =new HashSet();

availableTaxis=new HashSet();

}

public synchronized void notifyAvailable(Taxi taxi){

public synchronized Image getImage(){

Image image =new Image();

for (Taxi t :taxis)

image. drawMarker(t. getLocation());

在LeftRightDeadlock 或transferMoney中,要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法。然而要在Taxi 和Dispatcher 中查找死锁则比较困难:如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。}

                                                                       

尽管没有任何方法会显式地获取两个锁,但setLocation和getImage等方法的调用者都会获得两个锁。如果一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。如果已经到达,它会通知Dispatcher:它需要一个新的目的地。因为setLocation和notifyAvailable 都是同步方法,因此调用setLocation 的线程将首先获取Taxi的锁,然后获取Dispatcher 的锁。同样,调用getImage的线程将首先获取Dispatcher锁,然后再获取每一个Taxi 的锁(每次获取一个)。这与LeftRightDeadlock中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

  开放调用

当然, Taxi 和Dispatcher 并不知道它们将要陷入死锁,况且它们本来就不应该知道。方法调用相当于一种抽象屏障,因而你无须了解在被调用方法中所执行的操作。但也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)[CPJ 2.4.1.3]。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。

可以很容易地将程序清单10-5中的Taxi 和Dispatcher 修改为使用开放调用,从而消除发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如程序清单10-6所示。通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块),那么就会导致程序清单10-5中的问题。(此外,收缩同步代码块的保护范围还可以提高可伸缩性,在11.4.1节中给出了如何确定同步代码块大小的方法。)

            程序清单10-6      通过公开调用来避免在相互协作的对象之间产生死锁                     

@ThreadSafe

class Taxi {

@GuardedBy("this") private Point location, destination;

private final Dispatcher dispatcher;

...

public synchronized Point getLocation(){

return location;

}

public void setLocation(Point location){

boolean reachedDestination;

synchronized (this){

this. location =location;

reachedDestination =location. equals(destination);

}

if (reachedDestination)

dispatcher. notifyAvailable(this);

}

}

@ThreadSafe

class Dispatcher {

@GuardedBy("this") private final Settaxis;

@GuardedBy("this") private final SetavailableTaxis;

...

public synchronized void notifyAvailable(Taxi taxi){

availableTaxis. add(taxi);

}

public Image getImage(){

Setcopy;

synchronized (this){

copy=new HashSet(taxis);

}

Image image =new Image();

for (Taxi t :copy)

image. drawMarker(t. getLocation());

return image;

}

}

                                                                     

                  

这些对开放调用以及锁顺序的依赖,反映了在构造同步对象(而不是对已构造好的对象进行同步)过程中存在的复杂性。

在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调度程序这辆出租车已准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。在其他情况中,虽然去掉原子性可能会出现一些值得注意的结果,但这种语义变化仍然是可以接受的。在容易产生死锁的版本中,getImage会生成某个时刻下的整个车队位置的完整快照,而在重新改写的版本中,getImage将获得每辆出租车不同时刻的位置。

然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。例如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。这个问题的解决方法是,在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始新操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态。因此,这项技术依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。

  资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现(请参见5.5.3节)当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库D₁的连接,并等待与数据库D₂的连接,而线程B则持有与D₂的连接并等待与D₁的连接。(资源池越大,出现这种情况的可能性就越小。如果每个资源池都有N个连接,那么在发生死锁时不仅需要N个循环等待的线程,而且还需要大量不恰当的执行时序。)

另一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)。8.1.1节给出.了这种危害的一个示例:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor 中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往

往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。

你可能感兴趣的:(java,算法,开发语言)