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 次
}

✅ 示例图解

aba problem-1


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 示例地址(仅供学习参考)


原始标题:The ABA Problem