gitbook/Java并发编程实战/docs/85001.md
2022-09-03 22:05:03 +08:00

230 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 05 | 一不小心就死锁了,怎么办?
在上一篇文章中我们用Account.class作为互斥锁来解决银行业务里面的转账问题虽然这个方案不存在并发问题但是所有账户的转账操作都是串行的例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的但是在这个方案里却被串行化了这样的话性能太差。
试想互联网支付盛行的当下8亿网民每人每天一笔交易每天就是8亿笔交易每笔交易都对应着一次转账操作8亿笔交易就是8亿次转账操作也就是说平均到每秒就是近1万次转账操作若所有的转账操作都串行性能完全不能接受。
那下面我们就尝试着把性能提升一下。
## 向现实世界要答案
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
1. 文件架上恰好有转出账本和转入账本,那就同时拿走;
2. 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
3. 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
上面这个过程在编程的世界里怎么实现呢其实用两把锁就实现了转出账本一把转入账本另一把。在transfer()方法内部我们首先尝试锁定转出账户this先把转出账本拿到手然后尝试锁定转入账户target再把转入账本拿到手只有当两者都成功时才执行转账操作。这个逻辑可以图形化为下图这个样子。
![](https://static001.geekbang.org/resource/image/cb/55/cb18e672732ab76fc61d60bdf66bf855.png)
两个转账操作并行示意图
而至于详细的代码实现如下所示。经过这样的优化后账户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送回去。我们姑且称为死等吧。
![](https://static001.geekbang.org/resource/image/f2/88/f293dc0d92b7c8255bd0bc790fc2a088.png)
转账业务中的“死等”
现实世界里的死等,就是编程领域的死锁了。**死锁**的一个比较专业的定义是:**一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象**。
上面转账的代码是怎么发生死锁的呢我们假设线程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;
}
}
}
}
}
```
关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。
![](https://static001.geekbang.org/resource/image/82/1c/829d69c7d32c3ad1b89d89fc56017d1c.png)
转账发生死锁时的资源分配图
## 如何预防死锁
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
那如何避免死锁呢要避免死锁就需要分析死锁发生的条件有个叫Coffman的牛人早就总结过了只有以下这四个条件都发生时才会出现死锁
1. 互斥共享资源X和Y只能被一个线程占用
2. 占有且等待线程T1已经取得共享资源X在等待共享资源Y的时候不释放共享资源X
3. 不可抢占其他线程不能强行抢占线程T1占有的资源
4. 循环等待线程T1等待线程T2占有的资源线程T2等待线程T1占有的资源就是循环等待。
反过来分析,**也就是说只要我们破坏其中一个,就可以成功避免死锁的发生**。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。
### 1\. 破坏占用且等待条件
从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
可以增加一个账本管理员然后只允许账本管理员从文件架上拿账本也就是说柜员不能直接在文件架上拿账本必须通过账本管理员才能拿到想要的账本。例如张三同时申请账本A和B账本管理员如果发现文件架上只有账本A这个时候账本管理员是不会把账本A拿下来给张三的只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有资源”。
![](https://static001.geekbang.org/resource/image/27/db/273af8c2ee60bd659f18673d2af005db.png)
通过账本管理员拿账本
对应到编程领域“同时申请”这个操作是一个临界区我们也需要一个角色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)有没有性能优势呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。