一不小心就死锁了,怎么办?

前言

在上篇中的银行转账的例子中, 用Account.class作为互斥锁,虽然能保证并发问题,但是用户A、B、C、D,A转B,B转C是串行的,这是由于用锁Account.class将转账操作串行化了,性能就会很低,在现实生活中这两个转账操作是可以并行处理的,所以需要提升性能。

向现实世界要答案

例如古代,所有的记账都是在账本上操作的,A和B各自有两个账本,A转账给B,需要账员同时看A和B的账本都在不在,只有两个账本都在的话才能转账成功,否则就不能转账,如果只有一本账本,就不能记账,否则会出现一本记了而一本没记的情况。

将上面的转账场景放到编程中如何实现呢?其实用两把锁就可以实现了,两个账本各加一把锁。在transfer()方法内部,给this.balance加一个锁,给target也加一个锁,只有当两个锁都拿到之后才执行this.balance -= amt; target.balance += amt;操作。

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;
        }
      }
    }
  } 
}

一不小心就死锁了,怎么办?_第1张图片

没有免费的午餐

相对于Account.class作为互斥锁,上述的例子锁定的范围就很小了,这样的锁叫做细粒度锁,使用细粒度锁可以提高并发度,是优化性能的一个重要手段。

使用细粒度的锁是有代价的,这个代价就是可能会导致死锁。

例如古代有A和B两个用户,A向B转帐,找记账员,单此时B向A也要转账(假设他们两个不认识,只知道用户唯一名),于是A和B都向柜员要账本,A拿到A的账本,B拿到B的账本,而此时A要等B归还账本,B要等A归还账本,这样就会无限制等下去,类似于死等了。

应用于代码中:

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;
        }
      }
    }
  } 
}

在当线程1和线程2转账操作,执行到①,获取到了各自的锁分别是A.this和B.this,继续执行,执行②处,由于A.this和B.this两个锁都被占用,所以A获取B.this和B获取A.this时都获取失败,此时A和B都处于等待状态,并会无限期的等待下去,也就出现了我们所谓的“死锁”。

“死锁”的一种较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久‘阻塞’的现象”

一不小心就死锁了,怎么办?_第2张图片

如何预防死锁

并发程序一旦出现死锁,一般没有特别好的方法,很多时候我们只能重启应用。所以规避死锁,是解决死锁的最好方法。

要避免死锁,就是要分析死锁发生的条件,牛人Coffman总结了四个死锁发生的必要条件:

  1. 互斥,共享资源X和Y只能被一个线程占用
  2. 占有且等待,线程1已经取得X的资源,在等待Y资源时,不释放锁X
  3. 不可抢占,其他线程不能强行抢占1占有的资源
  4. 循环等待,线程1等待线程2持有的资源,线程2等待线程1持有的资源

所以,只要有一个条件不满足,就会破坏死锁,避免死锁的发生。

互斥:因为我们用的锁就是互斥锁,所以互斥这个条件是没办法破坏的。只能从其他条件出发了;
占有且等待:我们可以一次性申请所有的资源,这样就不存在等待了;
不可抢占:占用部分资源并申请其他资源时,如果无法申请到其他资源时,释放已经占有的资源。这样就可以破坏了;
循环等待:可以按序申请,是指资源是有线性申请的,申请的时候可以申请资源序号小的,再申请资源序号大的,这样就不存在循环了。

1. 破坏占用且等待条件

想破坏这个条件,就需要我们一次性申请所有的资源,那么我们可以将资源交给一个管理者(Allocator)管理,它有两个重要功能:同时申请apply()和同时释放资源free()。账户Account类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。

class Allocator {

	private List<Object> als = new ArrayList();
	
	/** 申请资源 */
	synchronized boolean apply(Object from, Object to) {
		if(als.contains(from) || als.contails(to)) {
			return false;
		} else {
			als.add(from);
			als.add(to);
			return true;
		}
	}

	/** 归还资源 */
	synchronized boolean free(Object from, Object to) {
		als.remove(from);
		als.remove(to);	
	}
}

class Account {

	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);
		}
	}
}

2. 破坏不可抢占条件

如何不能同时申请到资源,就把已占有的资源也给释放了。 但是synchronized是做不到的,原因是synchronized申请资源时,如果申请不到线程会直接进入阻塞状态,等待这个资源释放,也就无法破坏不可抢占条件了。所以Java语言层面是无法解决这个问题的,但是在SDK层面是解决了的。java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。

3. 破坏循环等待

将资源按顺序来标注,从小到大申请资源,就可以解决循环等待。假设每个账户有一个属性id,这个id可以作为排序字段,申请的时候从小到大来顺序申请,即从小到达来锁定账户

class Account {
	
	private int id;
	
	private int balance;

	void transfer(Account target, int amt) {
		Account left = this;
		Account right = target;
		if (this.getId() > right.getId()) {
			left = target;
			right = this;
		}
		// 先锁定序号小的账户
		synchronized(left) {
			// 再锁定序号小的账户
			synchronized(right) {
				this.balance -= amt;
				target.balance += amt;
			}
		}
	}
}

总结

用细粒度锁来锁定多个资源时,要注意死锁的问题。

你可能感兴趣的:(并发编程,Java并发编程实战)