1. 问题的由来
上一篇用Account.class作为互斥锁,但是所有账户的转账都是串行化,性能太差。比如A给B转账,C给D转账同时进行,这是现实允许的,但是现在都串行化了。
2. 用两把锁解决并发
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;
}
}
}
}
}
以上可以解决A给B转账,C给D转账的并发问题。
3. 使用两把锁带来的问题
使用细粒度锁提高性能的同时,有可能带来死锁问题。
死锁:一组互相竞争资源因互相等待,导致永久阻塞的现象。
死锁是如何发生的:
- 假设线程 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;
}
}
}
}
}
资源分配图:
4. 如何预防死锁
Coffman总结的四个条件都发生才会出现死锁:
- 互斥,共享资源x和y只能被一个线程占用;
- 占有且等待,线程T1已经取得x资源,在等待y过程中,不释放x;
- 不可抢占,其他线程不可强行抢占T1占有的资源;
- 循环等待,线程T1等待线程T2占有的资源,T2等待T1占有的资源。
只要破坏其中一个就可以避免死锁。
解决办法
- 互斥无法破坏,因为锁就是互斥的;
- 对于占有且等待,一次性申请所有资源;
- 对于不可抢占,占有资源的线程申请其他资源时,申请不到,就主动释放占有的资源;
- 对于循环等待,可以按序申请资源预防,比如先申请资源序号小的,再申请资源序号大的。
4.1 破坏占有且等待条件
建一个单例类来管理锁,所有的转账都向这个单例申请锁. 如以下单例类有两个重要方法:同时申请资源 apply() 和同时释放资源 free()。
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);
}
}
}
4.2 破坏不可抢占的条件
核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
java在语言层面没有解决问题,在SDK层面解决,在java.util.concurrent的lock解决。
4.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;
}
}
}
}
}
5. 总结
遇到并发问题可以模拟现实的世界来理解和解决,不过要注意现实世界和计算机领域的不同,容易忽略一些细节。
预防死锁的三中方法中,也需要评估哪种成本最低。
6. 思考
我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));
这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?