大家都知道,在并发情况下对两个账户进行转账操作可能会产生死锁,可能出现死锁的原因是,并发情况下对两个账户的操作无法保证其执行顺序。
1. 并发问题描述
假如现在执行下面的操:
线程一执行的是:【账户A】给【账户B】转账
线程二执行的是:【账户B】给【账户A】转账
如果两个转账动作同时执行,则会出现线程一会请求对【账户B】进行加锁
,线程二会请求对【账户A】进行加锁
由于此时的【账户A】已由线程一进行锁定,【账户B】已由线程二进行锁定 此时就会产生死锁问题。接下来分析一下产生死锁的原因,以及如何避免死锁。
2. 如何避免死锁
有个叫 Coffman 的牛人总结过一条经验,只有当以下四个条件同时发生,才会出现死锁,所以只要打破其中一个条件,就可以避免死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用
- 占有且等待,线程 A 获取到资源 X,在等待资源 Y 的时候,不释放资源 X
- 不可抢占,其他线程不能强行抢占线程 A 占有的资源
- 循环等待,线程 A 等待线程 B 占有的资源,线程 B 等待线程 A 的资源
首先,互斥这个条件是没法破坏的,因为锁存在的目的就是互斥,对于剩下的三个条件都可破坏。
2.1 破坏占有且等待
对于占有且等待,可以同时获取要使用的多个资源锁X和Y,这样就不会存在取得了X还要等待Y。这种方式只在需要获取的资源锁较少的情况下使用,如果要获取的资源锁很多(例如10个),就不太可行。代码实现时,我们通过增加一个 Allocator 账号管理员对象,并且将其设置为单例,每次进行转账的时候,我们都先通过 Allocator 分配账号,如果分配账号成功,则进行转账,如果失败则重新获取,可以设置一个失败次数或是超时时间,达到失败次数或超时时间则转账失败。如下是代码实现,
package com.zhouj.endless.diy;
import java.util.ArrayList;
import java.util.List;
/**
* @author 天启
* @date 2020-02-19 16:31
* @description 账户分配
*/
public class Allocator {
private static class InstanceHolder {
static Allocator instance = new Allocator();
}
public static Allocator getInstance() {
return InstanceHolder.instance;
}
/**
* 上锁的账户列表
*/
private List<Account> lockAccountList = new ArrayList<>();
/**
* 申请分配账户
*
* @param from 从这个账户转钱
* @param to 转钱到这个账户
*/
public synchronized void apply(Account from, Account to) {
while (lockAccountList.contains(from) || lockAccountList.contains(to)) {
// 如果两个账户中,只要有一个账户上锁了,则申请失败,进入循环等待
try {
// 阻塞当前线程,等待通知
this.wait();
} catch (InterruptedException e) {
}
}
lockAccountList.add(from);
lockAccountList.add(to);
}
/**
* 释放账户锁
*
* @param from 从这个账户转钱
* @param to 转钱到这个账户
*/
public synchronized void free(Account from, Account to) {
lockAccountList.remove(from);
lockAccountList.remove(to);
// 通知所有线程,让其再次获取锁
this.notifyAll();
}
private Allocator() {
}
}
2.2 破坏不可抢占条件
对于不可抢占,可以获取了部分资源,再进一步获取其他资源时如果获取不到时,把已经获取的资源一起释放掉。破坏不可抢占条件,需要线程在获取不到锁的情况下主动释放它拥有的资源。当我们使用synchronized的时候,线程是没有办法主动释放它占有的资源的。因为,synchronized在申请不到资源时,会使线程直接进入阻塞状态,而线程进入了阻塞状态就不能主动释放占有的资源。java.util.concurrent中的Lock接口,提供了如下三种设计思想都可以解决死锁的不可抢占条件:
- 能够响应中断:线程处于阻塞状态时可以接收中断信号。我们便可以给阻塞的线程发送中断信号,唤醒线程,线程便有机会释放它曾经拥有的锁。这样便可破坏不可抢占条件。
- 支持超时:如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
- 非阻塞地获取锁:如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也可以破坏不可抢占条件。
对应方法
// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();
使用非阻塞的获取锁实现:
package com.zhouj.endless.diy;
/**
* @author 天启
* @date 2020-02-19 17:07
* @description 可抢占
*/
public class Preemptible {
public boolean transferMoney(Account fromAcct, Account toAcct) {
while (true) {
// 使用tryLock()获取锁
if (fromAcct.lock.tryLock()) {
try {
// 使用tryLock()获取锁
if (toAcct.lock.tryLock()) {
return true;
}
} finally {
// 释放前面获取到的锁
fromAcct.lock.unlock();
}
}
}
}
}
2.3 破坏循环等待条件
对于循环等待,可以将需要获取的锁资源排序,按照顺序获取,这样就不会多个线程交叉获取相同的资源导致死锁,而是在获取相同的资源时就等待,直到它释放。比如根据账号的主键 id 进行排序,从小到大的获取锁,这样就可以避免循环等待。
package com.zhouj.endless.diy;
/**
* @author 天启
* @date 2020-02-19 16:31
* @description 账户排序
*/
public class Account {
private Integer id;
private int balance;
/**
* 转账
*
* @param target 目标
* @param amt 金额
*/
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;
}
}
}
}
}