并发编程实战(二) --- 如何避免死锁
死锁了怎么办?
前面说使用Account.class作为转账的互斥锁,这种情况下所有的操作都串行化,性能太差,这个时候需要提升性能,肯定不能使用这种方案.
现实化转账问题
假设某个账户的所有操作都在账本中,那转账操作需要两个账户,这个时候有三种情况:
- 两个账户的账本都存在,这个时候一起拿走
- 两个账户的账本只存在其一,先拿一个,等待其他人把剩余一本送过来
- 两个账户的账本都没有,等待其他人把两个账本都送回来
上面的逻辑其实就是使用两把锁实现,图形化:
代码实现如下:
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转账100给B,由张三做这个转账业务;B转账给A100元,由李四完成这个转账业务,这个时候张三拿到A的账户本,同一时刻李四拿到B的账户本,这个时候张三等待李四的B账户本,李四等待张三的A账户本,两人都不会送回来,就产生的死等,死等就是变成领域的死锁.死锁是指一组互相竞争资源的线程因互相等待,导致永久阻塞的现象
预防死锁
死锁一旦产生是没有办法解决的,只能重启应用. 所以解决死锁的最好办法就是避免死锁,如何避免死锁,那就要从产生死锁的条件入手:
- 互斥,共享资源X和Y只能被一个线程占用
- 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X
- 不可抢占,其他线程不能强行抢占T1占有的资源
- 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待
四个添加同时满足就会产生死锁,只要能破坏掉有一个条件,死锁就不会产生.共享资源是没有办法破坏,也就是互斥是没有办法解决,锁的目的就是为了互斥.
- 占有且等待: 一次性申请所有的资源就可以解决
- 不可抢占: 占用部分资源后获取不到后续资源就释放掉前面获取的资源,就可以解决
- 循环等待: 按照序号申请资源来预防,也就是说给每个资源标记一个序号,没次加锁的时候都先获取资源序号小的,这样有顺序就不会出现循环等待
破坏占用且等待
只需要同时申请资源就可以,同时申请这个操作是一个临界区,需要一个Java类来管理这个临界区,也就是定义一个角色,这个角色的两个重要功能就是同时申请资源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)
}
}
}
破坏不可抢占条件
这个的核心是释放掉已占有的资源,这个synchronized是做不到,因为synchronized申请资源的时候如果申请不到就直接进入阻塞,阻塞状态啥也干不了.
这个时候就需要java.util.concurrent包下提供的Lock,这个等学到的时候再总结.
破坏循环等待条件
这个就需要一个id值了,保护加锁的顺序是从序号小的资源开始.
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this; ①
Account right = target; ②
// left是序号小的资源锁
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
保证课加锁的顺序,就不会出现循环等待了.
编程世界其实是和现实世界有所关联的,编程不就是为了解决现实生活中的问题吗? 上面的解决死锁的两个方案,那个更好呢? 其实破坏循环等待条件的成本要比破坏占有且等待的成本要低,后者也锁定了所有账户并且使用了死循环.相对来说,前者的成本低,但是不是绝对的,只是转账的这个例子中,破坏循环等待的成本比较低.