并发中的 ABA 问题-Java快速进阶教程
1. 简介
在本教程中,我们将介绍并发编程中 ABA 问题的理论背景。我们将看到它的根本原因以及解决方案。
2. 比较和交换
为了了解根本原因,让我们简要回顾一下比较和交换的概念。
比较和交换 (CAS) 是无锁算法中的常用技术,用于确保一个线程对共享内存的更新在另一个线程同时修改相同空间时失败。
我们通过在每次更新中使用两条信息来实现这一点:更新的值和原始值。然后,比较和交换将首先将现有值与原始值进行比较。如果相等,则用更新的值交换现有值。
当然,这种情况也可能发生在引用中。
3. ABA问题
现在,ABA 问题是一个反常现象,仅靠比较和交换方法就让我们失败了。
例如,假设一个活动读取一些共享内存 (A),以准备更新它。然后,另一个活动临时修改该共享内存 (B),然后恢复它 (A)。之后,一旦第一个活动执行比较和交换,它就会看起来好像没有进行任何更改,从而使检查的完整性无效。
虽然在许多情况下这不会导致问题,但有时,A 并不像我们想象的那样等于 A。让我们看看这在实践中如何。
3.1. 示例域
为了通过一个实际示例来演示这个问题,让我们考虑一个简单的银行账户类,其中整数变量保存实际余额的金额。我们还有两个功能:一个用于提款,一个用于存款。这些操作使用 CAS 来减少和增加帐户余额。
3.2. 问题出在哪里?
让我们考虑一个多线程方案,当线程 1 和线程 2 在同一个银行帐户上运行时。
当线程 1 想要提取一些资金时,它会读取实际余额,以便稍后使用该值比较 CAS 操作中的金额。但是,由于某种原因,线程 1 有点慢——也许它被阻止了。
同时,线程 2 使用相同的机制对帐户执行两个操作,而线程 1 处于挂起状态。首先,它更改线程 1 已读取的原始值,但随后将其更改回原始值。
线程 1 恢复后,它看起来好像没有任何变化,CAS 将成功:
4. Java示例
为了更好地可视化这一点,让我们看一些代码。在这里,我们将使用 Java,但问题本身不是特定于语言的。
4.1. 账户
首先,我们的Account类将余额保存在一个 AtomicInteger 中,这为我们提供了 Java 中整数的 CAS。此外,还有另一个AtomicInteger来计算成功交易的数量。最后,我们有一个ThreadLocal变量来捕获给定线程的 CAS 操作失败。
public class Account {
private AtomicInteger balance;
private AtomicInteger transactionCount;
private ThreadLocal<Integer> currentThreadCASFailureCount;
...
}
4.2. 存款
接下来,我们可以为Account类实现存款方法:
public boolean deposit(int amount) {
int current = balance.get();
boolean result = balance.compareAndSet(current, current + amount);
if (result) {
transactionCount.incrementAndGet();
} else {
int currentCASFailureCount = currentThreadCASFailureCount.get();
currentThreadCASFailureCount.set(currentCASFailureCount + 1);
}
return result;
}
注意,AtomicInteger.compareAndSet(...)只不过是AtomicInteger.compareAndSwap() 方法的包装器,用于反映 CAS 操作的布尔结果。
4.3. 提款
同样,提款方法可以创建为:
public boolean withdraw(int amount) {
int current = getBalance();
maybeWait();
boolean result = balance.compareAndSet(current, current - amount);
if (result) {
transactionCount.incrementAndGet();
} else {
int currentCASFailureCount = currentThreadCASFailureCount.get();
currentThreadCASFailureCount.set(currentCASFailureCount + 1);
}
return result;
}
为了能够演示 ABA 问题,我们创建了一个maybeWait() 方法来保存一些耗时的操作,为另一个线程提供一些额外的时间框架来对余额执行修改。
现在,我们只需暂停线程 1 两秒钟:
private void maybeWait() {
if ("thread1".equals(Thread.currentThread().getName())) {
// sleep for 2 seconds
}
}
4.4. ABA 场景
最后,我们可以编写一个单元测试来检查 ABA 问题是否可能。
我们要做的是有两个线程,我们的线程 1 和之前的线程 2。线程 1 将读取余额并延迟。线程 2,当线程 1 处于休眠状态时,将更改平衡,然后将其更改回来。
一旦线程 1 唤醒,它就不会更明智,它的操作仍然会成功。
经过一些初始化后,我们可以创建线程 1,该线程需要一些额外的时间来执行 CAS 操作。完成此操作后,它不会意识到内部状态已更改,因此 CAS 失败计数将为零,而不是 ABA 方案中预期的 1:
@Test
public void abaProblemTest() {
// ...
Runnable thread1 = () -> {
assertTrue(account.withdraw(amountToWithdrawByThread1));
assertTrue(account.getCurrentThreadCASFailureCount() > 0); // test will fail!
};
// ...
}
同样,我们可以创建将在线程 1 之前完成的线程 2,并更改帐户余额并将其更改回原始值。在这种情况下,我们预计不会出现任何 CAS 故障。
@Test
public void abaProblemTest() {
// ...
Runnable thread2 = () -> {
assertTrue(account.deposit(amountToDepositByThread2));
assertEquals(defaultBalance + amountToDepositByThread2, account.getBalance());
assertTrue(account.withdraw(amountToWithdrawByThread2));
assertEquals(defaultBalance, account.getBalance());
assertEquals(0, account.getCurrentThreadCASFailureCount());
};
// ...
}
运行线程后,线程 1 将获得预期的余额,尽管线程 2 中额外的两个事务不需要:
@Test
public void abaProblemTest() {
// ...
assertEquals(defaultBalance - amountToWithdrawByThread1, account.getBalance());
assertEquals(4, account.getTransactionCount());
}
5. 基于价值的场景与基于参考的场景
在上面的例子中,我们可以发现一个重要的事实——我们在场景结束时得到的AtomicInteger与我们开始时的完全一样。除了未能捕获线程 2 进行的两个额外事务外,此特定示例中未发生异常。
这背后的原因是我们基本上使用了值类型而不是引用类型。
5.1. 基于引用的异常
我们可能会遇到使用引用类型的 ABA 问题,目的是重用它们。在本例中,在 ABA 方案结束时,我们返回匹配的引用,因此 CAS 操作成功,但是,引用可能指向与原始对象不同的对象。这可能会导致歧义。
6. 解决方案
现在我们已经很好地了解了这个问题,让我们深入研究一些可能的解决方案。
6.1. 垃圾回收
对于引用类型,垃圾回收(GC)在大多数情况下可以保护我们免受ABA问题的影响。
当线程 1 在我们使用的给定内存地址处具有对象引用时,线程 2 所做的任何操作都不会导致另一个对象使用相同的地址。该对象仍处于活动状态,在未保留对它的引用之前,不会重用其地址。
虽然这适用于引用类型,但问题是当我们在无锁数据结构中依赖 GC 时。
当然,有些语言不提供 GC 也是事实。
6.2. 危险指针
危险指针与前一个指针有些联系——我们可以在没有自动垃圾收集机制的语言中使用它们。
简而言之,线程跟踪共享数据结构中的受质疑指针。这样,每个线程都知道指针定义的给定内存地址上的对象可能已被另一个线程修改。
所以现在,让我们看看其他几个解决方案。
6.3. 不变性
当然,使用不可变对象可以解决这个问题,因为我们不会在整个应用程序中重用对象。每当发生更改时,都会创建一个新对象,因此 CAS 肯定会失败。
但是,我们的最终解决方案也允许可变对象。
6.4. 双重比较和交换
双重比较和交换方法背后的想法是跟踪另一个变量,即版本号,然后在比较中也使用它。在这种情况下,如果我们有旧版本号,CAS 操作将失败,这只有在另一个线程同时修改我们的变量时才有可能。
在Java中,AtomicStampedReference和AtomicMarkableReference是此方法的标准实现。