关于java:Java并发编程死锁下如何解决死锁

32次阅读

共计 3382 个字符,预计需要花费 9 分钟才能阅读完成。

我在上篇文章已经提到,锁的实质是串行化,如果笼罩的范畴太大,会导致程序的性能低下。

为了晋升性能,咱们用了细粒度锁,但这又带来了死锁问题。

如何解决死锁问题,就是程序员价值所在。

如何躲避死锁

说实话,大部分状况下,你不须要思考死锁问题。因为只有在并发量很大的时候,死锁才会呈现。

那该怎么解决呢?很简略,重启利用就行。

然而,问题来了。既然是在高并发场景下,才会呈现死锁。那这不就意味着,一旦呈现死锁,无论重启多少次,程序也没法运行?

因为只有并发量一大,你就得重启利用,但原来的业务没解决完,当初得接着解决呀。后果就是,等重启完了,并发量再次飙升,死锁再次出现,你又得重启利用。如此周而复始,基本看不到头。

因而,想要解决死锁,惟一可行的方法是:躲避死锁,不让死锁呈现。

如何躲避死锁?这个问题,早在 1971 年,就有位叫 Coffman 的大神提出了解决思路。

死锁的产生要同时满足四个条件。换句话说,咱们只有毁坏其中一个,就能够防止死锁。这四个条件别离是:

  1. 互斥:在同一时刻,一个资源只能由一个线程操作;
  2. 占有且期待:线程占有了一个资源,在申请另一个资源的时候,不开释本人占有的资源;
  3. 不可抢占:资源被某个线程占有后,其它线程不能强行抢占;
  4. 循环期待:线程一占有了一个资源,线程二占有了另一个资源,它们都在等对方的资源。

这四个条件必须同时满足,才会产生死锁。咱们只有想方法毁坏其中一个,就能防止死锁的呈现。

既然如此,该毁坏哪个条件呢?

首先,毁坏条件一 互斥,这是没法做到的,毕竟用锁就是为了互斥。

而后,毁坏条件三 不可抢占,当初也做不到。因为这须要线程被动开释占有的资源,但synchronized 可做不到这点,得用到 Java 的并发包。所以,咱们当前再聊。

那么,计划只剩两个了。

毁坏占有且期待条件

所谓占有且期待就是,运行程序须要两个以上的资源,那么线程要把这些资源都拿到手,才有资格运行程序。

然而,线程又不止一个,资源就放在那儿,谁抢到就是谁,就能够始终占着,不开释进来。

这时候,如果线程只抢到了一个资源,没资格运行程序,但又不开释本人的资源,就在那儿干等着。那其它线程怎么办?只能陪你一起等,谁都没法动呗。

这种僵持的场面怎么破?

一次性锁定所有资源,这样就能毁坏占有且期待条件。

拿后面的转账来说,它须要锁定两个资源,一个是转出账户,一个是转入账户。如果一次只锁定一个账户,下一个账户又锁定不了,死锁不就呈现了。

那这样行不行,我一次性锁定所有账户。

在转账之前,我同时申请两个账户,而且必须同时加锁胜利时,能力持续转账操作。

在设计思路上,咱们能够把代码分成两块:第一块,是业务员模块,负责具体的转账业务;第二块,是管理员模块,负责资源的审批。你看下这副图:

简略来说,业务员提交申请,账户管理员锁定资源。

比如说,张三想要转账,要锁定 账户 A 账户 B ,但账户管理员发现文件架上只有 账户 A ,那张三只能等着,直到 账户 A 账户 B 都回来时,能力进行加锁。

思路有了,接下来,咱们就转换成代码吧。

class Account {
    private int balance;

    // 转账
    void transfer(Account target, int amt) {
        // 一次性申请资源:转出账户、转入账户,一直循环
        while (!Allocator.getInstance().apply(this, target));

        // 执行转账
        if (this.balance >= amt) {
            this.balance -= amt;
            target.balance += amt;
        }

        // 偿还资源:转出账户、转入账户
        Allocator.getInstance().free(this, target);
    }
}

class Allocator {

    // 单例对象,我就不写了,置信你能搞定
    static Allocator getInstance() {return null;}

