死锁杂谈

以前说锁和被保护的资源是1:N的关系,这些被保护的资源有可能是彼此没关联的,也有可能彼此关联。那么有什么不同呢?死锁到底是怎么产生的?死锁该如何规避?

保护没有关联关系的多个资源

由于这些资源彼此没关系,我们可以把他们全都塞进this这把锁里一了百了,我们也可以给不同的资源加不同的锁分而治之。总的来说并行分而治之的性能肯定要比串行的同一把锁好的多。分而治之的这种锁也叫细粒度锁

保护有关联关系的多个资源

有关联的资源也可以像没关联的资源那样加锁吗?

下面我们举个栗子:

1.有A、B、C三个银行账户,账户余额都为200

2.A给B转账100,B给C转账100

如果我们不加锁:


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

这样的话就不存在互斥,不存在原子性操作,会有并发问题产生。

那么我们就加锁,首先将所有资源都塞到一个对象锁里面去试试:


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

两个相关联的资源都在当前对象锁的临界区,但是我们发现锁住当前对象只能锁住当前对象的变量balance 无锁住target对象的balance。因为对象锁的概念就是,要进入对象锁的代码块就首先要拿到该对象的锁。

线程1拿到了A账户的对象锁,它开始对A出账,对B入账;线程2拿到可B的对象锁,它开始对B出账对C入账。所以两个线程同时对B账户进行了操作:

我们期望转账后A、B、C的余额分别是100 200 300。

但事实上B账户的余额数值100 200 300这三种结果都会有,取决于两个线程谁先把B的结果写入内存。

既然对象锁不行那就加类锁试试:


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

类锁的好处就是临界区的操作绝对是唯一的,所有该类的对象操作都用同一把锁,这样就能保证不管多少线程,能进入临界区对B账户操作的只有。

但是,想象一下如果有上亿个账户都这样傻瓜式的顺序执行,那该有多慢。我们之前提到细粒度的锁性能会更好一点,账户A转账账户B,账户C转账账户D是不存在互斥的,是可以同时进行的。我们知足要给转入账户和转出账户加上锁就OK,没必要加全局的锁。


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.balance ,target对象的锁保护了target.balance细粒度化了两个操作实现了可并行操作。但是看似美好的事物背后都藏着陷阱,编程也一样。

就拿上面这个例子来看,假如A要给B转账,B恰好也要给A转账,没毛病,可以并行。但是问题就出在,两个线程一开始就把当前对象this锁了,也就是把彼此的资源balance家里锁。A等B给它余额信息,同时B也在等A给他余额信息。然后就是互不让步,就是瞎等。这样就形成了一个无解的闭环,很坏的关系:死锁

如何防止死锁呢?

最简单粗暴的解决方法就是重启,但这样治标不治本,遇到类似情况一样白瞎。所以说最好的办法是规避它,在设计之初就考虑到它的存在。

如何解决死锁问题,我们站在巨人的肩头,所以巨人们已有定论。有个叫 Coffman 的牛人早就总结过了死锁发生的条件:

1.互斥,共享资源 X 和 Y 只能被一个线程占用;

2.占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;

3.不可抢占,其他线程不能强行抢占线程 T1 占有的资源;

4.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

如何解决,总结一句话就是破而后立,僵持中总有人要让步,破坏形成死锁的条件

之前说过,要保持原子性操作,就要互斥。所以条件1,我们是没办法破坏的。那么剩下的解决办法就有:

1. 破坏占用且等待条件

一个线程一次性申请所有的资源。上面的例子,如果我们一次性申请到A B两个账户的余额信息,交给一个管理员T管理申请,如果A和B的账户余额信息缺一个都表示申请失败。那么A转账B 和B转账A就互斥了,也就不存在死锁了;

分布式事务貌似就可以这样实现

2. 破坏不可抢占条件

从字面上看就知道,如果抢占不到共享资源,让它释放线程占用的资源。然而synchronized是做不到这一点的,它抢占不到资源就会进入阻塞状态,不会释放线程占用资源的。

当然它做不到,不等于别人做不到,岂不小看了天下英雄。至少java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。

我们经常会遇到说synchronized和Lock的区别,这就是其中一个。

3. 破坏循环等待条件

要破坏循环等待条件,就是那个无解的闭环,线程1占有A的资源,线程2占有B的资源。不妨给A和B的账户资源排个序。再让线程去申请资源。这样如果资源A排在前头,线程1 申请到了A的资源,加了锁后线程2就无法进入资源A锁的临界区,也就不可能去占用B的资源了。如下面代码:


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

总结:

细颗粒的锁存在死锁的风险,要根据情况去规避。

破坏死锁的几个条件,酌情应用。

原子性的本质并非不可分割,而是要保持操作的中间状态的不可见性。例如账户转账的例子:

A转账B ,A减少100的时候B还没有变,这个就是中间状态。还没有操作完成的变量,要保持对外的不可见性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值