Java并发中如何避免同步方法导致的死锁现象?

2026-05-08 03:233阅读0评论SEO教程
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计1683个文字,预计阅读时间需要7分钟。

Java并发中如何避免同步方法导致的死锁现象?

在Java中,可以使用以下代码实现将字符串中的特定字符替换为另一种字符:

一个经典的死锁场景发生在多个线程尝试获取多个锁,但获取顺序不一致时。考虑一个transferMoney方法,它需要同步两个Account对象以执行转账操作:

public class Account { private UUID id; private float balance; // 构造函数、getter/setter等 public UUID getId() { return id; } public void debit(float amount) { this.balance -= amount; } public void credit(float amount) { this.balance += amount; } } public class TransferService { public void transferMoney(Account a, Account b, float value) { synchronized(a) { // 线程1获取了A的锁 synchronized(b) { // 线程1尝试获取B的锁 // 执行转账逻辑 a.debit(value); b.credit(value); } } } }

假设现在有两个线程同时调用transferMoney方法:

  • 线程1调用transferMoney(accountA, accountB, 100)
  • 线程2调用transferMoney(accountB, accountA, 50)

如果线程1成功获取了accountA的锁,并紧接着线程2成功获取了accountB的锁,那么:

  • 线程1会等待accountB的锁(已被线程2持有)
  • 线程2会等待accountA的锁(已被线程1持有)

这将导致典型的死锁,两个线程都无法继续执行。

立即学习“Java免费学习笔记(深入)”;

策略一:强制一致的锁获取顺序

避免死锁的关键在于确保所有线程以相同的、预定义的顺序获取多个锁。这意味着我们不能依赖于方法参数的传入顺序,而应该基于锁对象的某个固有属性来确定其获取顺序。

为了实现这一点,我们可以为每个Account对象引入一个唯一标识符(例如UUID或Long ID),并约定在获取锁时,总是先获取ID较小的账户的锁,再获取ID较大的账户的锁。

首先,修改Account类,使其包含一个用于比较的唯一ID:

import java.util.Comparator; import java.util.UUID; import java.util.function.BinaryOperator; public class Account { private UUID id; private float balance; public Account(UUID id, float initialBalance) { this.id = id; this.balance = initialBalance; } public UUID getId() { return id; } public void debit(float amount) { if (this.balance < amount) { throw new IllegalArgumentException("Insufficient funds."); } this.balance -= amount; } public void credit(float amount) { this.balance += amount; } public float getBalance() { return balance; } @Override public String toString() { return "Account{" + "id=" + id.toString().substring(0, 8) + ", balance=" + balance + '}'; } // 辅助方法,用于确定两个账户中ID较小的那个 public static final BinaryOperator<Account> FIRST = BinaryOperator.minBy(Comparator.comparing(Account::getId)); // 辅助方法,用于确定两个账户中ID较大的那个 public static final BinaryOperator<Account> SECOND = BinaryOperator.maxBy(Comparator.comparing(Account::getId)); }

接下来,修改transferMoney方法,使用FIRST和SECOND辅助方法来确定锁的获取顺序:

public class TransferService { public void transferMoney(Account a, Account b, float value) { // 确保不能向同一个账户转账 if (a.getId().equals(b.getId())) { throw new IllegalArgumentException("Cannot transfer money to the same account."); } // 确定锁的获取顺序:总是先获取ID较小的账户的锁,再获取ID较大的账户的锁 Account firstLock = Account.FIRST.apply(a, b); Account secondLock = Account.SECOND.apply(a, b); synchronized (firstLock) { synchronized (secondLock) { // 执行转账逻辑 System.out.println(Thread.currentThread().getName() + " acquired locks for " + firstLock.getId().toString().substring(0, 8) + " and " + secondLock.getId().toString().substring(0, 8)); try { // 模拟转账耗时 Thread.sleep(100); firstLock.debit(value); secondLock.credit(value); System.out.println(Thread.currentThread().getName() + " transferred " + value + " from " + a.getId().toString().substring(0, 8) + " to " + b.getId().toString().substring(0, 8)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } }

通过这种方式,无论transferMoney方法被调用时accountA和accountB的传入顺序如何,firstLock和secondLock变量总是会引用到具有一致ID顺序的账户。例如,如果accountA.id小于accountB.id,那么firstLock总是accountA,secondLock总是accountB。这样,所有线程都会以synchronized(account_with_smaller_id) { synchronized(account_with_larger_id) {...} }的顺序获取锁,从而有效避免死锁。

策略二:使用java.util.concurrent.locks.Lock

除了synchronized关键字,Java并发API还提供了java.util.concurrent.locks.Lock接口,它提供了更灵活的锁机制。Lock接口的实现(如ReentrantLock)允许更精细地控制锁的获取和释放,尤其是在处理死锁时提供了额外的工具。

Lock接口的核心思想是,当一个线程需要获取多个锁时,如果它无法一次性获取所有必需的锁,就应该释放已经持有的锁,并稍后重试。这可以通过tryLock()方法实现,它尝试获取锁而不阻塞,并返回一个布尔值指示是否成功获取。

使用Lock的基本模式如下:

import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; // 假设每个Account对象内部有一个ReentrantLock public class AccountWithLock { private UUID id; private float balance; private final Lock lock = new ReentrantLock(); // 每个账户一个独立的锁 // ... 构造函数、getter/setter等 public Lock getLock() { return lock; } } public class TransferServiceWithLock { public void transferMoney(AccountWithLock a, AccountWithLock b, float value) { // 同样,先确定一致的锁获取顺序 AccountWithLock first = AccountWithLock.FIRST.apply(a, b); // 假设AccountWithLock也有FIRST/SECOND AccountWithLock second = AccountWithLock.SECOND.apply(a, b); Lock lock1 = first.getLock(); Lock lock2 = second.getLock(); boolean acquired1 = false; boolean acquired2 = false; try { // 尝试获取第一个锁 acquired1 = lock1.tryLock(); if (acquired1) { // 尝试获取第二个锁 acquired2 = lock2.tryLock(); if (acquired2) { // 成功获取所有锁,执行转账 a.debit(value); b.credit(value); } else { // 未能获取第二个锁,释放第一个锁,稍后重试 lock1.unlock(); } } } finally { // 确保所有获取的锁都被释放 if (acquired2) { lock2.unlock(); } if (acquired1) { // 再次检查,因为如果acquired2失败,acquired1可能仍为true lock1.unlock(); } } } }

注意事项:

  • tryLock()方法可以带超时参数,避免无限等待。
  • finally块对于确保锁的释放至关重要,即使在转账过程中发生异常。
  • 使用Lock接口时,同样需要遵循一致的锁获取顺序原则,以简化死锁处理逻辑。

总结与最佳实践

死锁是并发编程中的一个常见陷阱,但通过遵循一些基本原则可以有效避免。

  1. 统一锁获取顺序: 这是预防多资源死锁最核心的策略。通过对需要同步的资源进行排序(例如,基于对象的唯一ID),并强制所有线程按照该顺序获取锁,可以消除循环等待的条件。
  2. 避免嵌套锁: 尽量减少在持有锁的情况下再去获取另一个锁的情况。如果必须嵌套,务必确保锁的获取顺序是严格一致的。
  3. 使用java.util.concurrent.locks.Lock: 对于更复杂的并发场景,ReentrantLock等Lock实现提供了比synchronized更强大的功能,如可中断的锁获取(lockInterruptibly())、非阻塞的锁获取(tryLock())以及公平性选项。这些特性可以帮助开发者构建更健壮的死锁恢复机制。
  4. 设置锁超时: 在使用Lock时,tryLock(long timeout, TimeUnit unit)方法允许线程在指定时间内尝试获取锁。如果超时仍未获取,线程可以选择放弃并回退,而不是无限期等待。

通过深入理解死锁的成因并采纳上述策略,开发者可以显著提高并发应用程序的稳定性和可靠性。

本文共计1683个文字,预计阅读时间需要7分钟。

Java并发中如何避免同步方法导致的死锁现象?

在Java中,可以使用以下代码实现将字符串中的特定字符替换为另一种字符:

一个经典的死锁场景发生在多个线程尝试获取多个锁,但获取顺序不一致时。考虑一个transferMoney方法,它需要同步两个Account对象以执行转账操作:

public class Account { private UUID id; private float balance; // 构造函数、getter/setter等 public UUID getId() { return id; } public void debit(float amount) { this.balance -= amount; } public void credit(float amount) { this.balance += amount; } } public class TransferService { public void transferMoney(Account a, Account b, float value) { synchronized(a) { // 线程1获取了A的锁 synchronized(b) { // 线程1尝试获取B的锁 // 执行转账逻辑 a.debit(value); b.credit(value); } } } }

假设现在有两个线程同时调用transferMoney方法:

  • 线程1调用transferMoney(accountA, accountB, 100)
  • 线程2调用transferMoney(accountB, accountA, 50)

如果线程1成功获取了accountA的锁,并紧接着线程2成功获取了accountB的锁,那么:

  • 线程1会等待accountB的锁(已被线程2持有)
  • 线程2会等待accountA的锁(已被线程1持有)

这将导致典型的死锁,两个线程都无法继续执行。

立即学习“Java免费学习笔记(深入)”;

策略一:强制一致的锁获取顺序

避免死锁的关键在于确保所有线程以相同的、预定义的顺序获取多个锁。这意味着我们不能依赖于方法参数的传入顺序,而应该基于锁对象的某个固有属性来确定其获取顺序。

为了实现这一点,我们可以为每个Account对象引入一个唯一标识符(例如UUID或Long ID),并约定在获取锁时,总是先获取ID较小的账户的锁,再获取ID较大的账户的锁。

首先,修改Account类,使其包含一个用于比较的唯一ID:

import java.util.Comparator; import java.util.UUID; import java.util.function.BinaryOperator; public class Account { private UUID id; private float balance; public Account(UUID id, float initialBalance) { this.id = id; this.balance = initialBalance; } public UUID getId() { return id; } public void debit(float amount) { if (this.balance < amount) { throw new IllegalArgumentException("Insufficient funds."); } this.balance -= amount; } public void credit(float amount) { this.balance += amount; } public float getBalance() { return balance; } @Override public String toString() { return "Account{" + "id=" + id.toString().substring(0, 8) + ", balance=" + balance + '}'; } // 辅助方法,用于确定两个账户中ID较小的那个 public static final BinaryOperator<Account> FIRST = BinaryOperator.minBy(Comparator.comparing(Account::getId)); // 辅助方法,用于确定两个账户中ID较大的那个 public static final BinaryOperator<Account> SECOND = BinaryOperator.maxBy(Comparator.comparing(Account::getId)); }

接下来,修改transferMoney方法,使用FIRST和SECOND辅助方法来确定锁的获取顺序:

public class TransferService { public void transferMoney(Account a, Account b, float value) { // 确保不能向同一个账户转账 if (a.getId().equals(b.getId())) { throw new IllegalArgumentException("Cannot transfer money to the same account."); } // 确定锁的获取顺序:总是先获取ID较小的账户的锁,再获取ID较大的账户的锁 Account firstLock = Account.FIRST.apply(a, b); Account secondLock = Account.SECOND.apply(a, b); synchronized (firstLock) { synchronized (secondLock) { // 执行转账逻辑 System.out.println(Thread.currentThread().getName() + " acquired locks for " + firstLock.getId().toString().substring(0, 8) + " and " + secondLock.getId().toString().substring(0, 8)); try { // 模拟转账耗时 Thread.sleep(100); firstLock.debit(value); secondLock.credit(value); System.out.println(Thread.currentThread().getName() + " transferred " + value + " from " + a.getId().toString().substring(0, 8) + " to " + b.getId().toString().substring(0, 8)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } }

通过这种方式,无论transferMoney方法被调用时accountA和accountB的传入顺序如何,firstLock和secondLock变量总是会引用到具有一致ID顺序的账户。例如,如果accountA.id小于accountB.id,那么firstLock总是accountA,secondLock总是accountB。这样,所有线程都会以synchronized(account_with_smaller_id) { synchronized(account_with_larger_id) {...} }的顺序获取锁,从而有效避免死锁。

策略二:使用java.util.concurrent.locks.Lock

除了synchronized关键字,Java并发API还提供了java.util.concurrent.locks.Lock接口,它提供了更灵活的锁机制。Lock接口的实现(如ReentrantLock)允许更精细地控制锁的获取和释放,尤其是在处理死锁时提供了额外的工具。

Lock接口的核心思想是,当一个线程需要获取多个锁时,如果它无法一次性获取所有必需的锁,就应该释放已经持有的锁,并稍后重试。这可以通过tryLock()方法实现,它尝试获取锁而不阻塞,并返回一个布尔值指示是否成功获取。

使用Lock的基本模式如下:

import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; // 假设每个Account对象内部有一个ReentrantLock public class AccountWithLock { private UUID id; private float balance; private final Lock lock = new ReentrantLock(); // 每个账户一个独立的锁 // ... 构造函数、getter/setter等 public Lock getLock() { return lock; } } public class TransferServiceWithLock { public void transferMoney(AccountWithLock a, AccountWithLock b, float value) { // 同样,先确定一致的锁获取顺序 AccountWithLock first = AccountWithLock.FIRST.apply(a, b); // 假设AccountWithLock也有FIRST/SECOND AccountWithLock second = AccountWithLock.SECOND.apply(a, b); Lock lock1 = first.getLock(); Lock lock2 = second.getLock(); boolean acquired1 = false; boolean acquired2 = false; try { // 尝试获取第一个锁 acquired1 = lock1.tryLock(); if (acquired1) { // 尝试获取第二个锁 acquired2 = lock2.tryLock(); if (acquired2) { // 成功获取所有锁,执行转账 a.debit(value); b.credit(value); } else { // 未能获取第二个锁,释放第一个锁,稍后重试 lock1.unlock(); } } } finally { // 确保所有获取的锁都被释放 if (acquired2) { lock2.unlock(); } if (acquired1) { // 再次检查,因为如果acquired2失败,acquired1可能仍为true lock1.unlock(); } } } }

注意事项:

  • tryLock()方法可以带超时参数,避免无限等待。
  • finally块对于确保锁的释放至关重要,即使在转账过程中发生异常。
  • 使用Lock接口时,同样需要遵循一致的锁获取顺序原则,以简化死锁处理逻辑。

总结与最佳实践

死锁是并发编程中的一个常见陷阱,但通过遵循一些基本原则可以有效避免。

  1. 统一锁获取顺序: 这是预防多资源死锁最核心的策略。通过对需要同步的资源进行排序(例如,基于对象的唯一ID),并强制所有线程按照该顺序获取锁,可以消除循环等待的条件。
  2. 避免嵌套锁: 尽量减少在持有锁的情况下再去获取另一个锁的情况。如果必须嵌套,务必确保锁的获取顺序是严格一致的。
  3. 使用java.util.concurrent.locks.Lock: 对于更复杂的并发场景,ReentrantLock等Lock实现提供了比synchronized更强大的功能,如可中断的锁获取(lockInterruptibly())、非阻塞的锁获取(tryLock())以及公平性选项。这些特性可以帮助开发者构建更健壮的死锁恢复机制。
  4. 设置锁超时: 在使用Lock时,tryLock(long timeout, TimeUnit unit)方法允许线程在指定时间内尝试获取锁。如果超时仍未获取,线程可以选择放弃并回退,而不是无限期等待。

通过深入理解死锁的成因并采纳上述策略,开发者可以显著提高并发应用程序的稳定性和可靠性。