死锁了怎么办?

互斥锁,解决原子性问题这篇文章中讲到,互斥锁可以将代码串行化,避免线程切换导致的并发问题。当一把锁想要锁住多个资源的时候,要使用更大力度的锁,在互斥锁,解决原子性问题转账的例子中使用了类锁 Account.class 来锁住转账双方的账号。

这样一来,A 转账给 B,B 转账给 C,这样两个操作都变成了串行化的。加入每秒有 1 万笔交易,而每个转账操作的操作都是串行的,性能完全不可接受。

细粒度锁

在转账操作中,我们使用了类锁Accont.class作为互斥锁,而Accont.class是所有 Account 对象共用的,这就导致转账操作完全串行化,无法并发。

这里的问题就是锁的范围太大,其实我们不需要锁住所有账户,只需要锁定两个转账账户就就可以了。我们可以使用细粒度锁,使用细粒度锁可以提高并行度,是性能优化的一个重要手段。代码如下所示:

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {              
      // 锁定转入账户
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

我们给转出账户加一把锁,给转入账户也加一把锁。转账时,先尝试锁定转出账户 this,锁定成功之后再尝试锁定转入账户 target,只有当两个账户都被锁定时才会执行转账操作。这样一来,我们即保证了转账操作不会出现金额问题,也能让多个账户之间的转账操作并发执行。

死锁问题

死锁指的是,一组互相竞争资源的线程因互相等待,导致「永久」阻塞的现象

以上面的 transfer() 方法为例,线程 T1 执行 A 向 B 转账的操作,线程 T2 执行 B 向 A 转账的操作。如果线程 T1 和 T2 同时执行 transfer() 会导致这样一个线程:

  • 线程 T1 持有账户 A,需要请求账户 B
  • 线程 T2 持有账户 B,需要请求账户 A

此时就造成了死锁的局面:线程 T1 和 T2 互相持有对方需要的资源,同时也都需要对方所持有的资源。

什么情况下会产生死锁呢?有个叫 Coffman 的牛人早就总结过了,只有以下这 4 个条件都发生时才会出现死锁:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

如何避免死锁

只有在互斥、占有且等待、不可抢占、循环等待这 4 个条件都满足的条件下才会产生死锁,所以我们只要破坏其中一个条件就可以避免死锁

描述 解决方案
互斥 共享资源 X 和 Y 只能被一个线程占用; \
占有且等待 线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X; 一次申请所有资源
不可抢占 其他线程不能强行抢占线程 T1 占有的资源; 主动释放线程占有的资源
循环等待 T1 等待 T2 占有的资源,T2 等待T1 占有的资源 按序申请资源:先申请资源需要小的,再申请资源需要大的

破坏占有且等待条件

一次申请所有资源,就可以破坏占有且等待条件。也就是不允许账户 A 自己去加锁和解锁,也不允许账户 B 自己去加锁和解锁,而是由一个管理员管理所有账户的锁。管理员则保证一次申请所有资源,即同时拿到账户 A 和账户 B 的锁。

对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java 里面的类)来管理这个临界区,我们就把这个角色定为 Allocator。它有两个重要功能,分别是:

  1. 同时申请资源 apply() 和同时释放资源 free()。账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。
  2. 当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

具体的代码实现如下:


class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

破坏不可抢占条件

破坏不可抢占条件的方式是,主动释放线程占有的资源,即线程 T1 持有资源的情况下请求其他资源,如果无法获得请求的资源,则主动释放自己的占有的资源。

synchronized主动做到主动释放资源,因为synchronized申请资源的时候,如果申请不到就会直接进入阻塞状态。进入阻塞状态的线程什么也不会做,也就不会主动释放资源。

但是我们可以使用 java.util.concurrent 包下的 Lock 来解决这个问题。

破坏循环等待条件

破坏循环等待条件,可以采用对资源进行排序的方式。以转账操作为例,我们可以给每个账户分配一个id。每次转账都保证先对 id 更小的账户先进行加锁的操作。


class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 锁定序号小的账户
    synchronized(left){
      // 锁定序号大的账户
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

相关文章

05 | 一不小心就死锁了,怎么办?


深页
105 声望3 粉丝