【Java并发基础】死锁

前言

咱们使用加锁机制来保证线程安全,可是若是过分地使用加锁,则可能会致使死锁。下面将介绍关于死锁的相关知识以及咱们在编写程序时如何预防死锁。java

什么是死锁

学习操做系统时,给出死锁的定义为两个或两个以上的线程在执行过程当中,因为竞争资源而形成的一种阻塞的现象,若无外力做用,它们都将没法推动下去。简化一点说就是:一组相互竞争资源的线程由于互相等待,致使“永久”阻塞的现象面试

下面咱们经过一个转帐例子来深刻理解死锁。算法

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

为了使以上转帐方法transfer()不存在并发问题,很快地咱们能够想使用Java的synchronized修饰transfer方法,因而代码以下:编程

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

须要注意,这里咱们使用的内置锁是this,这把锁虽然能够保护咱们本身的balance,却不能够保护target的balance。使用咱们上一篇介绍的锁模型来描绘这个代码就是下面这样:(图来自参考[1])安全

更具体来讲,假设有 A、B、C 三个帐户,余额都是 200 元,咱们用两个线程分别执行两个转帐操做:帐户 A 转给帐户 B 100 元,帐户 B 转给帐户 C 100 元,最后咱们指望的结果应该是帐户 A 的余额是 100 元,帐户 B 的余额是 200 元, 帐户 C 的余额是 300 元。
若是有两个线程1和线程2,线程1 执行帐户 A 转帐户 B 的操做,线程2执行帐户 B 转帐户 C 的操做。这两个线程分别运行在两颗的CPU上,因为this这个锁只能保护本身的balance而不能保护别人的,线程 1 锁定的是帐户 A 的实例(A.this),而线程 2 锁定的是帐户 B 的实例(B.this),因此这两个线程能够同时进入临界区 transfer(),所以两个线程没有实现互斥。
出现可能的结果就为,两个线程同时读到帐户B的余额为200元,致使最终帐户 B 的余额多是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),多是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不多是 200。
并发转帐示意图(图来自参考[1])并发

因而咱们应该使用一个可以覆盖全部保护资源的锁,若是还记得咱们上一篇讲synchronized修饰静态方法时默认的锁对象的话,那这里就很容易解决了。这个默认的锁就是类的class对象。因而,咱们就可使用Account.class做为一个能够保护这个转帐过程的锁。app

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

这个方案虽然不存在并发问题,可是全部帐户的转帐操做都是串行的。现实世界中,帐户 A 转帐户 B、帐户 C 转帐户 D 这两个转帐操做现实世界里是能够并行的。较于实际状况来讲,这个方案就显得性能太差。性能

因而,咱们尽可能模仿现实世界的转帐操做:
每一个帐户都有一个帐本,这些帐本都统一存放在文件架上。当转帐A给帐户B转帐时,柜员会去拿A帐本和B帐本作登记,此时柜员在拿帐本时会遇到三种状况:学习

  1. 文件架上刚好有A帐本和B帐本,那就同时拿走;
  2. 若是文件架上只有A帐本和B帐本之一,那这个柜员就先把文件架上有的帐本拿到手,同时等着其余柜员把另一个帐本送回来;
  3. A帐本和B帐本都没有,那这个柜员就等着两个帐本都被送回来

在编程实现中,咱们可使用两把锁来实现这个过程。在 transfer() 方法内部,咱们首先尝试锁定转出帐户 this(先把A帐本拿到手),而后尝试锁定转入帐户 target(再把B帐本拿到手),只有当二者都成功时,才执行转帐操做。
这个逻辑能够图形化为下图这个样子,(图来自参考[1]):优化

代码以下:

