《Java并发编程实践》四(1):死锁

第10~12章是本书第三部分,介绍多线程模式下的性能问题:

  • 第10章:死锁,主要介绍死锁引起的线程活性失败;
  • 第11章:并发性能和可伸缩性,介绍多线程角度如何分析程序的性能;
  • 第12章:测试,如何对多线程程序进行测试,此章跳过。

死锁及死锁避免(Deadlock)

如果线程A持有一个锁,那么其他想要获取这个锁的线程会被阻塞;如果A永远不释放这个锁,那么其他线程永远被阻塞,这最简单的死锁场景。这种情景是比较明显的编程错误,实际项目中比较少出现。

如果线程A当前拥有锁L,尝试获取锁M,但是线程B却相反,当前拥有M,尝试获取L,那么两个线程都将被阻塞。这是比较典型死锁场景

线程互相锁死的本质是,线程之间的锁等待形成一个闭环,线程A等待B释放锁,B等待C,C等待A。无论这条路径有多长,只要形成一个环,就会造成死锁。

JVM无法从将线程从死锁中恢复,只有重启才能解决,所以java编程要尽力避免死锁场景。程序中存在死锁场景并不代表死锁一定会发生,死锁的发生需要一点巧合,而这种巧合往往在系统负载较高时会出现。

加锁顺序

下面的LeftRightDeadlock展示了加锁顺序导致的死锁风险。leftRight方法先对left加载再对right加锁,rightLeft方法反过来。当两个线程分别调用leftRight和rightLeft,就可能发生死锁。

public class LeftRightDeadlock {
	private final Object left = new Object();
	private final Object right = new Object();
	public void leftRight() {
		synchronized (left) {
			synchronized (right) {
				doSomething();
			}
		}
	}
	public void rightLeft() {
		synchronized (right) {
			synchronized (left) {
				doSomethingElse();
				}
		}
	}
}

如果所有线程以一种相对一致的顺序进行加锁,那么就不能形成锁等待的循环,进而避免死锁。因此,对代码的的各种加锁路径进行分析,排除不同的加锁顺序是很有必要的。

动态的加锁顺序

有时候加锁的顺序是由运行时的状态决定的,比如下面的转账逻辑:

public void transferMoney(Account fromAccount,Account toAccount,	DollarAmount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}

上面代码的陷阱在于transferMoney的加锁顺序是由调用参数决定的,如果有线程执行transferMoney(A,B),另外有线程执行transferMoney(B,A),就有死锁风险。

解决此问题的方法是,基于fromAccount和toAccount的运行时值计算一个固定的加锁顺序,如果Account有一个表示唯一身份的字段,那就容易了。如果找不到这样的排序方案,那就只能对transferMoney整体进行加锁。

协作对象之间的死锁

通过分析代码加锁路径来避免死锁并不总是可行,当加锁发生在多个对象内(甚至第三方对象)时,加锁顺序就不像LeftRightDeadlock和transferMoney那么明显了。

下面看一个Taxi的调度系统,Taxi.setLocation方法发现自己到达目的地之后,调用Dispatcher.notifyAvailable通知调度器自己已经空闲。Dispatcher.getTaxiLocations返回全部出租车当前的位置分布,可用于地图渲染。

class Taxi {
	@GuardedBy("this") private Point location, destination;
	private final Dispatcher dispatcher;
	
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
			dispatcher.notifyAvailable(this);
	}
}
class Dispatcher {
	@GuardedBy("this") private final Set<Taxi> taxis;
	@GuardedBy("this") private final Set<Taxi> availableTaxis;

	public synchronized void notifyAvailable(Taxi taxi) {
		availableTaxis.add(taxi);
	}
	public synchronized List<Point> getTaxiLocations() {
		List<Point> list = new ArrayList<>()
		for (Taxi t : taxis)
			list.add(t.getLocation());
		return list;
	}
}

上面的代码存在死锁风险,Taxi.setLocation和Dispatcher.getTaxiLocations以相反的顺序加锁。但分别分析Taxi和Dispatcher的代码是不能发现此问题的。显然,这里需要更一种更加广泛的方法论来指导我们,而不是将死锁分析变成找茬游戏。

Open Call

上面问题的本质在于,Taxi在调用dispatcher.notifyAvailable时,并不清楚会存在相反的加锁路径;Taxi不知道Dispatcher的实现细节(不过话说回来,封装不就是为了对外屏蔽这些细节吗?),在持有一个锁的同时,调用了Dispatcher。

在没有持有任何锁的时候,调用一个外部方法,称之为Open Call。Open Call对预防死锁的意义,正如封装性对线程安全的作用:理论上,不坚持Open Call可能避免死锁,但是随着程序复杂度提升,难度会越来越大,直至几乎不可能。

现在我们将Taxi和Dispatcher按Open Call原则来修改:

class Taxi {
	public void setLocation(Point location) {
		boolean reachedDestination;
		synchronized (this) {
			this.location = location;
			reachedDestination = location.equals(destination);
		}
		if (reachedDestination)
			dispatcher.notifyAvailable(this);
	}
}

class Dispatcher {
	public List<Point> getTaxiLocations() {
		Set<Taxi> copy;
		synchronized (this) {
			copy = new HashSet<Taxi>(taxis);
		}
		List<Point> list = new ArrayList<>()
		for (Taxi t : copy)
			list.add(t.getLocation());
		return list;
	}
}

Taxi和Dispatcher之间存在互相调用,所以要坚持OpenCall的原则;如果两个模块之间的调用关系是单向的,就消除了此类死锁场景。实际项目中,如果模块层次分明,是有助于避免死锁的;此时是要特别注意回调机制的实现(Observer设计模式),原则上,底层模块对上层接口实现的回调要符合OpenCall。

资源等待死锁

本质上,java锁是一种资源,每个锁相当于一种数量只有1的资源类型。任何其他类型数量有限、且不可被并发访问的资源都可能造成死锁。

另外还有一种特殊的资源等待死锁:“线程饥饿死锁”,由于等待的任务获取不到线程资源,而产生死锁。

非Java Lock造成的死锁,比较少见;不过一旦出现,往往更加难诊断(因为JVM不能提供任何协助)。

尝试加锁

除了“Open Call”和“保持一致的加锁顺序”,还有一种避免死锁的方式是使用Lock.tryLock,而不是java监视器锁那样无限阻塞的锁。

当Lock.tryLock失败时,虽然不能够知道失败原因,但是至少有机会记录日志,然后再尝试恢复正常。而不只有重启进程一条路。

死锁检测

JVM能够通过线程dump来检测死锁,dump信息能给出线程持有的锁和正在等待的锁,并判断线程之间有没有形成一个锁等待的环路。

其他活性陷阱

除了死锁外,还有以下导致并发程序活性失败的陷阱。

  • 饥饿

饥饿是指线程虽然没有陷入死锁,但是在某种调度策略下得不到访问某种资源的机会,从而无法执行。给java设置线程优先级可能导致饥饿问题,最好不要这么做。

  • 响应性差

线程由于长时间等待锁(虽然没有死锁),或者被其他繁忙线程抢占CPU,从而使得它的响应性很差。

  • 活锁

活锁是指线程虽然一直在用运行,但一直在尝试同一个操作且不断失败,无法前进一步。比如一个消息处理器,在处理消息失败时进行重试,如果缺少了某种条件,该重试永远不会成功。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值