写点什么

【高并发】优化加锁方式时竟然死锁了!!

作者:冰河
  • 2022 年 5 月 26 日
  • 本文字数:4623 字

    阅读完需:约 15 分钟

【高并发】优化加锁方式时竟然死锁了!!

写在前面

今天,在优化程序的加锁方式时,竟然出现了死锁!!到底是为什么呢?!经过仔细的分析之后,终于找到了原因。

为何需要优化加锁方式?

在《【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)》一文中,我们在转账类 TansferAccount 中使用 TansferAccount.class 对象对程序加锁,如下所示。


public class TansferAccount{    private Integer balance;    public void transfer(TansferAccount target, Integer transferMoney){        synchronized(TansferAccount.class){          if(this.balance >= transferMoney){                this.balance -= transferMoney;                target.balance += transferMoney;            }           }    }}
复制代码


这种方式确实解决了转账操作的并发问题,但是这种方式在高并发环境下真的可取吗?试想,如果我们在高并发环境下使用上述代码来处理转账操作,因为 TansferAccount.class 对象是 JVM 在加载 TansferAccount 类的时候创建的,所有的 TansferAccount 实例对象都会共享一个 TansferAccount.class 对象。也就是说,所有 TansferAccount 实例对象执行 transfer()方法时,都是互斥的!!换句话说,所有的转账操作都是串行的!!


如果所有的转账操作都是串行执行的话,造成的后果就是:账户 A 为账户 B 转账完成后,才能进行账户 C 为账户 D 的转账操作。如果全世界的网民一起执行转账操作的话,这些转账操作都串行执行,那么,程序的性能是完全无法接受的!!!


其实,账户 A 为账户 B 转账的操作和账户 C 为账户 D 转账的操作完全可以并行执行。所以,我们必须优化加锁方式,提升程序的性能!!

初步优化加锁方式

既然直接 TansferAccount.class 对程序加锁在高并发环境下不可取,那么,我们到底应该怎么做呢?!


仔细分析下上面的代码业务,上述代码的转账操作中,涉及到转出账户 this 和转入账户 target,所以,我们可以分别对转出账户 this 和转入账户 target 加锁,只有两个账户加锁都成功时,才执行转账操作。这样就能够做到账户 A 为账户 B 转账的操作和账户 C 为账户 D 转账的操作完全可以并行执行。


我们可以将优化后的逻辑用下图表示。



根据上面的分析,我们可以将 TansferAccount 的代码优化成如下所示。


public class TansferAccount{    //账户的余额    private Integer balance;    //转账操作    public void transfer(TansferAccount target, Integer transferMoney){        //对转出账户加锁        synchronized(this){            //对转入账户加锁            synchronized(target){                if(this.balance >= transferMoney){                    this.balance -= transferMoney;                    target.balance += transferMoney;                }               }        }    }}
复制代码


此时,上面的代码看上去没啥问题,但真的是这样吗? 我也希望程序是完美的,但是往往却不是我们想的那样啊!没错,上面的程序会出现 死锁, 为什么会出现死锁啊? 接下来,我们就开始分析一波。

死锁的问题分析

TansferAccount 类中的代码看上去比较完美,但是优化后的加锁方式竟然会导致死锁!!!这是我亲测得出的结论!!


关于死锁我们可以结合改进的 TansferAccount 类举一个简单的场景:假设有线程 A 和线程 B 两个线程同时运行在两个不同的 CPU 上,线程 A 执行账户 A 向账户 B 转账的操作,线程 B 执行账户 B 向账户 A 转账的操作。当线程 A 和线程 B 执行到 synchronized(this)代码时,线程 A 获得了账户 A 的锁,线程 B 获得了账户 B 的锁。当执行到 synchronized(target)代码时,线程 A 尝试获得账户 B 的锁时,发现账户 B 已经被线程 B 锁定,此时线程 A 开始等待线程 B 释放账户 B 的锁;而线程 B 尝试获得账户 A 的锁时,发现账户 A 已经被线程 A 锁定,此时线程 B 开始等待线程 A 释放账户 A 的锁。


这样,线程 A 持有账户 A 的锁并等待线程 B 释放账户 B 的锁,线程 B 持有账户 B 的锁并等待线程 A 释放账户 A 的锁,死锁发生了!!

死锁的必要条件

在如何解决死锁之前,我们先来看下发生死锁时有哪些必要的条件。如果要发生死锁,则必须存在以下四个必要条件,四者缺一不可。

互斥条件

在一段时间内某资源仅为一个线程所占有。此时若有其他线程请求该资源,则请求线程只能等待。

不可剥夺条件

线程所获得的资源在未使用完毕之前,不能被其他线程强行夺走,即只能由获得该资源的线程自己来释放(只能是主动释放)。

请求与保持条件

线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放。

循环等待条件

既然死锁的发生必须存在上述四个条件,那么,大家是不是就能够想到如何预防死锁了呢?

死锁的预防

并发编程中,一旦发生了死锁的现象,则基本没有特别好的解决方法,一般情况下只能重启应用来解决。因此,解决死锁的最好方法就是预防死锁。


发生死锁时,必然会存在死锁的四个必要条件。也就是说,如果我们在写程序时,只要“破坏”死锁的四个必要条件中的一个,就能够避免死锁的发生。接下来,我们就一起来探讨下如何“破坏”这四个必要条件。

破坏互斥条件

互斥条件是我们没办法破坏的,因为我们使用锁为的就是线程之间的互斥。这一点需要特别注意!!!!

破坏不可剥夺条件

破坏不可剥夺的条件的核心就是让当前线程自己主动释放占有的资源,关于这一点,synchronized 是做不到的,我们可以使用 java.util.concurrent 包下的 Lock 来解决。此时,我们需要将 TansferAccount 类的代码修改成类似如下所示。


public class TansferAccount{    private Lock thisLock = new ReentrantLock();    private Lock targetLock = new ReentrantLock();    //账户的余额    private Integer balance;    //转账操作    public void transfer(TansferAccount target, Integer transferMoney){        boolean isThisLock = thisLock.tryLock();        if(isThisLock){            try{                boolean isTargetLock = targetLock.tryLock();                if(isTargetLock){                    try{                         if(this.balance >= transferMoney){                            this.balance -= transferMoney;                            target.balance += transferMoney;                        }                       }finally{                        targetLock.unlock                    }                }            }finally{                thisLock.unlock();            }        }    }}
复制代码


其中 Lock 中有两个 tryLock 方法,分别如下所示。


