Java并发编程 | 遇到死锁不要慌,看一下死锁本质

712 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

上一篇文章我们留了一个坑,就是对Accout转账的方法transfer()使用了Account.class作为锁来进行保护,这样可以保证所有用户的余额都是并发安全的,但是性能也极度堪忧,假如有1万笔转账,则这1万笔转账都要串行进行,肯定不合理。

正文

细粒度锁

我们来仔细思考一下transfer方法中涉及的受保护资源,一个是转出账户的余额,一个是转入账户的余额,所以我们只需要保证在同一时刻转出账户和转入账户只能被一个线程访问即可,而其他账户我们不做要求,所以这里我们细化锁,使用2把锁来解决问题:

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转账给账户B,账户B转账给账户C,分别是线程1和线程2来执行,当线程1调用A的transfer方法时,它就会先锁定A.this,假如B.this还没有被线程2锁定,则线程1会继续锁定B.this,直到完成再释放A.this和B.this这2个锁,这时线程2才能获取到B.this这个锁,这样就不会导致B的余额错乱的情况。

死锁

上面解决方案看似完美解决了问题,即没有使用Account.class这种全局锁导致的性能问题,也不会导致余额错误,但是这种细粒度锁却可能会导致一个严重问题,就是死锁

死锁概念

假设现在有业务是账户A转账给账户B,账户B转账给账户A,分别是线程1和线程2来执行,当线程1执行transfer方法时,锁定A.this时,线程2也执行到B的transfer方法中锁定B.this的位置,这时线程1就一直等待B.this锁的释放,同时线程2一直等待A.this锁的释放,但是现实情况是他们都不会等到,会一直等下去,这就造成了程序一直等待的情况。

而上面情况就是死锁,一个比较专业的定义是:一组互相竞争资源的线程因为互相等待,导致"永久"阻塞的现象。

死锁产生的条件

当程序发生死锁时,一般只有重启应用了,因此解决死锁的最佳办法是规避死锁。

既然是规避死锁,这里就必须要清楚死锁产生的条件,这样才有对应办法来规避死锁,只有下面4个条件都满足才会发生死锁:

  1. 互斥,共享资源只能被一个线程占用;
  2. 占有且等待,即一个线程获取了共享资源X,在等待获取共享资源Y的时候,会一直等待;
  3. 不可抢占,其他线程不能强行抢占该线程已经占有的资源;
  4. 循环等待,线程1等待线程2占用的资源,线程2等待线程1占用的资源,就是循环等待。

其实上面产生死锁的4个条件都很好理解,我们想避免死锁,就破坏上面的产生条件即可,其中互斥我们没法破坏,因为使用锁的原因就是实现互斥,所以我们可以破坏其他3个条件:

  1. 破坏占有且等待条件,我们可以一次性申请所有资源,这样就不会出现占有一部分资源再等待另一部分资源的情况了。
  2. 破坏不可抢占,当一个持有部分资源的线程去申请新的资源时,如果申请不到,则可以主动释放它拥有的资源,这样其他线程就可以抢占该线程占用的资源。(这里按理说是A线程去申请某个资源X,申请不到,持有资源X的线程释放才对,但是这里无法判断,这个X被哪个线程持有了,所以我们反向思维)
  3. 破坏循环等待, 我们可以采用按序申请方法,这里循环等待的原因是线程1和线程2都占用了不同的资源,占用顺序是随机的,如果我们给资源加个先后顺序,这样就不会出现等待其他线程占用资源的情况了。

理解了死锁产生的条件,以及破坏死锁的方法,我们下面分别来看一下如何实现。

破坏占用且等待条件

从理论上说,要破坏这个条件可以一次性申请所有资源,这样就不会出现一个线程获取了一部分资源,然后等待获取另一部分资源地情况。

我们还以上面的转账作为例子,在转账操作中,比如A给B转账,B给A转账,2个线程如果能一次性申请到A、B 2个账户,这样一个线程就可以先执行,等执行完后,把A、B账户再释放,另一个线程再获取A、B账户,这样就不会死锁了。

按照这个思路,我们需要一个中间人,这个中间人就是用来申请2个账户的作用,当2个账户都申请成功后,则告诉线程可以执行了。这里我们定义一个Allocator类,然后定义一个申请所有资源的方法和释放所有资源的方法:

class Allocator {
  //已经被锁定的资源 already locked resources
  private List<Object> als = new ArrayList<>();
  
  // 一次性申请所有资源
  synchronized boolean apply(Object from, Object to){
    //当锁定的资源池中有from或者to的话,则说明没有完全申请成功
    if(als.contains(from) || als.contains(to)){
      return false;  
    } else {
      //再把form和to加到锁定的资源池中
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

想象一下,我们需要一个中间人来申请所需要的多个资源,那这个申请操作必须要是互斥的,而且因为中间人只能有一个,所以这里Allocator也必须是单例

有了中间人之后,代码就好实现了,如下:

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

上面代码中,在A给B转账的同时B也给A转账,就不会出现死锁的情况了。

破坏不可抢占条件

在前面我们说了,这种情况虽然叫做破坏不可抢占其实核心还是线程能主动释放它所占有的资源,这一点synchronized是做不到的,因为synchronized申请资源的时候,如果申请不到,线程就会进入阻塞状态,也不会释放线程所占有的资源。

不过Java的SDK中的并发包里会提供Lock来解决这个问题,我们后面说Lock的时候再说。

破坏循环等待条件

这个也非常好理解,为什么会循环等待呢 也就是因为占用的资源和需要的资源在不同线程间形成了闭环,就比如上面例子中线程1持有A,缺少B,而线程2持有B,缺少A,这样就会导致循环等待。

那如果给资源编个顺序,然后按序申请,比如去线程1和线程2都必须按照A -> B 这个顺序去申请,有一个申请成功时,另一个线程便只会等待,就不会死锁了。

具体代码如下所示:

class Account {
  //资源的编号
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    //这里left是id小的账号,right为id大的账号
    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;
        }
      }
    }
  } 
}

比如上面代码,当区申请资源时有顺序,就不会造成循环等待了,从而解决了死锁问题。

总结

死锁发生的4个条件:互斥、占有且等待、不可抢占和循环等待,破坏占有且等待使用一次性申请所有资源方法,破坏不可抢占使用线程可以释放资源方法,循环等待让资源有序被申请。