java并发-锁顺序死锁有关问题

java并发-锁顺序死锁问题

        初次接触死锁的概念是大学的一门课程《操作系统原理》中描述的“哲学家进餐”问题。操作系统中,由于各个进程共享系统资源而可能出现死锁问题。同样java多线程环境下,也存在资源共享导致的死锁问题。当一组java线程发生死锁时,程序有可能就此阻塞,而无法正常结束。Java应用程序中,我们使用加锁机制来确保线程的安全,但是如果过度地使用加锁,可能导致锁顺序死锁问题。

       整理《java并发编程实践》一书中锁顺序死锁的分析,及代码示例验证如下。

    锁顺序死锁

       程序中需要同时获取多个锁的时候,加锁的顺序不同,可能会导致死锁问题。一种简单的锁顺序死锁的代码如下:

public class LeftRightDeadLock {
	private final Object left = new Object();
	private final Object right = new Object();
	
	public void leftRight(){
		synchronized (left) {
			synchronized (right) {
				//doSomethoing();
			}
		}
	}
	
	public void rightLeft(){
		synchronized (right) {
			synchronized (left) {
				//doSomethoing();
			}
		}
	}
}
       以上代码存在死锁风险:当一个线程调用leftRight方法获取了left锁后,而另一个线程同时调用了rightLeft方法获取了right锁,那么此时他们都会陷入等待另一个锁的阻塞过程,而导致死锁。因为他们的操作是交错进行的,而加在相同对象的锁的顺序也是交错的,这就容易导致死锁的发生。破解的方法就是:所有的线程以固定的顺序来获取相同的锁,那么程序中就不会出现锁顺序死锁问题。

    动态的锁顺序死锁

       上述加锁是对固定的成员变量的锁,可以控制加锁的顺序;还有一种是在方法中针对方法传递的参数的加锁行为,那么就会存在动态的顺序死锁。根据java并发编程实践给出的片段的代码,它描述的场景是将资金从一个账户转入另一个账户,在开始转账之前,先要同时获取这两个账户对象的锁,以确保通过原子操作来更新两个账户中的余额。这个场景的抽象需要一个代表金额的Amount类,一个Account账户类,一个转账方法。编写代码如下:

       顶层金额抽象类Amount,实现comparable以便转账之前的数据校验操作;它的子类DallarAmount。

/**
 * 顶层金额抽象类:包含币种和余额两个属性
 * @author bh
 */
public abstract class Amount implements Comparable<Amount>{
	public abstract BigDecimal getBalance();
	public abstract void setBalance(BigDecimal balance);
	public abstract Currency getCurrency();
	
	@Override
	public int compareTo(Amount o) {
		if(o==null){
			throw new NullPointerException("null arg.");
		}
		
		if(this.getBalance()==null||o.getBalance()==null){
			throw new NullPointerException("null arg.");
		}
		
		return this.getBalance().compareTo(o.getBalance());
	}
}
/**
 * 美元类
 * @author bh
 */
public class DollarAmount extends Amount{
	private BigDecimal balance;
	private Currency currency = Currency.getInstance(Locale.US);
	
	public DollarAmount(BigDecimal balance){
		this.balance = balance;
	}
	
	public BigDecimal getBalance() {
		return balance;
	}

	public void setAmount(BigDecimal amount) {
		this.balance = amount;
	}

	public Currency getCurrency() {
		return currency;
	}

	public void setBalance(BigDecimal balance) {
		this.balance = balance;
	}
}
       Account类,提供账户的借贷方法。

/**
 * 账户类:包括金额,借贷及获取余额方法
 * @author bh
 */
public class Account {
	private String id;
	private Amount balance;
	
	public Account(Amount amount,String id){
		this.balance = amount;
		this.id = id;
	}
	
	public Amount getBalance() {
		return balance;
	}

	public void setBalance(Amount balance) {
		this.balance = balance;
	}

	public void debit(Amount amount){
		if(this.balance==null||amount==null||amount.getBalance()==null){
			return;
		}
		
		System.out.println(id+" 支出金额"+amount.getBalance());
		
		//修正账户余额:本账户减去借方金额
		BigDecimal current = this.balance.getBalance();
		BigDecimal now = current.subtract(amount.getBalance());
		this.balance.setBalance(now);
	}
	
	public void credit(Amount amount){
		if(this.balance==null||amount==null||amount.getBalance()==null){
			return;
		}
		
		System.out.println(id+" 收入金额"+amount.getBalance());
		
		//修正账户余额:本账户加贷方金额
		BigDecimal current = this.balance.getBalance();
		BigDecimal now = current.add(amount.getBalance());
		this.balance.setBalance(now);
	}

	public String getId() {
		return id;
	}

	public void setId(String id) {
		this.id = id;
	}
}
        转账功能提供者,在执行转账之前获取两个账户的锁:

/**
 * 同时获取两个账户的锁
 * @author bh
 */
public class AccountHelper {
	public void transferMoney(final Account fromAcct,
			final Account toAcct,
			final Amount amount){
		//参数校验
		if(fromAcct==null||toAcct==null||amount==null){
			throw new IllegalArgumentException("null arg.");
		}
		
		//余额校验
		if(fromAcct.getBalance().compareTo(amount)<0){
			throw new IllegalArgumentException(fromAcct.getId()+"账户余额不足");
		}
		
		synchronized (fromAcct) {
			synchronized (toAcct) {
				this.transfer(fromAcct, toAcct, amount);
			}
		}
	}
	
