[Java并发-4]解决Java死锁的问题

在上一篇中,咱们尝试使用了 Account.class做为互斥锁,来解决转帐问题。可是很容易发现这样,全部的转帐操做都是串行的,性能太差了。java

让咱们尝试提高下性能。编程

向现实世界要答案

现实世界中,转帐操做是支持并发的。性能优化

我设想下,在古代没有信息化的时候。帐户的存在就是一个个帐本,并且每一个用户都有一个帐本。这些帐本都放在架子上。银行柜员在转帐时候,是去架子上同时拿到转入帐本和转出帐本,而后作转帐都。这时候这个柜员会遇到3种状况
1,架子上恰好有 转入和转出帐本,同时拿走便可。
2,若是架子上只有转入和转出帐本之一,柜员先拿走一本,在等着另外一本被送回来。
3,转入和转出帐本都没有,柜员只好等着2个帐本被送回来。并发

上面的步骤转换成编码,其实就是2把锁实现。转入帐本一把锁,转出帐本一把锁。在 transfer() 方法内部,咱们首先尝试锁定转出帐户 this(先把转出帐本拿到手),而后尝试锁定转入帐户 target(再把转入帐本拿到手),只有当二者都成功时,才执行转帐操做。这个逻辑能够图形化为下图这个样子。app

图片描述

以下所示。通过这样的优化后,帐户 A 转帐户 B 和帐户 C 转帐户 D 这两个转帐操做就能够并行了。性能

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

相对于用 Account.class 做为互斥锁,锁定的范围太大,而咱们锁定两个帐户范围就小多了,这样的锁,上一章咱们介绍过,叫细粒度锁使用细粒度锁能够提升并行度,是性能优化的一个重要手段。优化

使用细粒度锁这么简单嘛?编写并发程序就须要这样时时刻刻保持谨慎。this

使用细粒度锁是有代价的,这个代价就是可能会致使死锁。

咱们仍是经过现实世界看一下死锁产生的缘由。若是有客户找柜员张三作个转帐业务:帐户 A 转帐户 B 100 元,此时另外一个客户找柜员李四也作个转帐业务:帐户 B 转帐户 A 100 元,因而张三和李四同时都去文件架上拿帐本,这时候有可能凑巧张三拿到了帐本 A,李四拿到了帐本 B。张三拿到帐本 A 后就等着帐本 B(帐本 B 已经被李四拿走),而李四拿到帐本 B 后就等着帐本 A(帐本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…由于张三不会把帐本 A 送回去,李四也不会把帐本 B 送回去。咱们姑且称为死等吧。编码

图片描述

现实世界里的死等,就是编程领域的死锁了。spa

死锁 一组互相竞争资源的线程因互相等待,致使“永久”阻塞的现象
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;
        }
      }
    }
  } 
}

关于这种现象,咱们还能够借助资源分配图来可视化锁的占用状况(资源分配图是个有向图,它能够描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经得到该资源,线程指向资源的边则表示线程请求资源,但还没有获得。转帐发生死锁时的资源分配图就以下图所示。

图片描述
转帐发生死锁时的资源分配图

如何预防死锁

并发程序一旦死锁,通常没有特别好的方法,不少时候咱们只能重启应用。所以,解决死锁问题最好的办法仍是规避死锁。

那如何避免死锁呢?要避免死锁就须要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有如下这四个条件都发生时才会出现死锁:

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

反过来分析,也就是说只要咱们破坏其中一个,就能够成功避免死锁的发生

其中,互斥这个条件咱们没有办法破坏,由于咱们用锁为的就是互斥。不过其余三个条件都是有办法破坏掉的,到底如何作呢?

1,对于“占用且等待”这个条件,咱们能够一次性申请全部的资源,这样就不存在等待了。
2,对于“不可抢占”这个条件,占用部分资源的线程进一步申请其余资源时,若是申请不到,能够主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
3,对于“循环等待”这个条件,能够靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候能够先申请资源序号小的,再申请资源序号大的,这样线性化后天然就不存在循环了。

咱们已经从理论上解决了如何预防死锁,下面咱们就来尝试用代码实践一下这些理论。

1. 破坏占用且等待条件

从理论上讲,要破坏这个条件,能够一次性申请全部资源。在现实世界里,就拿前面咱们提到的转帐操做来说。能够增长一个帐本管理员,而后只容许帐本管理员从文件架上拿帐本,也就是说柜员不能直接在文件架上拿帐本,必须经过帐本管理员才能拿到想要的帐本。例如,张三同时申请帐本 A 和 B,帐本管理员若是发现文件架上只有帐本 A,这个时候帐本管理员是不会把帐本 A 拿下来给张三的,只有帐本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请全部资源”。

图片描述
经过帐本管理员拿帐本图

对应到编程领域,“同时申请”这个操做是一个临界区,咱们也须要一个角色(Java 里面的类)来管理这个临界区,咱们就把这个角色定为 Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。帐户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一我的来分配资源)。当帐户 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)
    }
  } 
}

2. 破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要可以主动释放它占有的资源,这一点 synchronized 是作不到的。缘由是 synchronized 申请资源的时候,若是申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,也释放不了线程已经占有的资源。java.util.concurrent 这个包下面提供的 Lock 是能够轻松解决这个问题的。关于这个话题,我们后面会详细讲。

3. 破坏循环等待条件

破坏这个条件,须要对资源进行排序,而后按序申请资源。这个实现很是简单,咱们假设每一个帐户都有不一样的属性 id,这个 id 能够做为排序字段,申请的时候,咱们能够按照从小到大的顺序来申请。好比下面代码中,①~⑥处的代码对转出帐户(this)和转入帐户(target)排序,而后按照序号从小到大的顺序锁定帐户。这样就不存在“循环”等待了。

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

总结

当咱们在编程世界里遇到问题时,应不局限于当下,能够换个思路,向现实世界要答案,利用现实世界的模型来构思解决方案,这样每每可以让咱们的方案更容易理解,也更可以看清楚问题的本质。

用细粒度锁来锁定多个资源时,要注意死锁的问题.

预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转帐那个例子,咱们破坏占用且等待条件上咱们也是锁了全部的帐户,并且仍是用了死循环 while(!actr.apply(this, target));方法,不过好在 apply() 这个方法基本不耗时。 在转帐这个例子中,破坏循环等待条件就是成本最低的一个方案。

因此咱们在选择具体方案的时候,还须要评估一下操做成本,从中选择一个成本最低的方案

相关文章
相关标签/搜索