JavaRush /Java 博客 /Random-ZH /Java 中的死锁及其解决方法
articles
第 15 级

Java 中的死锁及其解决方法

已在 Random-ZH 群组中发布
在开发多线程应用程序时,经常会出现一个困境:应用程序的可靠性和性能哪个更重要。例如,我们使用同步来保证线程安全,如果同步顺序不正确,就会导致死锁。我们还使用线程池和信号量来限制资源消耗,这种设计中的错误可能会因资源不足而导致死锁。在本文中,我们将讨论如何避免死锁,以及应用程序性能中的其他问题。我们还将研究如何编写应用程序以便能够在死锁情况下恢复。 Java 中的死锁及其解决方法 - 1死锁是指两个或多个占用某些资源的进程试图获取其他进程占用的其他资源,但没有一个进程能够占用它们所需的资源,从而释放所占用的资源的情况。这个定义太笼统,因此很难理解;为了更好地理解,我们将通过示例来了解死锁的类型。

同步顺序互锁

考虑以下任务:您需要编写一个方法来执行交易,将一定数量的资金从一个帐户转移到另一个帐户。解决方案可能如下所示:
public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}
乍一看,这段代码同步得很正常;我们有一个检查和更改源帐户状态以及更改目标帐户的原子操作。然而,采用这种同步策略,可能会出现死锁情况。让我们看一个例子来说明这是如何发生的。需要进行两笔交易:从A账户转x个钱到B账户,从B账户转y个钱到A账户。通常这种情况不会导致死锁,但是,在不幸的情况下,事务 1 将占用帐户监视器 A,事务 2 将占用帐户监视器 B。结果是死锁:事务 1 等待事务 2 释放帐户监视器B,但事务2必须访问监视器A,该监视器被事务1占用。死锁的一大问题是在测试中不容易发现。即使在示例中描述的情况下,线程也可能不会阻塞,也就是说,这种情况不会不断地重现,这会使诊断变得非常复杂。一般来说,所描述的非确定性问题是多线程的典型问题(尽管这并没有使它变得更容易)。因此,代码审查在提高多线程应用程序的质量方面发挥着重要作用,因为它允许您识别在测试期间难以重现的错误。当然,这并不意味着应用程序不需要测试;我们只是不应该忘记代码审查。我应该怎么做才能防止这段代码导致死锁?此阻止是由于帐户同步可能以不同的顺序发生而引起的。因此,如果您在帐户上引入某种顺序(这是一些允许您说帐户 A 小于帐户 B 的规则),那么问题将被消除。怎么做?首先,如果帐户具有某种唯一标识符(例如帐号)数字、小写或其他具有自然顺序概念的东西(字符串可以按字典顺序进行比较,那么我们可以认为自己很幸运,我们会我们总是可以先占用较小帐户的监视器,然后再占用较大帐户的监视器(反之亦然)。
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromId= fromAcct.getId();
	int toId = fromAcct.getId();
	if (fromId < toId) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	} else  {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	}
}
第二种选择,如果我们没有这样的标识符,我们就必须自己想出它。作为第一个近似,我们可以通过哈希码来比较对象。他们很可能会有所不同。但如果结果相同怎么办?然后您将必须添加另一个对象以进行同步。它可能看起来有点复杂,但你能做什么呢?此外,第三个对象很少被使用。结果将如下所示:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	if (fromHash < toHash) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else if (fromHash > toHash) {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else {
		synchronized (tieLock) {
			synchronized (fromAcct) {
				synchronized (toAcct) {
					doTransfer(fromAcct, toAcct, amount)
				}
			}
		}
	}
}

对象之间的死锁

所描述的阻塞条件代表了最容易诊断的死锁情况。通常在多线程应用程序中,不同的对象尝试访问相同的同步块。这可能会导致死锁。考虑以下示例:航班调度员应用程序。飞机到达目的地时会通知管制员并请求降落。管制员存储有关朝其方向飞行的飞机的所有信息,并可以在地图上绘制它们的位置。
class Plane {
	private Point location, destination;
	private final Dispatcher dispatcher;

	public Plane(Dispatcher dispatcher) {
		this.dispatcher = dispatcher;
	}
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
		dispatcher.requestLanding(this);
	}
}

class Dispatcher {
	private final Set<Plane> planes;
	private final Set<Plane> planesPendingLanding;

