避免同步死锁

翻译自  避免同步死锁

在我之前的文章“ Double-Checked Locking:Clever,but Broken ”(JavaWorld,2001年2月),我描述了几种常用的避免同步的技术实际上是不安全的,并建议了一个“如有疑问,同步”的策略。一般来说,只要您正在读取以前可能由另一个线程写入的变量,或者您正在编写可能随后被另一个线程读取的变量,就应该进行同步。此外,尽管同步带来了性能损失,但与无争用同步相关的惩罚并不如某些消息来源所暗示的那样严重,并且随着每个连续的JVM实现而不断降低。所以现在似乎没有什么理由避免同步了。然而,另一个风险与过度同步相关:死锁。

什么是僵局?

我们说,当每个线程正在等待只有集合中的另一个进程可能导致的事件时,一组进程或线程才会死锁另一种解释死锁的方法是构建一个有向图,其顶点是线程或进程,其边缘表示“正在等待”关系。如果此图形包含一个循环,则系统处于死锁状态。除非系统设计为从死锁中恢复,否则死锁会导致程序或系统挂起。

Java程序中的同步死锁

在Java中会发生死锁,因为synchronized关键字会导致执行线程在等待与指定对象关联的锁定或监视器时阻塞。由于线程可能已经拥有与其他对象关联的锁,因此两个线程都可以等待另一个线程释放锁; 在这种情况下,他们最终会永远等待。以下示例显示了一组可能发生死锁的方法。两种方法都获得两个锁对象的锁,cacheLock并且tableLock在它们继续之前。在这个例子中,充当锁的对象是全局(静态)变量,这是通过在较粗粒度级别执行锁定来简化应用程序锁定行为的常用技术:

清单1.潜在的同步死锁

 public static Object cacheLock = new Object();
  public static Object tableLock = new Object();
  ...
  public void oneMethod() {
    synchronized (cacheLock) {
      synchronized (tableLock) { 
        doSomething();
      }
    }
  }
  public void anotherMethod() {
    synchronized (tableLock) {
      synchronized (cacheLock) { 
        doSomethingElse();
      }
    }
  }

现在,想象线程A oneMethod()在线程B同时调用时调用anotherMethod()想象一下,线程A获取锁定cacheLock,同时线程B获取锁定tableLock现在线程被锁死了:线程将不会放弃它的锁,直到它获得另一个锁,但是在另一个线程放弃它之前,它们都不能获取其他锁。当一个Java程序死锁时,死锁线程会永远等待。虽然其他线程可能会继续运行,但最终必须杀死程序并重新启动它,并希望它不会再次发生死锁。

测试死锁是困难的,因为死锁取决于时间,负载和环境,因此可能不经常发生或仅在某些情况下发生。代码可能具有死锁的可能性,如清单1所示,但是在发生随机事件和非随机事件的某些组合之前不会出现死锁,例如程序受到特定的加载级别,在某个硬件配置上运行或暴露于某个特定的用户操作和环境条件的混合。僵局类似于在我们的代码中等待爆炸的时间炸弹; 当他们这样做时,我们的程序就会挂起。

不一致的锁定排序会导致死锁

幸运的是,我们可以对锁定采集施加相对简单的要求,以防止同步死锁。清单1的方法有可能发生死锁,因为每个方法都以不同的顺序获取两个锁如果清单1已经被写入,以便每个方法以相同的顺序获取两个锁,那么执行这些方法的两个或更多个线程不会死锁,无论时序或其他外部因素如何,因为如果没有线程可以获取第二个锁,第一。如果你能保证始终以一致的顺序获取锁,那么你的程序不会死锁。

僵局并不总是那么明显

一旦适应锁定顺序的重要性,您可以轻松识别清单1的问题。但是,类似的问题可能不太明显:也许这两种方法驻留在不同的类中,或者可能通过调用synchronized方法隐式获取涉及的锁,而不是通过同步块显式获取。考虑这两个合作类,Model并且View在一个简化的MVC(模型 - 视图 - 控制器)框架中:

清单2.更微妙的潜在同步死锁

 public class Model { 
    private View myView;
    public synchronized void updateModel(Object someArg) { 
      doSomething(someArg);
      myView.somethingChanged();
    }
    public synchronized Object getSomething() { 
      return someMethod();
    }
  }
  public class View { 
    private Model underlyingModel;
    public synchronized void somethingChanged() { 
      doSomething();      
    }
    public synchronized void updateView() { 
      Object o = myModel.getSomething();
    }
  }

清单2有两个具有同步方法的合作对象; 每个对象调用其他的同步方法。这种情况类似于清单1 - 两种方法获取相同两个对象的锁定,但以不同的顺序。然而,这个例子中的不一致的锁定顺序比清单1中的要少得多,因为锁定获取是方法调用的一个隐含部分。如果一个线程Model.updateModel()在另一个线程同时调用View.updateView()同时调用,则第一个线程可以获得Model锁并等待该View锁,而另一个线程获得该View锁并永久等待该Model锁。

