《Java 并发编程·线程与内存模型》8.1 CAS 原理与 ABA

从 compareAndSet 的 volatile 读写语义讲到失败重试的真实开销:用门闩固定交错,在无锁栈上把 ABA 复现成 2000/2000 次可重复的损坏,再用 AtomicStampedReference 压到 0/2000,并给出 AtomicMarkableReference 的 attemptMark 陷阱与本机实测输出。

承接的问题

第 4 章到第 7 章讲的是「让别人看不见中间状态」:要么用锁把临界区围起来,要么用 volatile 发布快照,要么把状态绑到作用域上(7.3 的 ScopedValue)。前两者的共同点是阻塞——抢不到锁就挂起,挂起就要切线程;ScopedValue 则解决的是「隐式传参」而不是互斥,它本身也不做原子更新。

CAS 走另一条路:不阻塞,失败就重试。它把「读-改-写」压成一条带条件的指令,由硬件保证「比较」与「写入」之间插不进别人。这条路带来两个新问题:失败重试要付多少成本,「值仍然是那个值」为什么不等于「状态没有变过」。前者是性能问题,后者是正确性问题,也就是 ABA——它不会让程序变慢,只会让程序悄悄给出错误结果。

先把结论放在前面:

问题结论
CAS 的原子性来自哪硬件指令(x86 lock cmpxchg,ARM LL/SC 或 LSE casal)
CAS 的内存语义volatile 读 + volatile 写,一次完整的 acquire/release
失败重试的成本与竞争线程数成正比;本机 8 线程下 80 万次自增伴随约 67 万~72 万次失败 CAS
ABA 在 Java 里何时出现节点被复用(对象池),或状态本身是值(AtomicInteger 状态机)
修复手段给引用配一个版本戳(AtomicStampedReference)或一个标记位(AtomicMarkableReference)

一条指令,两种失败

AtomicInteger.compareAndSet 在 JDK 25 里的实现只有一行:

public final boolean compareAndSet(int expectedValue, int newValue) {
    return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}

U 是 VarHandle 的门面,VALUE 是类初始化时通过 MethodHandles.lookup().findVarHandle(...) 拿到的字段句柄。HotSpot 认识这个调用,会把它内联成目标平台的一条指令:

平台指令说明
x86-64lock cmpxchglock 前缀锁缓存行,同时充当全屏障
AArch64ldaxr / stlxr 循环LL/SC,失败由 stlxr 返回非零
AArch64 + LSEcasalARMv8.1 的单条原子指令

本机没有安装 hsdis,拿不到反汇编,所以这里只给指令名不给机器码。语义不依赖具体指令,这也是第 2.3 节用 JCStress 做行为级验证、而不是比对汇编的原因。

CAS 有两种失败,必须分开:值不匹配是正常的竞争失败,重读重试即可;虚假失败是值明明匹配、指令仍然返回失败——LL/SC 架构上,ldaxr 与 stlxr 之间只要发生过任何写(哪怕写的是别的地址),stlxr 就会失败。这就是 weakCompareAndSet 存在的原因,第 8.2 节展开。

Java 的 compareAndSet 承诺「不虚假失败」,所以循环可以只依赖返回值:读当前值、算新值、CAS,失败就整段重来。但这不是无代价的:每次失败都要重新读、重新算、再发一条原子指令,而原子指令会锁缓存行,失败重试把这条缓存行在核间来回弹。

实测:8 个线程各做 10 万次手写 CAS 自增,统计失败次数:

AtomicInteger counter = new AtomicInteger();
AtomicInteger failures = new AtomicInteger();
// 8 个线程,每个线程
for (int k = 0; k < 100_000; k++) {
    for (;;) {
        int prev = counter.get();
        if (counter.compareAndSet(prev, prev + 1)) break;
        failures.incrementAndGet();
    }
}

本机两次运行:

manual CAS loop: counter=800000 (expect 800000), failed CAS=674249
manual CAS loop: counter=800000 (expect 800000), failed CAS=720863

结果是正确的 800000,但大约每 1.2 次成功就伴随 1 次失败。失败次数会随线程数超线性增长:线程越多,读到同一个旧值的线程越多,被浪费的 CAS 越多。这就是高竞争下 CAS 循环的吞吐上限,也是第 8.3 节 LongAdder 要解决的问题。

CAS 的内存语义

compareAndSet 不是一个「只保证原子」的操作,它的内存效果等价于一次 volatile 读加一次 volatile 写:

  • 读 expectedValue 时是 volatile 读,能看到之前所有已经发布的写;
  • 成功写入时是 volatile 写,写入之前的所有写对读到新值的线程可见;
  • 失败时也完成了一次 volatile 读,所以能看到别人写入的最新值。