	public Dispatcher() {
		planes = new HashSet<Plane>();
		planesPendingLanding = new HashSet<Plane>();
	}
	public synchronized void requestLanding(Plane plane) {
		planesPendingLanding.add(plane);
	}
	public synchronized Image getMap() {
		Image image = new Image();
		for (Plane plane : planes)
			image.drawMarker(plane.getLocation());
		return image;
	}
}
了解此代码中存在可能导致死锁的错误比前一个代码更困难。乍一看,它没有重新同步,但事实并非如此。您可能已经注意到,setLocationPlanegetMap类方法Dispatcher是同步的,并且在它们内部调用其他类的同步方法。这通常是不好的做法。如何纠正这个问题将在下一节中讨论。因此,如果飞机到达该地点,当有人决定拿卡时,就会出现僵局。即会调用getMap和方法,分别setLocation占用实例监视器DispatcherPlane。然后,该方法将getMap调用plane.getLocation(特别是在当前繁忙的实例上Plane),该方法将等待每个实例的监视器变为空闲Plane。同时,该方法setLocation将被调用dispatcher.requestLanding,而实例监视器Dispatcher仍忙于绘制地图。结果陷入僵局。

公开征集

为了避免出现上一节中描述的情况,建议使用公共调用其他对象的方法。即调用synchronized块之外的其他对象的方法。如果使用开放调用的原则来重写方法setLocationgetMap则将消除死锁的可能性。例如,它看起来像这样:
public void setLocation(Point location) {
	boolean reachedDestination;
	synchronized(this){
		this.location = location;
		reachedDestination = location.equals(destination);
	}
	if (reachedDestination)
		dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
	Set<Plane> copy;
	synchronized(this){
		copy = new HashSet<Plane>( planes);
	}
	Image image = new Image();
	for (Plane plane : copy)
		image.drawMarker(plane.getLocation());
	return image;
}

资源死锁

当尝试访问某些一次只有一个线程可以使用的资源时,也可能会发生死锁。一个例子是数据库连接池。如果某些线程需要同时访问两个连接,并且它们以不同的顺序访问它们,这可能会导致死锁。从根本上讲,这种锁定与同步顺序锁定没有什么不同,只不过它不是在尝试执行某些代码时发生,而是在尝试访问资源时发生。

如何避免死锁?

当然,如果代码编写没有任何错误(我们在前面几节中看到的示例),那么其中就不会有死锁。但谁能保证他的代码写得没有错误呢?当然,测试有助于识别大部分错误,但正如我们之前所看到的,多线程代码中的错误并不容易诊断,即使在测试之后,您也无法确定是否存在死锁情况。我们能以某种方式保护自己免受阻塞吗?答案是肯定的。类似的技术也用在数据库引擎中,它们经常需要从死锁中恢复(与数据库中的事务机制相关)。Lock包中可用的接口及其实现java.util.concurrent.locks允许您尝试使用该方法占用与此类实例关联的监视器tryLock(如果可以占用监视器,则返回 true)。假设我们有一对实现接口的对象Lock,并且我们需要以避免相互阻塞的方式占用它们的监视器。你可以这样实现:
public void twoLocks(Lock A,  Lock B){
	while(true){
		if(A.tryLock()){
			if(B.tryLock())
			{
				try{
					//do something
				} finally{
					B.unlock();
					A.unlock();
				}
			} else{
				A.unlock();
			}
		}
	}
}
正如您在该程序中看到的,我们占用了两个监视器,同时消除了相互阻塞的可能性。请注意,该块try- finally是必要的,因为包中的类java.util.concurrent.locks不会自动释放监视器,并且如果在执行任务期间发生某些异常,监视器将陷入锁定状态。如何诊断死锁?JVM 允许您通过在线程转储中显示死锁来诊断死锁。此类转储包含有关线程所处状态的信息。如果它被阻塞,则转储包含有关线程正在等待释放的监视器的信息。在转储线程之前,JVM 会查看等待(繁忙)监视器的图表,如果发现循环,则会添加死锁信息,指示参与的监视器和线程。死锁线程的转储如下所示:
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
上面的转储清楚地表明处理数据库的两个线程已相互阻塞。为了使用此 JVM 功能诊断死锁,需要在程序中的各个位置调用线程转储操作并测试应用程序。接下来,您应该分析生成的日志。如果它们表明发生了死锁,则转储中的信息将有助于检测死锁发生的条件。一般来说,您应该避免死锁示例中的情况。在这种情况下,应用程序很可能会稳定运行。但不要忘记测试和代码审查。如果问题确实发生,这将有助于识别问题。如果您正在开发一个死锁字段的恢复至关重要的系统,您可以使用“如何避免死锁?”一节中描述的方法。在这种情况下,来自 的lockInterruptibly接口方法。它允许您使用此方法中断已占用监视器的线程(从而释放监视器)。 Lockjava.util.concurrent.locks
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION