美文网首页程序员
并发编程-死锁

并发编程-死锁

作者: 我可能是个假开发 | 来源:发表于2023-02-12 08:27 被阅读0次

一、细粒度锁

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

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

  • 文件架上恰好有转出账本和转入账本,那就同时拿走;
  • 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
  • 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

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

  • 首先尝试锁定转出账户 this(先把转出账本拿到手)
  • 然后尝试锁定转入账户 target(再把转入账本拿到手)
  • 当两者都成功时,才执行转账操作。

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


两个转账操作并行.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;
        }
      }
    }
  } 
}
  • 线程 T1 执行账户 A 转账户 B 的操作,账户 A.transfer(账户 B);
  • 同时线程 T2 执行账户 B 转账户 A 的操作,账户 B.transfer(账户 A)。
  • 当 T1 和 T2 同时执行完①处的代码时,T1 获得了账户 A 的锁(对于 T1,this 是账户 A),
  • 而 T2 获得了账户 B 的锁(对于 T2,this 是账户 B)。
  • 之后 T1 和 T2 在执行②处的代码时,T1 试图获取账户 B 的锁时,发现账户 B 已经被锁定(被 T2 锁定),所以 T1 开始等待;
  • T2 则试图获取账户 A 的锁时,发现账户 A 已经被锁定(被 T1 锁定),所以 T2 也开始等待。
  • 于是 T1 和 T2 会无期限地等待下去,也就是我们所说的死锁了。

三、预防死锁

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

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

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

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

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

1.破坏占用且等待条件

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

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


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

“同时申请”这个操作是一个临界区,需要一个角色(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 在语言层次没有解决这个问题,不过在 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

相关文章

  • Java高并发 -- 并发扩展

    Java高并发 -- 并发扩展 主要是学习慕课网实战视频《Java并发编程入门与高并发面试》的笔记 死锁 死锁是指...

  • Java Concurrent 死锁

    前言 死锁是一个比较大的概念,在并发场景下的加锁行为都有可能产生死锁问题。在Java 并发编程中会有死锁,操作系统...

  • 并发编程-死锁

    一、细粒度锁 现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们...

  • Java 并发编程(1): Java 内存模型(JMM)

    1. 并发编程 1.1 并发编程的挑战 并发编程的目的是为了加快程序的运行速度, 但受限于上下文切换和死锁等问题,...

  • 死锁

    一、什么是死锁 并发编程的本质是将串行执行的代码编程并行执行。并发编程的目的是为了加快程序的运行速度,但是...

  • 并发编程艺术-1

    本篇文章主要简单地介绍了并发编程的目的,上下文切换带来的影响,以及死锁的检测,解决,常见的并发资源限制。 并发编程...

  • 并发编程情况下几个相应问题简介

    1.并发编程的挑战之死锁 ​ 死锁是两个或更多线程阻塞着等待其它处于死锁状态的线程所持有的锁。死锁通常发生在多...

  • 大厂敲门砖,Github霸榜的顶级并发编程宝典被我搞到手了!

    并发编程的目的是为了提高程序的执行速度,但是并不意味着启动更多的线程会达到更好的并发效果,并发编程还会引起死锁 ,...

  • 《JAVA并发编程的艺术》要点(一)并发编程的挑战

    并发编程的目的是为了让程序运行的更快 并发编程面临的挑战 一、上下文切换问题 二、死锁问题 三、资源受限问题 (一...

  • 并发编程01-对于并发的认知

    多线程和并发的概念 上下文切换 如何减少上下文切换无锁并发编程CAS算法使用最少线程协程 死锁避免死锁的几个常见的...

网友评论

    本文标题:并发编程-死锁

    本文链接:https://www.haomeiwen.com/subject/ffzqkdtx.html