1. 引言
在并发编程中,我们经常会遇到一些看似合理但实际存在问题的场景。ABA 问题就是其中一种典型的并发异常现象。它发生在使用 Compare and Swap(CAS)操作进行无锁编程时,可能导致线程误判共享变量的状态。
本文将深入探讨 ABA 问题的成因、示例以及常见的解决方案,帮助你在开发中避免踩坑。
2. Compare and Swap(CAS)机制
CAS 是无锁编程中常用的一种机制,用于在不使用锁的情况下实现线程安全的更新操作。
✅ CAS 的基本原理
- CAS 操作包含三个参数:内存地址(V)、期望值(A)、新值(B)。
- 只有当内存地址 V 中的值等于期望值 A 时,才将 V 的值更新为 B。
- 否则,不做任何操作并返回失败。
这种机制保证了在多线程环境下,只有第一个线程能成功修改共享变量,其他线程会因值不匹配而失败。
✅ 示例代码(Java 中的 AtomicInteger)
AtomicInteger atomicInt = new AtomicInteger(10);
boolean success = atomicInt.compareAndSet(10, 20);
上面代码中,只有当前值为 10 时,才会被更新为 20。
3. ABA 问题的本质
ABA 问题指的是:一个线程读取了共享变量 A,在准备更新时,该变量曾被其他线程修改为 B,又改回了 A。此时 CAS 操作会误认为该变量未被修改,从而导致逻辑错误。
❗ 为什么 ABA 是个问题?
虽然变量值最终回到了 A,但中间可能发生了某些关键操作(如释放资源、修改状态等),这些操作可能影响程序的正确性。CAS 只比较值是否相同,而无法感知中间状态的变化。
4. 实际场景举例
我们用一个银行账户类来模拟 ABA 问题。
4.1. Account 类结构
public class Account {
private AtomicInteger balance;
private AtomicInteger transactionCount;
private ThreadLocal<Integer> currentThreadCASFailureCount;
public Account(int initialBalance) {
this.balance = new AtomicInteger(initialBalance);
this.transactionCount = new AtomicInteger(0);
this.currentThreadCASFailureCount = ThreadLocal.withInitial(() -> 0);
}
// deposit 和 withdraw 方法见下文
}
4.2. 存款方法(deposit)
public boolean deposit(int amount) {
int current = balance.get();
boolean result = balance.compareAndSet(current, current + amount);
if (result) {
transactionCount.incrementAndGet();
} else {
int count = currentThreadCASFailureCount.get();
currentThreadCASFailureCount.set(count + 1);
}
return result;
}
4.3. 取款方法(withdraw)
public boolean withdraw(int amount) {
int current = balance.get();
maybeWait(); // 模拟线程延迟
boolean result = balance.compareAndSet(current, current - amount);
if (result) {
transactionCount.incrementAndGet();
} else {
int count = currentThreadCASFailureCount.get();
currentThreadCASFailureCount.set(count + 1);
}
return result;
}
private void maybeWait() {
if ("Thread-1".equals(Thread.currentThread().getName())) {
try {
Thread.sleep(2000); // 线程1暂停2秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
4.4. ABA 测试场景
我们模拟两个线程操作同一个账户:
- Thread-1:读取余额后暂停,尝试取款。
- Thread-2:在 Thread-1 暂停期间,先存款再取款,使余额恢复原值。
测试代码如下:
@Test
public void abaProblemTest() {
Account account = new Account(100);
int amountToWithdrawByThread1 = 50;
int amountToDepositByThread2 = 100;
int amountToWithdrawByThread2 = 100;
Thread thread1 = new Thread(() -> {
assertTrue(account.withdraw(amountToWithdrawByThread1));
assertEquals(50, account.getBalance());
assertTrue(account.currentThreadCASFailureCount.get() > 0); // ❌ 这里会失败
}, "Thread-1");
Thread thread2 = new Thread(() -> {
assertTrue(account.deposit(amountToDepositByThread2));
assertEquals(200, account.getBalance());
assertTrue(account.withdraw(amountToWithdrawByThread2));
assertEquals(100, account.getBalance());
assertEquals(0, account.currentThreadCASFailureCount.get());
}, "Thread-2");
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
fail();
}
assertEquals(50, account.getBalance()); // 实际余额是 50,但中间发生了两次交易
assertEquals(4, account.getTransactionCount()); // 总交易次数为 4 次
}
✅ 示例图解
5. 值类型 vs 引用类型的 ABA 问题
5.1. 值类型(如 AtomicInteger)
在值类型中,ABA 问题虽然发生,但因为值本身没有语义变化(比如整数 100 始终是 100),通常不会造成严重后果。但像上面的例子中,会导致事务统计错误。
5.2. 引用类型(如对象引用)
引用类型中,ABA 问题更危险。例如:
- 线程 A 读取一个对象引用 A。
- 线程 B 将对象 A 替换为 B,然后又换回 A。
- 此时线程 A 使用 CAS 检查引用,发现还是 A,于是操作成功。
- 但实际上,引用 A 所指向的对象可能已经被释放并重新分配,导致访问非法内存。
6. 解决方案
6.1. 垃圾回收机制(GC)
在 Java 等具备 GC 的语言中,对象不会被立即回收,只要引用还存在,就不会被复用。这在一定程度上缓解了 ABA 问题。
但这种机制并不适用于所有场景,尤其是需要手动管理内存的语言。
6.2. Hazard Pointers(危险指针)
适用于无 GC 的语言(如 C++),通过记录当前线程正在访问的指针,防止其他线程释放该内存。
6.3. 不可变对象(Immutability)
每次修改都创建新对象,避免共享对象被修改。这样 CAS 操作会失败,从而避免 ABA 问题。
6.4. 带版本号的 CAS(Double Compare and Swap)
通过引入一个版本号(或标记位),每次修改时递增版本号,使 CAS 操作同时比较值和版本号。
✅ Java 中的实现类:
AtomicStampedReference
:支持带版本号的引用比较。AtomicMarkableReference
:支持带布尔标记的引用比较。
示例:使用 AtomicStampedReference 避免 ABA
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(10, 0);
Thread thread1 = new Thread(() -> {
int expectedValue = 10;
int stamp = ref.getStamp();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
boolean success = ref.compareAndSet(expectedValue, 5, stamp, stamp + 1);
System.out.println("Thread 1 CAS success: " + success);
});
Thread thread2 = new Thread(() -> {
int expectedValue = 10;
int stamp = ref.getStamp();
ref.compareAndSet(expectedValue, 20, stamp, stamp + 1);
stamp = ref.getStamp();
ref.compareAndSet(20, 10, stamp, stamp + 1);
});
thread1.start();
thread2.start();
此时,thread1 的 CAS 会失败,因为它看到的版本号已经改变。
7. 总结
ABA 问题是无锁编程中一个经典的并发陷阱,尤其在使用 CAS 操作时容易被忽视。它可能在值类型中造成逻辑错误,在引用类型中甚至导致内存安全问题。
✅ 关键点回顾:
方案 | 说明 | 适用语言 | 是否推荐 |
---|---|---|---|
GC 机制 | 对象不会被复用 | Java、Go 等 | ✅ 对引用类型有效 |
Hazard Pointers | 手动管理指针 | C/C++ | ✅ 对无 GC 有效 |
不可变对象 | 每次修改创建新对象 | 通用 | ✅ 推荐 |
带版本号的 CAS | 比较值和版本号 | Java、C++ | ✅ 强烈推荐 |
如果你在开发中使用了 CAS 操作,尤其是涉及引用或状态变化的场景,一定要注意 ABA 问题的潜在风险。
✅ 完整源码示例:GitHub 示例地址(仅供学习参考)