  • tryLock()方法


tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回 true,如果获取失败(即锁已被其他线程获取),则返回 false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。


  • tryLock(long time, TimeUnit unit)方法


tryLock(long time, TimeUnit unit)方法和 tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回 false。如果一开始拿到锁或者在等待期间内拿到了锁,则返回 true。

破坏请求与保持条件

破坏请求与保持条件,我们可以一次性申请所需要的所有资源,例如在我们完成转账操作的过程中,我们一次性申请账户 A 和账户 B,两个账户都申请成功后,再执行转账的操作。此时,我们需要再创建一个申请资源的类 ResourcesRequester,这个类的作用就是申请资源和释放资源。同时,TansferAccount 类中需要持有一个 ResourcesRequester 类的单例对象,当我们需要执行转账操作时,首先向 ResourcesRequester 同时申请转出账户和转入账户两个资源,申请成功后,再锁定两个资源;当转账操作完成后,释放锁并释放 ResourcesRequester 类申请的转出账户和转入账户资源。


ResourcesRequester 类的代码如下所示。


public class ResourcesRequester{    //存放申请资源的集合    private List<Object> resources = new ArrayList<Object>();    //一次申请所有的资源    public synchronized boolean applyResources(Object source, Object target){        if(resources.contains(source) || resources.contains(target)){            return false;        }        resources.add(source);        resources.add(targer);        return true;    }        //释放资源    public synchronized void releaseResources(Object source, Object target){        resources.remove(source);        resources.remove(target);    }}
复制代码


此时,TansferAccount 类的代码如下所示。


public class TansferAccount{    //账户的余额    private Integer balance;    //ResourcesRequester类的单例对象    private ResourcesRequester requester;       //转账操作    public void transfer(TansferAccount target, Integer transferMoney){        //自旋申请转出账户和转入账户,直到成功        while(!requester.applyResources(this, target)){            //循环体为空            ;        }        try{            //对转出账户加锁            synchronized(this){                //对转入账户加锁                synchronized(target){                    if(this.balance >= transferMoney){                        this.balance -= transferMoney;                        target.balance += transferMoney;                    }                   }            }        }finally{            //最后释放账户资源            requester.releaseResources(this, target);        }
}}
复制代码

破坏循环等待条件

破坏循环等待条件,则可以通过对资源排序,按照一定的顺序来申请资源,然后按照顺序来锁定资源,可以有效的避免死锁。


例如,在我们的转账操作中,往往每个账户都会有一个唯一的 id 值,我们在锁定账户资源时,可以按照 id 值从小到大的顺序来申请账户资源,并按照 id 从小到大的顺序来锁定账户,此时,程序就不会再进行循环等待了。


程序代码如下所示。


public class TansferAccount{    //账户的id    private Integer id;    //账户的余额    private Integer balance;    //转账操作    public void transfer(TansferAccount target, Integer transferMoney){        TansferAccount beforeAccount = this;        TansferAccount afterAccount = target;        if(this.id > target.id){            beforeAccount = target;            afterAccount = this;        }        //对转出账户加锁        synchronized(beforeAccount){            //对转入账户加锁            synchronized(afterAccount){                if(this.balance >= transferMoney){                    this.balance -= transferMoney;                    target.balance += transferMoney;                }               }        }    }}
复制代码

总结

在并发编程中,使用细粒度锁来锁定多个资源时,要时刻注意死锁的问题。另外,避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的线程申请资源必须以一定的顺序来操作进而避免死锁。

写在最后

最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。



发布于: 2022 年 05 月 26 日阅读数: 76
用户头像

冰河

关注

公众号:冰河技术 2020.05.29 加入

互联网资深技术专家,《深入理解分布式事务:原理与实战》,《海量数据处理与大数据技术实战》和《MySQL技术大全:开发、优化与运维实战》作者,mykit-data与mykit-transaction-message框架作者。【冰河技术】作者。

评论 (1 条评论)

发布
用户头像
原创不易,小伙伴们给个一键三连可好?
2022 年 05 月 26 日 10:16
回复
没有更多了
【高并发】优化加锁方式时竟然死锁了!!_并发编程_冰河_InfoQ写作社区