这条语义有两个直接推论。一是 CAS 可以当发布手段:用 CAS 把构造完成的不可变对象写进字段,读到新引用的线程能看到构造器里的全部写入。二是 CAS 只保护它自己那一个变量:用 CAS 扣库存、再用另一个 CAS 扣配额,中间可能被别的线程看到「库存已减、配额未减」的半成品状态。

getAndUpdate、accumulateAndGet 看起来是「一次调用」,内部仍是 CAS 循环(第 8.2 节给源码)。传入的 lambda 可能被执行多次,所以函数必须是纯的:不要在 lambda 里打日志、计数、发消息,否则重试会重复执行。

Treiber 栈:ABA 的最小舞台

无锁栈是讲 ABA 最合适的载体,因为它的状态只有「栈顶引用」一个变量,逻辑短到可以把交错画出来。

final class Node<T> {
    final T value;
    Node<T> next;
    Node(T value) { this.value = value; }
}

final class TreiberStack<T> {
    private final AtomicReference<Node<T>> top = new AtomicReference<>();

    void push(T value) { pushNode(new Node<>(value)); }

    void pushNode(Node<T> n) {
        n.next = top.get();
        while (!top.compareAndSet(n.next, n)) {
            n.next = top.get();
        }
    }

    Node<T> peekNode() { return top.get(); }
    boolean casTop(Node<T> expected, Node<T> update) {
        return top.compareAndSet(expected, update);
    }

    T pop() {
        for (;;) {
            Node<T> old = top.get();
            if (old == null) return null;
            Node<T> next = old.next;
            if (top.compareAndSet(old, next)) return old.value;
        }
    }

    List<T> snapshot() {
        List<T> out = new ArrayList<>();
        for (Node<T> n = top.get(); n != null; n = n.next) out.add(n.value);
        return out;
    }
}

pushNode 是给「复用节点」留的入口:对象池把弹出来的节点还回去时走这里。casTop 暴露了「先读、后算、再 CAS」这个可被拆开的窗口——真实代码里这个窗口由两条指令之间的距离决定,这里用门闩把它拉到无限长,让交错百分之百发生。

ABA 复现:2000 次,2000 次损坏

场景:栈里是 n3 -> n2 -> n1(值 3、2、1)。

  1. popper 读到栈顶 n3 和 n3.next == n2,然后停住。
  2. helper 连续弹三次,拿到 3、2、1,栈变空;再把 n3 这个同一个节点对象压回去。
  3. popper 用陈旧快照 (expected = n3, update = n2) 做 CAS。

此时栈顶确实是 n3——同一个对象引用、同一个值。CAS 成功,栈顶被写成 n2。

Thread popper = new Thread(() -> {
    Node<Integer> old = stack.peekNode();
    Node<Integer> next = old.next;
    staleNode.set(old);
    read.countDown();
    await(go);
    if (stack.casTop(old, next)) out.returned.add(old.value);
});

Thread helper = new Thread(() -> {
    await(read);
    out.returned.add(stack.pop());   // 3
    out.returned.add(stack.pop());   // 2
    out.returned.add(stack.pop());   // 1
    stack.pushNode(staleNode.get()); // 复用节点,把 n3 压回
    go.countDown();
});

本机输出:

== plain Treiber stack, one interleaving ==
returned=[3, 2, 1, 3] rest=[2, 1]

helper 已经把栈弹空,只放回了 3,所以正确终态应该是 [3]。实际终态是 [2, 1]:n2 和 n1 已经被弹出、从来没有被重新压入,却重新出现在栈里,下一次 pop 会把它们再交出去一次。在节点池里,这意味着同一个节点被分配给两个使用者;在任务队列里,这意味着一个任务被执行两遍。

这个交错不是碰运气:把同一段代码跑 2000 次,每次都用门闩固定顺序,统计终态不等于 [3] 的次数:

rounds=2000 plainBad=2000 stampedBad=0

stampedBad=0 是修复后的结果,下一节说。先说 2000/2000 这个数字意味着什么:ABA 一旦条件成立,就是确定性的错误,不是概率问题。线上难复现,是因为「另一个线程恰好弹空并复用同一个节点」这个窗口通常极短。

Java 里 ABA 从哪来

C/C++ 里 ABA 的经典来源是内存分配器:节点被 free 之后,下一次 malloc 可能拿回同一个地址。Java 有 GC,对象地址不会被这样复用,所以很多人以为「Java 没有 ABA」。这个判断是错的,只是来源变了:

来源一:显式复用对象。 对象池、ThreadLocal 缓存、手写的空闲链表,都会把同一个对象重新放回结构里。只要 compareAndSet 比较的是引用,ABA 就成立。

