程序员

并发编程-死锁

2023-02-12  本文已影响0人  我可能是个假开发

一、细粒度锁

现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。

在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:

用两把锁就实现上述过程,转出账本一把,转入账本另一把。
在 transfer() 方法内部:

这个逻辑可以图形化为下图:


两个转账操作并行.png

账户 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 作为互斥锁,锁定的范围太大,而锁定两个账户范围就小多了,这样的锁叫细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
但是,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。

转账业务中的死等.png
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;
        }
      }
    }
  } 
}

三、预防死锁

只有以下这四个条件都发生时才会出现死锁:

只要我们破坏其中一个,就可以成功避免死锁的发生。

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。

1.破坏占用且等待条件

要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请 时,我们该怎么解决这个问题呢?

增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本 A 和 B,账本管理员如果发现文件架上只有账本 A,这个时候账本管理员是不会把账本 A 拿下来给张三的,只有账本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请所有资源”。


通过账本管理员拿账本.png

“同时申请”这个操作是一个临界区,需要一个角色(Java 里面的类)来管理这个临界区,把这个角色定为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 在语言层次没有解决这个问题,不过在 SDK 层面还是解决了的,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;
        }
      }
    }
  } 
}

极客时间《Java并发编程实战》学习笔记Day08 - http://gk.link/a/11W9i

上一篇 下一篇

猜你喜欢

热点阅读