    // 资源账本:记录哪些资源不能用
    private Set<Object> alsSet = new HashSet<>();

    // 一次性申请所有资源
    synchronized boolean apply(Object from, Object to) {
        // 查找账本,看看哪些资源不能用
        if (alsSet.contains(from) || alsSet.contains(to)) {return false;} 
        
        // 借出资源
        alsSet.add(from);
        alsSet.add(to);
        
        return true;
    }
    // 偿还资源
    synchronized void free(Object from, Object to) {alsSet.remove(from);
        alsSet.remove(to);
    }
}

线程在执行转账前,要通过循环,一直向 Allocator 申请资源。如果申请失败,就持续申请;如果申请胜利,就执行转账操作,完预先再把资源还回去。

通过一次性锁定所有资源,咱们就能毁坏占有且期待条件,胜利躲避死锁。

不过,这个计划有两点很难承受。

首先,一次性申请资源,这可是一个死循环。在高并发的时候,可能循环上万次能力拿到锁,这太耗费 CPU 资源了。

其次,代码量翻了不止一倍。本来的代码连十行都不到,但当初得新建一个类,还得在外面写各种办法,像是创立单例、申请资源、偿还资源什么的,这些想想就头大。

一次性申请资源尽管解决了死锁,但程序性能是硬伤,实现起来也很简单,我还是看看别的计划吧~

毁坏循环期待条件

所谓循环期待就是,运行程序须要两个以上的资源,那么线程要把这些资源都拿到手,才有资格运行程序。

然而,一个线程拿到了其中一个资源,另一个线程拿到了另一个资源,而后它们都在等对方的资源。

别忘了,只有运行完程序,线程才会开释资源。但这两个线程都只拿到一个资源,没法运行程序,那这种期待注定没有后果。你能够看上面这副图:

这种场面又怎么破呢?

按程序加锁,这样就能毁坏循环期待条件。

你有没有发现,在下面这幅图中,两个线程都要要锁定 账户 A 账户 B ,但它们的加锁程序不同。一个线程先锁定 账户 A ,另一个线程先锁定 账户 B ,后果就是它们各自占了一个资源,都在等对方的资源。

既然是加锁程序的问题,那咱们让线程按程序加锁,问题不就解决了吗?你看这副图:

两个线程的加锁程序是一样的,都要先锁定 账户 A ,再锁定 账户 B 。如果线程一先锁定了 账户 A ,那线程二只能等着,直到线程一执行完转账,线程二能力再次尝试加锁。

这样一来,循环期待就被破解了。如果再把下面的思路转换成代码,就是这样的。

class Account {
    private int id;
    private int balance;

    void transfer(Account target, int amt) {
        /** 资源进行排序:id 越小,账号就先加锁 **/
        // 默认状况:转出账号的 id 更小,先进行加锁
        Account first = this;
        Account second = target;

        // 如果转出账号的 id 更大,就扭转加锁程序
        if (this.id > target.id) {
            first = target;
            second = this;
        }

        /** 执行转账 **/
        // 锁定序号小的账号
        synchronized (first) {
            // 锁定序号大的账号
            synchronized (second) {if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

每个账号都有一个 id,那么咱们先就把 id 作为排序字段,从小到大地排序。而后,咱们持续依照程序,从小到大地锁定账户。

这样一来,就不会呈现两个线程各占山头,互相死等对方资源的状况了。

而且,这个计划无论是性能,还是实现难度都非常低。

首先,对资源进行排序,这部分的代码非常简单,不过是加了一个 if 判断,创立了两个对象而已,耗费不了什么资源。

其次,代码量极少。相比原来的代码,当初只是多加了 6 行代码用来排序,其它的根本没变。

这样看来,对转账业务来说,按程序加锁算是一个低成本、高回报的计划了。

写在最初

想要解决死锁,只能重启利用,但重启不肯定管用。所以,咱们只能在写代码的时候,想方法躲避死锁。

个别状况下,有两个方法能够防止死锁:

  1. 一次性锁定所有资源;
  2. 按程序加锁;

看到这儿,置信你曾经感触到了并发编程的魅力。每一种计划都要付出相应的代价,咱们得在正确性、性能、代码复杂度之间,一直地衡量,从中选出适合的计划。

正文完
 0