并发编程实战(二) --- 如何避免死锁

死锁了怎么办?

前面说使用Account.class作为转账的互斥锁,这种情况下所有的操作都串行化,性能太差,这个时候需要提升性能,肯定不能使用这种方案.

现实化转账问题

假设某个账户的所有操作都在账本中,那转账操作需要两个账户,这个时候有三种情况:

  1. 两个账户的账本都存在,这个时候一起拿走
  2. 两个账户的账本只存在其一,先拿一个,等待其他人把剩余一本送过来
  3. 两个账户的账本都没有,等待其他人把两个账本都送回来

上面的逻辑其实就是使用两把锁实现,图形化:
transfer

代码实现如下:

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;
        }
      }
    }
  } 
}

这个其实就是细粒度锁. 细粒度锁提高并行度,是性能优化的一个重要手段.,但是天下没有免费的午餐,这种细粒度锁可能会导致死锁的情况发生.也就是假如现在A转账100给B,由张三做这个转账业务;B转账给A100元,由李四完成这个转账业务,这个时候张三拿到A的账户本,同一时刻李四拿到B的账户本,这个时候张三等待李四的B账户本,李四等待张三的A账户本,两人都不会送回来,就产生的死等,死等就是变成领域的死锁.死锁是指一组互相竞争资源的线程因互相等待,导致永久阻塞的现象

预防死锁

死锁一旦产生是没有办法解决的,只能重启应用. 所以解决死锁的最好办法就是避免死锁,如何避免死锁,那就要从产生死锁的条件入手:

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

四个添加同时满足就会产生死锁,只要能破坏掉有一个条件,死锁就不会产生.共享资源是没有办法破坏,也就是互斥是没有办法解决,锁的目的就是为了互斥.

  1. 占有且等待: 一次性申请所有的资源就可以解决
  2. 不可抢占: 占用部分资源后获取不到后续资源就释放掉前面获取的资源,就可以解决
  3. 循环等待: 按照序号申请资源来预防,也就是说给每个资源标记一个序号,没次加锁的时候都先获取资源序号小的,这样有顺序就不会出现循环等待
破坏占用且等待

只需要同时申请资源就可以,同时申请这个操作是一个临界区,需要一个Java类来管理这个临界区,也就是定义一个角色,这个角色的两个重要功能就是同时申请资源apply()和同时释放资源free(),并且这个类是单例的.其实本质就是设置一个管理员,只有管理员有权限去分配资源,其他普通用户只能去管理员那取资源,一个人操作就不会产生死锁了.

代码实现如下:

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)
    }
  } 
}

破坏不可抢占条件

这个的核心是释放掉已占有的资源,这个synchronized是做不到,因为synchronized申请资源的时候如果申请不到就直接进入阻塞,阻塞状态啥也干不了.

这个时候就需要java.util.concurrent包下提供的Lock,这个等学到的时候再总结.

破坏循环等待条件

这个就需要一个id值了,保护加锁的顺序是从序号小的资源开始.

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

保证课加锁的顺序,就不会出现循环等待了.

编程世界其实是和现实世界有所关联的,编程不就是为了解决现实生活中的问题吗? 上面的解决死锁的两个方案,那个更好呢? 其实破坏循环等待条件的成本要比破坏占有且等待的成本要低,后者也锁定了所有账户并且使用了死循环.相对来说,前者的成本低,但是不是绝对的,只是转账的这个例子中,破坏循环等待的成本比较低.

posted @ 2019-03-10 09:26  庄子游世  阅读(1229)  评论(0编辑  收藏  举报