	//transfer对两个账户的操作必须是原子的完成
	private void transfer(final Account fromAcct,
			final Account toAcct,
			final Amount amount){
		System.out.println("Thread "+Thread.currentThread().getName()+" do transfer.");
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
         上述代码看似没有问题,但是它确有死锁的问题,虽然线程看似以相同的加锁方式先对from加锁,再对to账户加锁,但事实上锁的顺序是动态的,取决于传递给这个方法的参数的顺序,而这些参数的顺序又取决于外部输入。编写测试代码,同时启动四个线程交替执行transferMoney方法,而参数传递顺序则以相反的顺序。代码如下:

public class MainTest {
	public static void main(String[] args) {
		Amount amFromAcc = new DollarAmount(new BigDecimal(2000));
		Amount amToAcc = new DollarAmount(new BigDecimal(1000));
		final AccountHelper h = new AccountHelper();
		
		final Account fromAcc = new Account(amFromAcc,"zhang_3");
		final Account toAcc = new Account(amToAcc,"wang_5");
		final Amount amToTran = new DollarAmount(new BigDecimal(1));
		Thread t1 = new Thread(new Runnable(){
			@Override
			public void run() {
				h.transferMoney(fromAcc, toAcc, amToTran);
			}
			
		});
		Thread t4 = new Thread(new Runnable(){
			@Override
			public void run() {
				h.transferMoney(fromAcc, toAcc, amToTran);
			}
		});
		Thread t2 = new Thread(new Runnable(){
			@Override
			public void run() {
				h.transferMoney(toAcc, fromAcc, amToTran);
			}
		});
		Thread t3 = new Thread(new Runnable(){
			@Override
			public void run() {
				h.transferMoney(toAcc, fromAcc, amToTran);
			}
		});
		t1.start();
		t2.start();
		t3.start();
		t4.start();
	}
}
         反复执行该方法,前三次恰巧能够正常运行;但是第四次执行的时候,就遭遇了死锁问题,只有两个线程顺序完成了操作,而另外两个线程陷入死锁状态,这个程序一直无法结束,测试结果如下:

java并发-锁顺序死锁有关问题

      因为我们无法控制参数的顺序,所以必须定义一种固定的锁的顺序,使得程序的锁定顺序不受参数的影响。该书中给出的解决办法是:比较对象的hashCode,以此固定一种顺序,当两个对象拥有相同的哈希值时,再使用加时赛锁固定加锁的顺序。AccountHelper破解动态顺序死锁的代码如下:

/**
 * 根据某种规则设置锁的获取顺序,避免锁顺序死锁问题
 * @author bh
 *
 */
public class AccountHelper {
	//加时赛锁
	private final Object tieLock = new Object();
	
	public void transferMoney(final Account fromAcct,
			final Account toAcct,
			final Amount amount){
		//参数校验
		if(fromAcct==null||toAcct==null||amount==null){
			throw new IllegalArgumentException("null arg.");
		}
		
		//余额校验
		if(fromAcct.getBalance().compareTo(amount)<0){
			throw new IllegalArgumentException(fromAcct.getId()+"账户余额不足");
		}
		
		//根据对象的hash值随机设置加锁顺序
		int fromHash = System.identityHashCode(fromAcct);
		int toHash = System.identityHashCode(toAcct);
		
		if(fromHash<toHash){
			synchronized (fromAcct) {
				synchronized (toAcct) {
					this.transfer(fromAcct, toAcct, amount);
				}
			}
		}else if(fromHash>toHash){
			synchronized (toAcct) {
				synchronized (fromAcct) {
					this.transfer(fromAcct, toAcct, amount);
				}
			}
		}else{
			//碰巧相对时,先获取加时赛锁
			synchronized (tieLock) {
				synchronized (fromAcct) {
					synchronized (toAcct) {
						this.transfer(fromAcct, toAcct, amount);
					}
				}
			}
		}
	}
	
	//transfer对两个账户的操作必须是原子的完成
	private void transfer(final Account fromAcct,
			final Account toAcct,
			final Amount amount){
		System.out.println("Thread "+Thread.currentThread().getName()+" do transfer.");
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}

    实践结论

        书中给出的例子,简洁明了地分析了过度使用锁存在的风险,它给出的例子可能是为了解释这一存在的问题。我不禁想,真正开发过程中,谁会写这样的代码呢?为什么要同时获取多个锁呢?在解决动态锁顺序死锁问题中使用加时赛锁,我完全可以一开始就只用这一个锁来保证转账操作的原子性呀。不过,我不得不承认这段代码扩宽了我的眼界和编程思维:原来还存在这样的问题,还可以用这样的解决办法。
        当验证代码遭遇死锁时,Eclipse的控制台一直显示红色运行状态,我起初觉得挺好玩,但随即又意识到了死锁对应用程序的威胁,如果我们投产的应用遭遇这种情况怎么办呢?这个章节看完,那个卡住的测试程序就是死锁给我最直观的解释。