您可以更深入地埋葬同步僵局的可能性。考虑一下这个例子:你有一种将资金从一个账户转移到另一个账户的方法。您希望在执行传输之前在两个帐户上获取锁定,以确保传输是原子性的。考虑这个无害的实现:

清单3.一个更微妙的潜在同步死锁

  public void transferMoney(Account fromAccount, 
                            Account toAccount, 
                            DollarAmount amountToTransfer) { 
    synchronized (fromAccount) {
      synchronized (toAccount) { 
        if (fromAccount.hasSufficientBalance(amountToTransfer) { 
          fromAccount.debit(amountToTransfer); 
          toAccount.credit(amountToTransfer);
        }
      }
    }
  }

即使所有在两个或更多帐户上运行的方法都使用相同的顺序,清单3也包含与清单1和清单2相同的死锁问题的种子,但以更微妙的方式。考虑线程A执行时会发生什么:

  transferMoney(accountOne, accountTwo, amount);

而在同一时间,线程B执行:

  transferMoney accountTwo accountOne anotherAmount );

同样,这两个线程试图获得相同的两个锁,但顺序不同; 僵局风险仍然笼罩,但形式不太明显。

如何避免死锁

防止发生死锁的最佳方法之一是避免一次获取多个锁,这通常是实用的。但是,如果这不可行,您需要一种策略,确保您以一致的,定义的顺序获取多个锁。

根据您的程序使用锁定的方式,确保您使用一致的锁定顺序可能并不复杂。在一些程序中(例如清单1),可能参与多重锁定的所有关键锁都是从一小组单身锁对象中抽取的。在这种情况下,您可以在一组锁上定义锁定获取顺序,并确保您始终按顺序获取锁定。一旦定义了锁定顺序,它只需要记录良好,以鼓励整个程序的一致使用。

收缩同步块以避免多重锁定

在清单2中,问题变得更加复杂,因为通过调用同步方法的结果,隐式获取锁。通常可以通过将同步范围缩小为尽可能小的块来避免类似清单2的情况所导致的潜在死锁。是否Model.updateModel()真的需要按住Model锁,而它调用View.somethingChanged()它通常不会; 整个方法可能被同步为捷径,而不是因为整个方法需要同步。但是,如果在方法内用同步块较小的方法替换同步方法,则必须将此锁定行为记录为方法的Javadoc的一部分。呼叫者需要知道他们可以安全地调用该方法而无需外部同步。调用者还应该知道该方法的锁定行为,以便他们可以确保以一致的顺序获取锁定。

更复杂的锁定排序技术

在其他情况下,如清单3的银行帐户示例,应用固定订单规则的情况会变得更加复杂; 您需要定义适合锁定的对象集合的总排序,并使用此排序选择锁定采集序列。这听起来很混乱,但实际上很简单。清单4说明了该技术; 它使用一个数字帐号来诱导Account对象的排序(如果您需要锁定的对象缺少像帐号这样的自然身份属性,则可以使用该Object.identityHashCode()方法生成一个。)

清单4.使用一个顺序来以固定顺序获取锁

 public void transferMoney(Account fromAccount, 
                            Account toAccount, 
                            DollarAmount amountToTransfer) { 
    Account firstLock, secondLock;
    if (fromAccount.accountNumber() == toAccount.accountNumber())
      throw new Exception("Cannot transfer from account to itself");
    else if (fromAccount.accountNumber() < toAccount.accountNumber()) {
      firstLock = fromAccount;
      secondLock = toAccount;
    }
    else {
      firstLock = toAccount;
      secondLock = fromAccount;
    }
    synchronized (firstLock) {
      synchronized (secondLock) { 
        if (fromAccount.hasSufficientBalance(amountToTransfer) { 
          fromAccount.debit(amountToTransfer); 
          toAccount.credit(amountToTransfer);
        }
      }
    }
  }

现在在呼叫中指定账户的顺序transferMoney()无关紧要; 锁总是以相同的顺序获取。

最重要的部分:文档

任何锁定策略中的一个关键 - 但经常被忽略的因素是文档。不幸的是,即使在设计锁定策略时非常小心的情况下,通常花费更少的努力来记录它。如果您的程序使用一小组单身锁,则应尽可能清楚地记录您的锁定顺序假设,以便将来的维护人员可以满足锁定顺序要求。如果一个方法必须获得一个锁来执行它的功能,或者必须使用特定的锁来调用,那么该方法的Javadoc应该注意到这一点。这样,未来的开发人员就会知道,调用给定的方法可能需要获得锁定。

很少有程序或类库充分记录其锁定使用情况。至少,每种方法都应该记录它获取的锁,以及呼叫者是否必须持有锁才能安全地调用该方法。另外,类应记录它们是否或在什么条件下它们是线程安全的。

在设计时注重锁定行为

由于死锁通常不明显,不经常发生且不可预知,所以它们可能会在Java程序中造成严重问题。通过在设计时注意程序的锁定行为并定义何时以及如何获取多个锁的规则,可以显着降低死锁的可能性。请记住记录您的程序的锁定采集规则及其同步的使用; 花在记录简单锁定假设上的时间将会大大减少以后出现死锁和其他并发问题的可能性。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值