class Account {
    private int balance;
    // 转帐
    void transfer(Account target, int amt){
        // 锁定转出帐户A
        synchronized(this) {              
            // 锁定转入帐户B
            synchronized(target) {           
                if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    } 
}

通过这样的优化后,帐户 A 转帐户 B 和帐户 C 转帐户 D 这两个转帐操做就能够并行了。

可是这样却会致使死锁。例如状况:柜员张三作帐户A转帐户B的转帐操做,柜员李四作帐户B转帐户C的转帐操做。他们两个同时操做,因而就会出现下面这种情形:(图来自参考[1])

他俩会一直等待对方将帐本放到文件架上,形成一个一直僵持的局势。

关于这种现象,咱们还能够借助资源分配图来可视化锁的占用状况(资源分配图是个有向图,它能够描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经得到该资源,线程指向资源的边则表示线程请求资源,但还没有获得。(图来自参考[1])

Java并发程序一旦死锁,通常没有特别好的方法,恢复应用程序的惟一方式就是停止并重启。所以,咱们要尽可能避免死锁的发生,最好不要产生死锁。要知道如何才能作到不要产生死锁,咱们首先要知道什么条件会发生死锁。

死锁发生的四个必要条件

虽然进程在运行过程当中,可能发生死锁,但死锁的发生也必须具有必定的条件,死锁的发生必须具有如下四个必要条件:

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

破坏死锁发生的条件预防死锁

只有这四个条件都发生时才会出现死锁,那么反过来,也就是说只要咱们破坏其中一个,就能够成功预防死锁的发生

四个条件中咱们不能破坏互斥,由于咱们使用锁目的就是保证资源被互斥访问,因而咱们就对其余三个条件进行破坏:

  • 占用且等待:一次性申请全部的资源,这样就不存在等待了。
  • 不可抢占,占用部分资源的线程进一步申请其余资源时,若是申请不到,能够主动释放它占有的资源。
  • 循环等待,靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候能够先申请资源序号小的,再申请资源序号大的,这样线性化申请后就不存在循环了。

下面咱们使用这些方法去解决如上的死锁问题。

破坏占用且等待条件

一次性申请完全部资源。咱们设置一个管理员来管理帐本,柜员同时申请须要的帐本,而管理员同时出他们须要的帐本。若是不能同时出借,则柜员就须要等待。

“同时申请”:这个操做是一个临界区,含有两个操做,同时申请资源apply()和同时释放资源free()。

class Allocator {
    private List<Object> als = new ArrayList<>();
    // 一次性申请全部资源
    synchronized boolean apply( Object from, Object to){
        if(als.contains(from) || als.contains(to)){    //from 或者 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))  //最好能够加个timeout避免一直循环
            ;
            try{
                // 锁定转出帐户
                synchronized(this){ //存在客户对本身帐户的操做
                    // 锁定转入帐户
                    synchronized(target){           
                        if (this.balance > amt){
                            this.balance -= amt;
                            target.balance += amt;
                        }
                    }
                }
            } finally {
                actr.free(this, target)    //释放资源
            }
    }
}

破坏不可抢占条件

破坏不抢占要可以主动释放它占有的资源,但synchronized是作不到的。缘由为synchronized申请不到资源时,线程直接进入了阻塞状态,而线程进入了阻塞状态也就没有办法释放它占有的资源了。不过SDK中的java.util.concurrent提供了Lock解决这个问题。

支持定时的锁

显示使用Lock类中的定时tryLock功能来代替内置锁机制,能够检测死锁和从死锁中恢复过来。使用内置锁的线程获取不到锁会被阻塞,而显示锁能够指定一个超时时限(Timeout),在等待超过该时间后tryLock就会返回一个失败信息,也会释放其拥有的资源。

破坏循环等待条件

破坏这个条件,须要对资源进行排序,而后按序申请资源。咱们假设每一个帐户都有不一样的属性 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;
                }
            }
        }
    } 
}

小结

记得学习操做系统时还有避免死锁,其和预防死锁的区别在于:预防死锁是设法至少破坏产生死锁的四个必要条件之一,严格地防止死锁的出现,可是这也会使系统性能下降;而避免死锁则不那么严格的限制产生死锁的必要条件的存在,由于即便死锁的必要条件存在,也不必定发生死锁,死锁避免是在系统运行过程当中注意避免死锁的最终发生。避免死锁的经典算法就是银行家算法,这里就不扩开介绍了。

还有一个避免出现死锁的结论:若是全部线程以固定顺序来得到锁,那么在程序中就不会出现锁顺序死锁问题。查看参考[4]理解。

咱们使用细粒度锁锁住多个资源时,要注意死锁的产生。只有先嗅到死锁的味道,才有咱们的施展之地。

参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016 [3]iywwuyifan.避免死锁和预防思索的区别.https://blog.csdn.net/masterchiefcc/article/details/83303813 [4]AddoilDan.死锁面试题(什么是死锁,产生死锁的缘由及必要条件).https://blog.csdn.net/hd12370/article/details/82814348

相关文章
相关标签/搜索