我在上篇文章曾经提到,锁的本质是串行化,如果覆盖的范围太大,会导致程序的性能低下。
为了提升性能,我们用了细粒度锁,但这又带来了死锁问题。
如何解决死锁问题,就是程序员价值所在。
如何规避死锁
说实话,大部分情况下,你不需要考虑死锁问题。因为只有在并发量很大的时候,死锁才会出现。
那该怎么解决呢?很简单,重启应用就行。
然而,问题来了。既然是在高并发场景下,才会出现死锁。那这不就意味着,一旦出现死锁,无论重启多少次,程序也没法运行?
因为只要并发量一大,你就得重启应用,但原来的业务没处理完,现在得接着处理呀。结果就是,等重启完了,并发量再次飙升,死锁再次出现,你又得重启应用。如此循环往复,根本看不到头。
因此,想要解决死锁,唯一可行的办法是:规避死锁,不让死锁出现。
如何规避死锁?这个问题,早在 1971 年,就有位叫 Coffman 的大神提出了解决思路。
死锁的发生要同时满足四个条件。换句话说,我们只要破坏其中一个,就可以避免死锁。这四个条件分别是:
- 互斥:在同一时刻,一个资源只能由一个线程操作;
- 占有且等待:线程占有了一个资源,在申请另一个资源的时候,不释放自己占有的资源;
- 不可抢占:资源被某个线程占有后,其它线程不能强行抢占;
- 循环等待:线程一占有了一个资源,线程二占有了另一个资源,它们都在等对方的资源。
这四个条件必须同时满足,才会发生死锁。我们只要想办法破坏其中一个,就能避免死锁的出现。
既然如此,该破坏哪个条件呢?
首先,破坏条件一互斥,这是没法做到的,毕竟用锁就是为了互斥。
然后,破坏条件三不可抢占,现在也做不到。因为这需要线程主动释放占有的资源,但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 行代码用来排序,其它的基本没变。
这样看来,对转账业务来说,按顺序加锁算是一个低成本、高回报的方案了。
写在最后
想要解决死锁,只能重启应用,但这不一定管用。所以,我们只能在写代码的时候,想办法规避死锁。
一般情况下,有两个办法可以避免死锁:
- 一次性锁定所有资源;
- 按顺序加锁;
看到这儿,相信你已经感受到了并发编程的魅力。每一种方案都要付出相应的代价,我们得在正确性、性能、代码复杂度之间,不断地权衡,从中选出合适的方案。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。