learn-tech/专栏/Java并发编程实战/05一不小心就死锁了,怎么办?.md
2024-10-16 06:37:41 +08:00

14 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        05 一不小心就死锁了,怎么办?
                        在上一篇文章中我们用Account.class作为互斥锁来解决银行业务里面的转账问题虽然这个方案不存在并发问题但是所有账户的转账操作都是串行的例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的但是在这个方案里却被串行化了这样的话性能太差。

试想互联网支付盛行的当下8亿网民每人每天一笔交易每天就是8亿笔交易每笔交易都对应着一次转账操作8亿笔交易就是8亿次转账操作也就是说平均到每秒就是近1万次转账操作若所有的转账操作都串行性能完全不能接受。

那下面我们就尝试着把性能提升一下。

向现实世界要答案

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

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

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

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

两个转账操作并行示意图

而至于详细的代码实现如下所示。经过这样的优化后账户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送回去。我们姑且称为死等吧。

转账业务中的“死等”

现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

上面转账的代码是怎么发生死锁的呢我们假设线程T1执行账户A转账户B的操作账户A.transfer(账户B)同时线程T2执行账户B转账户A的操作账户B.transfer(账户A)。当T1和T2同时执行完①处的代码时T1获得了账户A的锁对于T1this是账户A而T2获得了账户B的锁对于T2this是账户B。之后T1和T2在执行②处的代码时T1试图获取账户B的锁时发现账户B已经被锁定被T2锁定所以T1开始等待T2则试图获取账户A的锁时发现账户A已经被锁定被T1锁定所以T2也开始等待。于是T1和T2会无期限地等待下去也就是我们所说的死锁了。

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; } } } } }

关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。

转账发生死锁时的资源分配图

如何预防死锁

并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。

那如何避免死锁呢要避免死锁就需要分析死锁发生的条件有个叫Coffman的牛人早就总结过了只有以下这四个条件都发生时才会出现死锁

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

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

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

我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。

  1. 破坏占用且等待条件

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

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

通过账本管理员拿账本

对应到编程领域“同时申请”这个操作是一个临界区我们也需要一个角色Java里面的类来管理这个临界区我们就把这个角色定为Allocator。它有两个重要功能分别是同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例必须是单例只能由一个人来分配资源。当账户Account在执行转账操作的时候首先向Allocator同时申请转出账户和转入账户这两个资源成功后再锁定这两个资源当转账操作执行完释放锁之后我们需通知Allocator同时释放转出账户和转入账户这两个资源。具体的代码实现如下。

class Allocator { private List