来源二:状态本身是值。 这时连复用都不需要。AtomicInteger 做状态机,0 -> 1 -> 0 走完之后,compareAndSet(0, 2) 依然成功:

AtomicInteger state = new AtomicInteger(0);
System.out.println("baseline CAS(0,2): " + state.compareAndSet(0, 2));

state.set(0);
state.compareAndSet(0, 1);
state.compareAndSet(1, 0);
System.out.println("after 0->1->0, CAS(0,2): " + state.compareAndSet(0, 2));

本机输出:

baseline CAS(0,2): true, state=2
after 0->1->0, CAS(0,2): true, state=2

两次都返回 true,但中间状态完全不同。如果这个状态机表示「订单未支付 → 支付中 → 未支付(支付失败回滚)」,那么第二行的 CAS 会把一个已经失败过的订单推进到「已支付」。这类 bug 在代码评审里几乎看不出来,因为两行 CAS 长得一模一样。

判断标准很简单:CAS 的期望值能否区分「没变过」和「变了又变回来」。不能区分,就有 ABA 风险。

修复:给引用配一个版本戳

AtomicStampedReference 把「引用 + 整数版本戳」打包成一个整体,compareAndSet 要求两者同时匹配,成功时版本戳加一。任何一次成功写入都会让旧版本戳失效,ABA 里的「变回来」于是变成「版本戳对不上」。

final class StampedStack<T> {
    private final AtomicStampedReference<Node<T>> top =
            new AtomicStampedReference<>(null, 0);

    void pushNode(Node<T> n) {
        int[] stamp = new int[1];
        for (;;) {
            Node<T> cur = top.get(stamp);
            n.next = cur;
            if (top.compareAndSet(cur, n, stamp[0], stamp[0] + 1)) return;
        }
    }

    T pop() {
        int[] stamp = new int[1];
        for (;;) {
            Node<T> cur = top.get(stamp);
            if (cur == null) return null;
            Node<T> next = cur.next;
            if (top.compareAndSet(cur, next, stamp[0], stamp[0] + 1)) return cur.value;
        }
    }

    Node<T> peekNode() { return top.getReference(); }
    int stamp() { return top.getStamp(); }

    boolean casTop(Node<T> expected, Node<T> update, int expectedStamp) {
        return top.compareAndSet(expected, update, expectedStamp, expectedStamp + 1);
    }
}

注意 int[] stamp = new int[1] 这个别扭的写法:get(int[]) 需要一个出参容器,因为 Java 不能返回两个值。不要用 getReference() 再 getStamp() 拼一个「快照」——两次调用之间版本戳可能已经变了,拼出来的组合从来没有真实存在过。要读就一次 get(int[]) 读全。

同样的交错,同样的门闩,只把栈实现换掉:

== stamped stack, same interleaving ==
returned=[3, 2, 1, 3] rest=[]

popper 的 CAS 因为版本戳对不上而失败,走重试分支重新读:此时栈顶是 n3,但 n3.next 已经是 null(pushNode 重新设置过),于是正确地弹出 3,栈变空。2000 轮里 0 次损坏。

代价要说清楚:每个节点多一个 int 版本戳,每次 CAS 多比较一个字段,且版本戳会回绕——int 在 20 亿次更新后回到原值,理论上 ABA 会重新出现。多数业务的生命周期远短于这个数字;对每秒千万次更新的热点结构,要么自己封装 long 版本戳,要么换一种不需要版本戳的算法。

AtomicMarkableReference:只需要「一次」的场景

有些场景不关心「变过几次」,只关心「有没有被标记过」。AtomicMarkableReference 把版本戳换成一个 boolean,语义是「引用 + 标记位」。

AtomicMarkableReference<String> claim =
        new AtomicMarkableReference<>("T-2002", false);
boolean first  = claim.compareAndSet("T-2002", "T-2002", false, true);
boolean second = claim.compareAndSet("T-2002", "T-2002", false, true);

本机输出:

claim once 1st=true, 2nd=false, marked=true

这是「只允许领取一次」的标准写法:compareAndSet(ref, ref, false, true) 只有在标记位为 false 时才成功,第二次因为标记已是 true 而失败。整个判断和置位是一次原子操作,不需要额外的锁或 AtomicBoolean。

需求选择理由
检测「变过又变回来」AtomicStampedReference版本戳能区分次数
只关心「是否被处理过」AtomicMarkableReference一个 bit 足够,内存更省

但 attemptMark 不是这个语义。本机实测 attemptMark("T-1001", true) 连续两次都返回 true:它只比较引用是否等于 expectedReference,然后把标记设成 newMark,完全不管当前标记是什么。想让「重复标记」失败,必须用四参数的 compareAndSet,把期望标记写进去。

