zoukankan      html  css  js  c++  java
  • java并发编程实战《五》死锁

    一不小心就死锁了,怎么办?

      在上一篇文章中,我们用 Account.class 作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,性能太差。

    向现实世界要答案

      

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

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

        此处王老师应该只是想给我们构建一个场景,特地查了下,参考此处  简单说下,我国古代奴隶社会时,一个人管一种账号,“司书掌管会计账簿,职内掌管财务收入账户,职岁掌管财务支出类账户,职币掌管财务结余”,称““单式记账法””,后又有“入出账法”。

      上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。

     
     1 class Account {
     2   private int balance;
     3   // 转账
     4   void transfer(Account target, int amt){
     5     // 锁定转出账户
     6     synchronized(this) {              
     7       // 锁定转入账户
     8       synchronized(target) {           
     9         if (this.balance > amt) {
    10           this.balance -= amt;
    11           target.balance += amt;
    12         }
    13       }
    14     }
    15   } 
    16 }

      上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用 Account.class 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,上一章我们介绍过,叫细粒度锁。

      使用细粒度锁可以提高并行度,是性能优化的一个重要手段。

    使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?  

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

        死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。如下图

      

    图片来自极客时间java并发编程实战
     
    如何预防死锁
      并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
      如何避免死锁呢?
        要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁
          互斥,共享资源 X 和 Y 只能被一个线程占用;
          占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
          不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
          循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
     
      只要我们破坏其中一个,就可以成功避免死锁的发生。
      互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。
    •   对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
    •   对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
    •   对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

    那具体如何体现在代码上呢?

      破坏 占用且等待 条件

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

        可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。这样就保证了“一次性申请所有资源”。(解决不了的问题就再加一个中间层?)

        

          “同时申请”这个操作是一个临界区,我们也需要一个角色(Java 里面的类)来管理这个临界区,我们就把这个角色定为 Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。

          账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。

          当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

        具体的代码实现如下:

          

     1 class Allocator {
     2   private List<Object> als =  new ArrayList<>();
     3   // 一次性申请所有资源
     4   synchronized boolean apply(
     5     Object from, Object to){
     6     if(als.contains(from) ||
     7          als.contains(to)){
     8       return false;  
     9     } else {
    10       als.add(from);
    11       als.add(to);  
    12     }
    13     return true;
    14   }
    15   // 归还资源
    16   synchronized void free(
    17     Object from, Object to){
    18     als.remove(from);
    19     als.remove(to);
    20   }
    21 }
    22 
    23 class Account {
    24   // actr应该为单例 //这个单例怎么实现?
    25   private Allocator actr;
    26   private int balance;
    27   // 转账
    28   void transfer(Account target, int amt){
    29     // 一次性申请转出账户和转入账户,直到成功
    30     while(!actr.apply(this, target)) // 原理类似CAS,也是自旋,实际项目中需要加入超时时间,避免一直阻塞
    31 32     try{
    33       // 锁定转出账户
    34       synchronized(this){              
    35         // 锁定转入账户
    36         synchronized(target){           
    37           if (this.balance > amt){
    38             this.balance -= amt;
    39             target.balance += amt;
    40           }
    41         }
    42       }
    43     } finally {
    44       actr.free(this, target)
    45     }
    46   } 
    47 }

      破坏 不可抢占 条件

      破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

        Java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。

        简单说一下synchronized的原理?

      

      破坏 循环等待 条件

      破坏这个条件,需要对资源进行排序,然后按序申请资源

        我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请(大到小也行,重点是排序)。

        

     1 class Account {
     2   private int id;
     3   private int balance;
     4   // 转账
     5   void transfer(Account target, int amt){
     6     Account left = this; 7     Account right = target;    ②
     8     if (this.id > target.id) { ③
     9       left = target;           ④
    10       right = this;            ⑤
    11     }                          ⑥
    12     // 锁定序号小的账户
    13     synchronized(left){
    14       // 锁定序号大的账户
    15       synchronized(right){ 
    16         if (this.balance > amt){
    17           this.balance -= amt;
    18           target.balance += amt;
    19         }
    20       }
    21     }
    22   } 
    23 }

    总结

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

      识别出风险很重要。

    识别出风险很重要。

      我们在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案。

    课后思考

      我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?

      引自极客用户:

        虽然上面两种锁的方式都是串行化了,但是具体还是有一点区别的:synchronized(Account.class)的方式相当于A->B 转账,C->D转账 先后执行,而 actr.apply(this, target)的方式则是apply-->转账-->free这样的串行方式执行,但是在转账中是可以A->B,C->D转账线程并行执行的,正如文中提到的apply方法耗时很少 所以比如一次转账耗时200ms,apply+release方式执行要20ms,所以用synchronized的方式A->B,C->D则需要耗时400ms,而appy的方式则要200+20*2=240ms,并且同时转账的人越多 apply方式的转账并行度越高 比synchronized的方式的优势越明显。

    对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
    只要我们破坏其中一个,就可以成功避免死锁的发生。
    能太差。
    性能太差。
  • 相关阅读:
    浅析Java8新特性-Optional方法介绍(Optional.ofNullable-构造方法、map-逐层安全地拆解value、filter-过滤值、orElse/orElseThrow-最终返回、stream-转为流)及常用实践(仅作为方法返回值、清晰地表达返回值中没有结果的可能性、勿滥用Optional)、Optional的设计思想实现
    浅析Java8新特性-新的日期和时间API:起初时间存在的问题(非线程安全、设计乱、时区处理麻烦)、日期时间(LocalDate/LocalTime/LocalDateTime)、时间戳(Instant)、时间差(Duration/Period)、TemporalAdjuster时间矫正器、LocalDateTime 与 Date 互转、时间先后判断/MonthDay类的使用(生日检查)
    浅析 Employee::new / String[]::new 是什么意思?Java8新特性-方法引用、构造器引用、数组引用的了解及使用
    浅析HTTP的Referer含义理解、Referer作用(记录访问来源、防盗链、防止恶意请求)、Referrer Policy引用策略的9个值及设置用法、什么情况会导致空Referer
    浅析Java8新特性-四大内置核心函数式接口 :消费型Consumer(void accept)、供给型Supplier(T get)、函数型Funciton(R apply)、断言型Predicate(boolean test)
    浅析Java Lambda表达式、如何理解Lambda、如何使用Lambda简化代码(结合stream处理遍历、配合Optional优雅的处理null情况)
    【原文】linux systemctl命令详解
    [转]解决Ubuntu DNS覆盖写入127.0.0.53
    mysql 安装
    【转】lvextend 无法使用 virtualbox 扩容的vdi
  • 原文地址:https://www.cnblogs.com/woooodlin/p/12970229.html
Copyright © 2011-2022 走看看