死锁是并发应用程序中的常见问题。在此类应用程序中,我们使用锁定机制来确保线程安全。此外,我们使用线程池和信号量来管理资源消耗。然而,在某些情况下,这些技术可能会导致死锁。
在本文中,我们将探讨死锁、死锁出现的原因以及如何分析和避免潜在的死锁情况。
理解死锁
简单地说, 当两个或多个线程在等待另一个线程持有的另一个资源可用时互相阻塞时就会发生死锁 。
JVM 并非为从死锁中恢复而设计的。因此,根据这些线程的操作,当发生死锁时,整个应用程序可能会停滞,或者会导致性能下降。
死锁示例
为了说明死锁现象,让我们创建一个在两个账户之间转移资金的模拟:
java
private static void transferFunds(Account fromAccount, Account toAccount, BigDecimal amount) {
synchronized (fromAccount) {
System.out.println(Thread.currentThread().getName() + " acquired lock on " + fromAccount);
synchronized (toAccount) {
transfer(fromAccount, toAccount, amount);
}
}
}
public static void transfer(Account fromAccount, Account toAccount, BigDecimal amount) {
if (fromAccount.getBalance().compareTo(amount) < 0)
throw new RuntimeException("Insufficient funds.");
else {
fromAccount.withdraw(amount);
toAccount.deposit(amount);
System.out.println(Thread.currentThread()
.getName() + " transferred $" + amount + " from " + fromAccount + " to " + toAccount);
}
}
乍一看,上面的代码可能没有明显地表明 transferFunds() 方法如何导致死锁。似乎所有线程都以相同的顺序获取锁。但是,锁的顺序取决于传递给 transferFunds() 方法的参数的顺序。
在我们的例子中,当两个线程同时调用transferFunds() 方法时,可能会发生死锁,一个线程将资金从account1转移到account2,另一个线程将资金从account2转移到account1 :
java
Thread thread1 = new Thread(() -> transferFunds(account1, account2, BigDecimal.valueOf(500)));
Thread thread2 = new Thread(() -> transferFunds(account2, account1, BigDecimal.valueOf(300)));
thread1.start();
thread2.start();
线程1获取**帐户 1 的锁并等待帐户 2 的锁,而线程 2持有**帐户 2 的锁并等待帐户 1的锁。
修复死锁
为了修复示例中的死锁, 我们可以定义锁的顺序,并在整个应用程序中一致地获取它们 。 这样,我们可以确保每个线程以相同的顺序获取锁。
引入对象排序的一种方法是利用它们的hashCode 值。此外, 我们还可以使用System.identityHashCode ,它返回 hashCode() 方法的值 。
让我们修改我们的transferFunds()方法并使用**System.identityHashCode引入锁排序:
java
public static void transferFunds(final Account fromAccount, final Account toAccount, final BigDecimal amount) {
int fromHash = System.identityHashCode(fromAccount);
int toHash = System.identityHashCode(toAccount);
if (fromHash < toHash) {
synchronized (fromAccount) {
System.out.println(Thread.currentThread().getName() + " acquired lock on " + fromAccount);
synchronized (toAccount) {
transfer(fromAccount, toAccount, amount);
}
}
} else if (fromHash > toHash) {
synchronized (toAccount) {
System.out.println(Thread.currentThread().getName() + " acquired lock on " + toAccount);
synchronized (fromAccount) {
transfer(fromAccount, toAccount, amount);
}
}
} else {
synchronized (sameHashCodeLock) {
synchronized (fromAccount) {
System.out.println(Thread.currentThread().getName() + " acquired lock on " + fromAccount);
synchronized (toAccount) {
transfer(fromAccount, toAccount, amount);
}
}
}
}
}
在上面的代码示例中,我们计算了fromAccount 和toAccount的哈希码,并根据给定的值定义了锁顺序。
由于两个对象可以具有相同的哈希码,我们需要添加额外的逻辑并引入第三个sameHashCodeLock锁:
java
private static final Object sameHashCodeLock = new Object();
在else 语句中,我们首先获取了sameHashCodeLock 上的锁,确保一次只有一个线程获取Account对象的锁。这消除了死锁的可能性。
避免死锁方法
进一步讨论如何避免死锁。我们应该记住,如果我们的程序一次只获取一个锁,它就永远不会遇到锁排序死锁。
指定锁定时间
我们的系统从死锁中恢复的一种方法是使用 定时锁定尝试 。我们可以使用Lock接口中的 tryLock() 方法。在该方法中,我们可以设置超时,如果方法无法获取锁,则超时后返回失败。这样,线程就不会无限期地阻塞:
java
while (true) {
if (fromAccount.lock.tryLock(1, SECONDS)) {
System.out.println(Thread.currentThread().getName() + " acquired lock on " + fromAccount);
try {
if (toAccount.lock.tryLock(1, SECONDS)) {
try {
transfer(fromAccount, toAccount, amount);
} finally {
toAccount.lock.unlock();
}
}
} finally {
fromAccount.lock.unlock();
}
}
SECONDS.sleep(10);
}
我们不应该忘记在finally块中调用 unlock() 方法。
使用线程转储检测死锁
最后,让我们看看如何使用线程转储 和fastThread工具检测死锁。线程转储包含每个正在运行的线程的堆栈跟踪和锁定信息。
导致死锁的生成的线程转储的一部分如下所示:
bash
"Thread-0":
waiting to lock monitor 0x000060000085c340 (object 0x000000070f994f08, a com.tier1app.deadlock.Account),
which is held by "Thread-1"
"Thread-1":
waiting to lock monitor 0x0000600000850410 (object 0x000000070f991c90, a com.tier1app.deadlock.Account),
which is held by "Thread-0"
为了检查我们的应用程序是否遭遇死锁,我们可以将线程转储上传到fastThread工具中:
图:死锁问题突出显示快速线程工具
完整报告可在此处找到。
接下来我们来看看导致此问题的详细信息:
图:发现的死锁详细信息快速线程工具
写在最后
在本文中,我们了解了什么是死锁,如何修复死锁以及如何避免死锁。
总而言之,当线程在等待从另一个线程获取的资源可用时相互阻塞时,并发应用程序中就会发生死锁。修复死锁的一种方法是使用对象的哈希码定义锁定顺序。
最后,我们可以使用线程转储和fastThread工具检测死锁。