极客时间已完结课程限时免费阅读

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

05 | 一不小心就死锁了,怎么办?-极客时间

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

讲述:王宝令

时长12:33大小11.47M

在上一篇文章中,我们用 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 的锁(对于 T1,this 是账户 A),而 T2 获得了账户 B 的锁(对于 T2,this 是账户 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<Object> als =
new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(
Object from, Object to){
if(als.contains(from) ||
als.contains(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))
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}

2. 破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
你可能会质疑,“Java 作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。关于这个话题,咱们后面会详细讲。

3. 破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 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;
}
}
}
}
}

总结

当我们在编程世界里遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。
但是现实世界的模型有些细节往往会被我们忽视。因为在现实世界里,人太智能了,以致有些细节实在是显得太不重要了。在转账的模型中,我们为什么会忽视死锁问题呢?原因主要是在现实世界,我们会交流,并且会很智能地交流。而编程世界里,两个线程是不会智能地交流的。所以在利用现实模型建模的时候,我们还要仔细对比现实世界和编程世界里的各角色之间的差异。
我们今天这一篇文章主要讲了用细粒度锁来锁定多个资源时,要注意死锁的问题。这个就需要你能把它强化为一个思维定势,遇到这种场景,马上想到可能存在死锁问题。当你知道风险之后,才有机会谈如何预防和避免,因此,识别出风险很重要
预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转账那个例子,我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,不过好在 apply() 这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
所以我们在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案

课后思考

我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 73

提建议

上一篇
04 | 互斥锁(下):如何用一把锁保护多个资源?
下一篇
06 | 用“等待-通知”机制优化循环等待
unpreview
 写留言

精选留言(236)

  • 捞鱼的搬砖奇
    2019-03-09
    synchronized(Account.class) 锁了Account类相关的所有操作。相当于文中说的包场了,只要与Account有关联,通通需要等待当前线程操作完成。while死循环的方式只锁定了当前操作的两个相关的对象。两种影响到的范围不同。

    作者回复: 还真是这样啊!

    共 16 条评论
    150
  • Tony Du
    2019-03-09
    while循环是不是应该有个timeout,避免一直阻塞下去?

    作者回复: 你考虑的很周到!👍 加超时在实际项目中非常重要!

    共 5 条评论
    113
  • 张立华
    2019-03-12
    之前遇到死锁,我就是用资源id的从小到大的顺序去申请锁解决的

    作者回复: 这个方案最简单

    共 6 条评论
    98
  • Demon.Lee
    2019-03-09
    while(actr.apply(this, target)); --> while(!actr.apply(this, target)); 我感觉应该是这样,老师,我理解错了?

    作者回复: 你发现了个大bug!感谢感谢!!!我这就修改一下啊

    共 5 条评论
    76
  • 几字凉了秋丶
    2019-03-10
    老师,请问一下,在实际的开发中,account对象应该是从数据库中查询出来的吧,假如A转B,C转B一起执行,那B的account对象如何保证是同一个对象,不太理解。。。

    作者回复: 实际开发中都是用数据库事务+乐观锁的方式解决的。这个就是个例子,为了说明死锁是怎么回事,以及死锁问题怎么解决。

    共 4 条评论
    60
  • 别皱眉
    2019-03-14
    @阿官 我来回答下你的问题 以下是阿官的问题 ------------------------------------------------------- 老师,在破坏占用且等待的案例中,为何申请完两个账户的资源后还需要再分别锁定this和target账户呢? ------------------------------------------------------- 因为还存在其他业务啊 比如客户取款 这个时候也是对全局变量balance做操作 如果不加锁 并发情况下会出问题 老师你看我说的对吗😄😄
    展开

    作者回复: 你说到我心里了😃😃😃

    共 15 条评论
    46
  • 李可威
    2019-03-17
    老师为什么按序申请资源就可以破坏循环等待条件呢?这点没有看懂求解答

    作者回复: 循环等待,一定是A->B->C->...->N->A形成环状。 如果按需申请,是不允许N->A出现的,只能N->P。没有环状,也就不会死锁了。

    共 6 条评论
    37
  • Bright丶
    2019-04-26
    老师,感觉下面的代码也能避免死锁,并且能实现功能: void transfer(Account target, int amt){ boolean isTransfer = false; // 锁定转出账户 synchronized(this){ if (this.balance > amt) { this.balance -= amt; isTransfer = true; } if (!isTransfer) { return; } // 锁定转入账户 synchronized(target){ target.balance += amt; } } 反映到现实中的场景:服务员A拿到账本1先判断余额够不够,够的话先扣款,再等待其他人操作完账本2,才增加它的额度。 但是这样转账和到账就存在一个时差,现实生活中也是这样,转账不会立马到账,短信提醒24小时内到账,所谓的最终一致性。 老师帮忙看看这样实现会不会有啥其他问题?
    展开

    作者回复: 实际工作中也有这么做的,只不过是把转入操作放到mq里面,mq消费失败会重试,所以能保证最终一致性。

    共 20 条评论
    30
  • 轻歌赋
    2019-03-09
    存在性能差距,虽然申请的时候加锁导致单线程访问,但是hash判断和赋值时间复杂度低,而在锁中执行业务代码时间长很多。 申请的时候单线程,但是执行的时候就可以多线程了,这里性能提升比较明显 想问问老师,如何判断多线程的阻塞导致的问题呢?有什么工具吗

    作者回复: 可以用top命令查看Java线程的cpu利用率,用jstack来dump线程。开发环境可以用 java visualvm查看线程执行情况

    26
  • 王二宝
    2019-08-28
    最常见的就是B转A的同时,A转账给B,那么先锁B再锁A,但是,另一个线程是先锁A再锁B,然而,如果两个线程同时执行,那么就是出现死锁的情况,线程T1锁了A请求锁B,此时线程T2锁了B请求锁A,都在等着对方释放锁,然而自己都不会释放锁,故死锁。 最简单的办法,就是无论哪个线程执行的时候,都按照顺序加锁,即按照A和B的id大小来加锁,这样,无论哪个线程执行的时候,都会先加锁A,再加锁B,A被加锁,则等待释放。这样就不会被死锁了。
    展开

    作者回复: 👍

    共 2 条评论
    21
  • 邋遢的流浪剑客
    2019-03-09
    思考题的话希望老师能够过后给出一个比较标准的答案,毕竟大家的留言中说法各不相同很难去判断答案的对错

    作者回复: 这一部分的最后一章,要不就给答案吧

    18
  • 铿然
    2019-09-18
    如果有人没有理解透彻,看着例子来写生产代码,那么并发情况下会出问题,如果并发小,一直没出问题,会以为代码没问题,真正出问题的时候都分析不出来哪里错了。 并发情况下,这些代码的加锁对象并不是同一个,所以是有问题的。-- 不同的线程都获取到了账户A的实例对象,但这些实例对象不是同一个。 希望所有读者都能看透这个,多线程对账户A,B实例加锁时一定要保证是同一个实例对象,就像在数据库表中通过select * from account where account_id = ? for update 加锁一样,锁住的是同一条账户记录。
    展开
    共 4 条评论
    16
  • aguan(^・ェ・^)
    2019-03-14
    老师,在破坏占用且等待的案例中,为何申请完两个账户的资源后还需要再分别锁定this和target账户呢?

    作者回复: 为了保险而已,单纯这个例子是不需要的,如果还有取款操作就需要了

    共 3 条评论
    16
  • gogo
    2019-03-10
    看了老师的讲解学到了很多,联想了下实际转账业务,应该是数据库来实现的,假如有账户表account,利用mysql的悲观锁select ...for update对a,b两条数据锁定,这时也有可能发生死锁,按照您讲到的第三种破坏循环等待的方式,按照id的大小顺序依次锁定。我这样理解的对吗?

    作者回复: 是的,就是id的次序。

    共 6 条评论
    14
  • 陈华
    2019-03-14
    对于第三点,按资源顺序来锁就能打破循环等待有疑问。 例如:账户 1 向 账户 3 转账 同时 账户 3 向 账户 5 转账 即使按资源顺序来锁,也是起不了啥作用吧!?,

    作者回复: 能起作用,这俩操作不会死锁

    共 7 条评论
    12
  • Howie
    2019-03-09
    while 循环就是一个自旋锁机制吧,自旋锁的话要关注它的循环时间,不能一直循环下去,不然会浪费 cpu 资源。

    作者回复: 自旋锁在JVM里是一种特殊的锁机制,自诩不会阻塞线程的。咱们这个其实还是会阻塞线程的。不过原理都一样,你这样理解也没问题。

    12
  • GP
    2019-03-13
    问下,上节最后说到,不能用可变对象做锁,这里为何又synchronized(left)?

    作者回复: 保护的是对象里面的成员,这俩对象变也只能是里面成员变,相对于里面的成员来说,这俩对象是永远不会变的。你可以这样理解。不是绝对不能用于可变对象,只是一条最佳实践。

    共 2 条评论
    11
  • 长眉_张永
    2019-03-13
    关键是如何找到最合适的锁的力度。

    作者回复: 是啊,所以知识只是知识,不是能力

    11
  • Nero.t.Kang
    2019-03-11
    虽然看起来 while(!actr.apply(this, target));只是锁住了两个对象,但是因为actr是一个单例的对象,这个方法在执行的时候也需要锁住actr,在多线程状态下也相当于是串行化了,那么这和加上一个Account.class的类锁的串行化有什么区别吗?请老师赐教,谢谢。

    作者回复: 有区别,如果转账操作很耗时,那么a-b,c-d能并行还是有价值的

    共 5 条评论
    11
  • 撒旦的堕落
    2019-08-21
    虽然上面两种锁的方式都是串行化了,但是具体还是有一点区别的: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的方式的优势越明显。 但是有一个不明白的地方,对于已经通过apply获取锁的线程,感觉没有必要对转账的账户锁定了,因为其他的线程想对相同的账户进行转账 调用apply方式是没法返回true的(已经有线程对list加入账户了)
    展开
    共 2 条评论
    9