不点蓝字,我们哪来故事
今天是雨天,淅淅沥沥,但依然浇不灭学习 Java 的热情。
今天谈一谈死锁,那还是从最经典的例子:转账开始说起。
程序即生活,程序的世界是生活的映射,但是人类是很智能的,轻轻松松处理任何复杂的问题,以至于有些细节我们都忽略不计。
但在程序的世界,任何一个细节都是代码构建的,下面我们来体会一下古人的转账如何体现在程序世界。
转账中的死锁
在古代,没有信息化,账户的存在真的就是一个账本,每个账户都有一个账本,这些账本统一放在文件架上。
掌柜在给我们做转账的时候,要去文件架上同时拿到转出账本和转入账本,然后才能做转账,掌柜在拿账本的时候,可能遇到下面三种情况:
- 文件架上恰好有转出账本和转入账本,那就同时拿走;
- 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上已有的账本拿到手,同时等着别的柜员把另外一个账本送回来;
- 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
上面这个过程在编程的世界如何实现?
其实用两把锁就实现了,转出账本一把,转入账本一把。
如下图,在 transfer() 方法中,我们首先尝试锁定转出账户 ,再尝试锁定转入账户,只有当两者都成功时,才执行转账操作。
用代码实现也很简单:
代码语言:javascript复制class Account {
private int balance;
void transfer(Account target,int amt) {
synchronize(this) {
synchronize(target) {
if (this.balance > amt ) {
this.balance -= amt;
target.balance = amt;
}
}
}
}
}
但是天下没有免费的午餐,这是种很典型的死锁写法,哈哈,什么情况下会产生死锁呢?
如果客户找柜员张三做个转账业务:账户A 转 账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转 账户 A 100元。
于是张三和李四同时都去文件架上拿账本,这时候可能凑巧张三拿到了账本 A,李四拿到了账本 B。
张三拿到 A 后就等着 账本 B (账本 B 被李四拿走了),而李四拿到账本 B 后就等着账本 A (账本A被张三拿走了),他们要等多久呢?
他们会永远等下去 ... 因为张三不会把账本送回去,李四也不会把账本送回去。
那这就是编程领域的死锁了。
如果给死锁一个专业的定义,就是:
一组互相竞争资源的线程因互相等待,导致“永久阻塞”的现象。
死锁发生的条件
既然编程中用到锁的地方,有很有可能发生死锁,那么是不是可以总结一个通用的产生死锁的条件。
有牛人已经准备好了,以下四个条件同时出现时,才会产生死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用
- 占有且等待,线程 T1 取得共享资源 X 在等待资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待 线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源。
只要破坏其中一个,就可以避免死锁发生。
如何避免死锁
(1)首先互斥这个条件是没法破坏的,因为我们设计锁的初衷为的就是互斥。
(2)占有且等待,我们可以一次性申请所有资源,也就是一次性占有资源 X 和 资源 Y 。
在程序中,我们可以用一个公有的单例的角色来管理资源,它的作用就是同时申请资源和同时释放资源。
(3)不可抢占,占有部分资源的线程进一步申请其他资源时,如果申请不到,就把自己占有的资源释放掉
在 Java 中,synchronize 是做不到主动释放资源的,因为在申请资源的时候,如果申请不到,线程就直接进入阻塞状态了。
但是,Lock 是可以轻松解决这个问题的。
(4)循环等待,可以把资源排序,按照顺序占有,这样线性化之后,自然就不存在循环了。
循环等待这个条件,我们可以在申请资源之前,就把资源排好序,有序申请,即可。
如何查看死锁
如果不可避免要用到多个锁,并且可能已经产生死锁了,我们要如何检测?
首先可以使用 jps 或者系统的 ps 命令,确定进程的 id
然后,使用 jstack 获取线程栈:
代码语言:javascript复制${JAVA_HOME}binjstack pid
可以看到两个进程,互相在等待对方的锁id,如果是简单的情形,还会直接显示有死锁的情况。
最后
非必要情况,尽量不使用多个锁,并且有需要时,才持有锁,否则即使是非常精通的工程师,也会掉坑里去。嵌套的 synchronize 或 lock 非常容易出问题。
如果非要必须使用多个锁,尽量设计好锁的获取顺序。
使用带超时的方法,为程序带来更多的可控性。