在 libraryd-conc 里落地

借书扣库存是 CAS 的典型场景:库存是一个整数,扣减是「读-改-写」,而且必须防止扣成负数。

public final class AtomicLoanService implements LoanService {
    private final BookRepository books;
    private final LoanRepository loans;
    private final ConcurrentMap<String, AtomicReference<Book>> slots = new ConcurrentHashMap<>();

    public AtomicLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
    }

    private AtomicReference<Book> slotOf(String isbn) {
        return slots.computeIfAbsent(isbn, key -> new AtomicReference<>(
                books.findByIsbn(key).orElseThrow(
                        () -> new LoanException(ErrorCode.BOOK_NOT_FOUND, key))));
    }

    /** delta 为 -1 时要求库存足够;返回 true 表示槽位已更新。 */
    private boolean tryAdjust(String isbn, int delta) {
        AtomicReference<Book> slot = slotOf(isbn);
        for (;;) {
            Book cur = slot.get();
            if (cur.stock() + delta < 0) return false;
            Book next = new Book(cur.isbn(), cur.title(), cur.stock() + delta);
            if (slot.compareAndSet(cur, next)) {
                books.save(next);
                return true;
            }
        }
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        if (!tryAdjust(isbn, -1)) {
            throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
        }
        return new Reservation("L-" + isbn + "-" + memberId, isbn, memberId,
                Instant.now().plusSeconds(30L * 24 * 3600));
    }

    @Override
    public boolean returnBook(String loanId) {
        return loans.findById(loanId).map(loan -> tryAdjust(loan.isbn(), 1)).orElse(false);
    }
}

这段代码有两个刻意的设计。

其一,CAS 的对象是不可变的 Book 记录。 AtomicReference 存的是整个 Book,扣减是「造一个新 Book 再整体替换」。没有「先改 stock 再发布」的窗口,读到引用的线程看到的一定是自洽的库存。

其二,库存和配额不是一次原子操作。 真实实现还要扣会员配额,那是另一个 CAS(或另一把锁)。两个 CAS 之间可以被别的线程看见,第 8.2 节会把这两个状态合并成一个可 CAS 的槽位。这一节先只保证「库存不为负」这一条不变量。

想验证这段代码,把 compareAndSet 换成 slot.set(next),再把并发压上去,就能看到超卖——那是第 2.1 节的形态在无锁版本里的复现。

常见错误与边界

写法问题修复
get() 后无条件 set()读和写之间没有原子性用 compareAndSet 循环
在 getAndUpdate 的 lambda 里打日志重试会重复执行副作用把副作用移到 CAS 成功之后
用 getReference() + getStamp() 拼快照两次读之间可能被改用 get(int[]) 一次读全
attemptMark 当作「只标记一次」它不检查当前标记用四参数 compareAndSet
复用节点却不加版本戳ABA 必然发生,且不可见AtomicStampedReference 或禁止复用
用 CAS 保护两个变量两次原子操作之间可被观察合并成不可变对象,或改用锁
把 CAS 循环当万能锁长临界区下重试成本爆炸长临界区用 ReentrantLock

什么时候不要用 CAS:临界区超过十几条指令时,失败重试的浪费会超过锁的挂起成本;需要等待条件成立时,CAS 只会空转而不会让线程睡眠,应该用 Condition 或 park/unpark(第 9.1 节);需要保护多个字段时,一个 CAS 只能原子地改一个位置;竞争极其激烈又只需要计数时,那是 LongAdder 的领域。

小结

  • compareAndSet 的原子性来自硬件指令,内存语义等价于一次 volatile 读加一次 volatile 写;失败重试是固有成本,本机 8 线程 80 万次自增伴随约 67 万~72 万次失败 CAS。
  • ABA 的本质是「期望值无法区分没变过与变了又变回来」。Java 里它来自节点复用或值状态机,一旦触发就是确定性错误:用门闩固定交错后 2000/2000 次复现。
  • AtomicStampedReference 用「引用 + 版本戳」把 ABA 压到 0 次;读版本戳必须用 get(int[]),不能分两步取。AtomicMarkableReference 适合「只处理一次」,但 attemptMark 不检查当前标记,正确写法是四参数 compareAndSet。
  • AtomicLoanService 用「不可变 Book + CAS 整体替换」保证库存不为负;库存与配额的一致性需要另想办法,下一节给出。

下一节把原子类家族铺开:基本类型、引用类型、数组、字段更新器各自解决什么问题,以及 weakCompareAndSet 在 JDK 25 里到底是什么语义。

阅读导航:上一节 7.3 ScopedValue:JDK 25 的作用域值 | 下一节 8.2 原子类家族与字段更新器

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收