Java 基础体系 · 第 65/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java Atomic 与 VarHandle:CAS、ABA、内存顺序和无锁边界

java.util.concurrent.atomicVarHandle 都提供了不依赖显式互斥锁的原子访问能力,但“能调用 CAS”并不等于“程序就是无锁的”。要正确使用它们,至少要分清四件事:

  1. 一次读写是否具有原子性;
  2. 多线程之间是否能够看见彼此的写入;
  3. 读写之间具有什么内存顺序;
  4. 竞争失败后,算法是否仍然能够取得整体进展。

本文以 Java 25 LTS 的 Java 内存模型和标准 API 为基础,逐步说明 Atomic、VarHandle、CAS、ABA 和无锁算法之间的关系。

一、先分开三个容易混淆的概念

1. 原子性:一次操作不能被拆开观察

如果多个线程同时对一个 AtomicInteger 调用 incrementAndGet(),每次递增都是一个不可分割的原子操作:

AtomicInteger counter = new AtomicInteger();

counter.incrementAndGet();

两个线程分别执行一次后,结果一定是 2,不会因为读改写交错而得到 1

但下面的操作不是原子的:

counter.set(counter.get() + 1);

它包含三个步骤:

读取旧值
计算旧值 + 1
写回新值

两个线程可能产生如下交错:

初始值:0

线程 T1:读取 0
线程 T2:读取 0
线程 T1:计算 1,写回 1
线程 T2:计算 1,写回 1

最终值:1

原子类保证的是单次原子方法的语义,不会自动把多个方法组合成一个事务。

2. 可见性:一个线程的写入能否被另一个线程观察到

class Task {
    boolean done;
}

线程 A 执行:

task.done = true;

线程 B 执行:

while (!task.done) {
    // 等待
}

如果 done 不是 volatile,也没有锁、线程启动、线程结束或其他同步关系,Java 内存模型不要求线程 B 必须及时观察到 true

volatile 可以建立可见性:

volatile boolean done;

原子类的读取和更新通常也带有相应的 volatile 内存语义,但这不表示所有相关字段都自动变成可见的。可见性必须沿着明确的同步关系传播。

3. 内存顺序:哪些读写必须排在另一些读写之前

即使一个写入最终能被另一个线程看到,仍然需要确定它之前的普通写入是否也能被正确观察。

例如:

int data;
volatile boolean ready;

void writer() {
    data = 42;
    ready = true;
}

void reader() {
    if (ready) {
        System.out.println(data);
    }
}

根据 Java 内存模型,线程 A 对 ready 的 volatile 写,与线程 B 读取到该写入值的 volatile 读之间建立 happens-before 关系。因此,data = 42 位于 release 一侧,读取 ready 后的代码位于 acquire 一侧,线程 B 能够看到 data 的写入。

这里需要注意两个条件:

  1. 写入 data 必须发生在发布 ready 之前;
  2. 读取方必须真正读取到相应的发布结果,而不是只读取到旧值。

因此,原子性、可见性和顺序是三个不同维度:

问题 解决手段示例
一次加法不能被打断 AtomicInteger.incrementAndGet()
写入可以被其他线程观察 volatile、release/acquire、锁
复杂状态转换不能被并发破坏 CAS、锁、事务性协议

二、Atomic 类解决什么问题

java.util.concurrent.atomic 提供针对常见类型和引用的原子操作:

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference<T>
  • AtomicStampedReference<V>
  • AtomicMarkableReference<V>
  • LongAdder
  • LongAccumulator
  • 以及面向字段更新的 AtomicIntegerFieldUpdater

AtomicInteger 的核心是“读—计算—条件写回”

下面的 incrementAndGet() 可以抽象为:

for (;;) {
    int oldValue = value;
    int newValue = oldValue + 1;

    if (compareAndSet(oldValue, newValue)) {
        return newValue;
    }
}

实际 JDK 实现可能使用 VarHandle 或其他内部机制,不能把上面的伪代码当成规定的实现方式,但它准确表达了 CAS 更新的逻辑。

CAS 是 Compare-And-Set 的缩写:

如果当前值 == expected,
    就写入 update 并返回成功;
否则,
    不写入并返回失败。

形式化地说,设内存位置为 L,期望值为 E,新值为 U

CAS(L, E, U):
    若 L == E:
        L := U
        返回 true
    否则:
        L 保持不变
        返回 false

这个“比较和写入”必须作为一个不可分割的原子动作执行。

CAS 的线性化点

并发算法经常需要说明某个操作“在哪一刻生效”。这个时刻称为线性化点。

对于成功的 CAS,通常线性化点就是 CAS 成功的瞬间:

T1 读取 old = 10
T2 把值从 10 改为 11
T1 CAS(10, 20) 失败
T1 重新读取 11
T1 CAS(11, 21) 成功

T1 的更新逻辑没有覆盖 T2 的更新,因为它只允许从自己观察到的版本 11 转换到 21

这也是 CAS 比“先读后写”安全的原因:写入时再次验证了读到的状态仍然有效。

Atomic 方法中的函数可能执行多次

以下代码看似只是传入一个函数:

AtomicInteger value = new AtomicInteger(0);

value.updateAndGet(x -> x + 1);

在竞争下,函数可能被多次调用:

T1 读取 0,计算 1
T2 读取 0,计算 1,CAS 成功
T1 CAS 失败
T1 重新读取 1,再次调用函数,计算 2,CAS 成功

因此,更新函数应当是无副作用的:

value.updateAndGet(x -> {
    auditLog.append(x); // 错误:竞争失败时可能重复记录
    return x + 1;
});

如果副作用必须只发生一次,就不能把它直接放入可能重试的计算函数中。应当先完成原子状态转换,再根据成功结果处理外部副作用,或者使用其他事务协调机制。

三、VarHandle 是什么

VarHandle 是对变量、数组元素或字段进行多种访问模式操作的统一句柄。它不是某个具体变量的值,而是描述“如何访问某个变量”的对象。

它可以访问:

  • 实例字段;
  • 静态字段;
  • 数组元素;
  • 某些字节缓冲区或其他受支持的变量坐标。

VarHandle 将“变量位置”和“内存访问模式”分开,使开发者可以在普通、opaque、acquire/release、volatile 等顺序之间选择。

下面是一个可运行的发布示例:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class VarHandlePublicationDemo {
    private static int data;
    private static boolean ready;

    private static final VarHandle READY;

    static {
        try {
            READY = MethodHandles.lookup()
                    .findStaticVarHandle(
                            VarHandlePublicationDemo.class,
                            "ready",
                            boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            data = 42;
            READY.setRelease(true);
        });

        Thread reader = new Thread(() -> {
            while (!(boolean) READY.getAcquire()) {
                Thread.onSpinWait();
            }

            System.out.println(data);
        });

        reader.start();
        writer.start();

        writer.join();
        reader.join();
    }
}

预期输出:

42

关键路径是:

写线程:data = 42
写线程:setRelease(ready, true)

读线程:getAcquire(ready) 读到 true
读线程:读取 data

如果 getAcquire() 读到了 setRelease() 发布的值,那么 release/acquire 关系保证写线程在发布前的普通写入对读线程可见。

代码中的几个失败点也需要明确:

  • findStaticVarHandle 找不到字段或类型不匹配时,会抛出反射相关异常;
  • setReleasegetAcquire 的坐标、类型必须匹配,否则可能抛出 WrongMethodTypeException
  • Thread.onSpinWait() 只是向运行时提示当前线程处于自旋等待,不会提供同步语义;
  • 如果没有任何线程最终写入 ready = true,读线程会一直自旋。

四、VarHandle 的内存访问模式

对同一个变量,VarHandle 可以使用不同强度的访问模式。

Plain:普通访问

handle.get(obj);
handle.set(obj, value);

这类似普通字段读写。它不主动建立跨线程同步关系。

如果多个线程并发读写同一个普通变量,且没有其他 happens-before 关系,就可能构成数据竞争。对于复杂对象发布,不能因为“最终大概率读得到”就认为是正确的并发协议。

Opaque:比普通访问更强,但弱于 acquire/release

handle.getOpaque(obj);
handle.setOpaque(obj, value);

Opaque 访问提供比 plain 更强的可观察性和访问一致性约束,但不提供完整的 acquire/release 顺序保证。它适合非常特定的低级协议,例如只需要对某个状态值进行较弱协调,而不要求通过该状态发布一整组之前的普通写入。

不能把 opaque 当作“便宜版 volatile”,也不能把 setOpaquegetOpaque 自动当成对象安全发布协议。

Release / Acquire:单向发布和获取

handle.setRelease(obj, value);
handle.getAcquire(obj);

release 约束发布方此前的操作;acquire 约束获取方此后的操作。典型用途是:

生产者先初始化数据,再 release 写入状态
消费者 acquire 读取状态成功后,再读取数据

它们适用于单向发布协议,通常比双方都使用 volatile 更精确。但正确性依赖协议本身,不能只替换某一个访问方法而忽略状态生命周期。

Volatile:最强的常用变量访问语义

handle.getVolatile(obj);
handle.setVolatile(obj, value);

volatile 访问具有更强的跨线程顺序和可见性保证。VarHandle 的普通 CAS 方法也使用相应的 volatile 访问语义;如果需要更弱或更明确的顺序,可以选择带后缀的变体。

常见方法大致可以这样理解:

操作 语义
get / set plain
getOpaque / setOpaque opaque
getAcquire / setRelease acquire/release
getVolatile / setVolatile volatile
compareAndSet 原子条件更新,volatile 语义
compareAndExchangeAcquire 原子交换,获取语义
compareAndExchangeRelease 原子交换,释放语义
weakCompareAndSetPlain 原子条件更新,但使用指定的较弱内存语义

精确选择访问模式前,应先写清楚通信协议:哪个线程发布、哪个线程获取、哪个值表示“发布完成”、失败路径如何处理。只根据性能直觉选择模式,通常会得到难以诊断的数据竞争。

五、CAS 不只是一条指令

在常见硬件上,CAS 可能映射到处理器提供的原子指令;但 Java 规范保证的是 API 的原子语义,不保证每个平台都使用某一条特定机器指令。

一个 CAS 更新通常包含:

1. 读取当前状态;
2. 根据状态计算候选新状态;
3. 原子比较;
4. 比较成功则写入;
5. 失败则重试或返回失败。

因此,CAS 算法的正确性不仅取决于“比较和写入是否原子”,还取决于:

  • 比较的状态是否足够完整;
  • 状态是否可能被修改后又恢复;
  • 重试是否会无限失败;
  • 失败时是否会丢失其他线程的更新;
  • 更新函数是否可以安全重复执行。

compareAndSet 返回布尔值,适合明确判断成功或失败:

AtomicInteger state = new AtomicInteger(0);

if (state.compareAndSet(0, 1)) {
    System.out.println("抢占成功");
} else {
    System.out.println("状态已经被其他线程改变");
}

compareAndExchange 则返回观察到的旧值:

int observed = state.compareAndExchange(0, 1);

if (observed == 0) {
    System.out.println("更新成功");
} else {
    System.out.println("实际观察到:" + observed);
}

这可以减少一次额外读取,尤其适合需要同时处理失败状态的算法。

weakCompareAndSet 允许伪失败,即使当前值等于期望值,也可能返回失败。因此它通常必须放在重试循环中:

for (;;) {
    int oldValue = state.get();
    int newValue = oldValue + 1;

    if (state.weakCompareAndSet(oldValue, newValue)) {
        break;
    }

    Thread.onSpinWait();
}

如果算法不能容忍伪失败,就应使用强 CAS,也就是通常的 compareAndSet

六、ABA:值相同不代表状态没有变化

ABA 是 CAS 算法中的经典问题。

假设共享引用 top 初始指向节点 A:

top -> A -> B

线程 T1 执行:

oldTop = top;          // 读到 A
next = oldTop.next;    // 读到 B

T1 暂停。

线程 T2 依次执行:

pop A:top 从 A 改为 B
pop B:top 从 B 改为 null
push A:top 从 null 改回 A

此时结构可能变成:

top -> A

T1 恢复并执行:

CAS(top, A, B)

CAS 只比较当前值是否仍然是 A。它看到的是:

T1 之前读取:A
当前值:     A

于是 CAS 成功。但 T1 读取的 B 已经被 T2 移除了,T1 把一个过期的链表后继重新写回了 top

top -> B

B 可能已经不属于当前栈,甚至在更复杂的手动内存管理环境中已经被回收或复用。

问题不在于 CAS 错误,而在于 CAS 的比较条件太弱:

只验证了“现在还是 A”
没有验证“期间是否发生过 A -> B -> A”

使用 AtomicStampedReference 记录版本

AtomicStampedReference<V> 将引用和整数版本号组合起来比较:

状态 = (引用, stamp)

上面的变化变成:

(A, 10)
(B, 11)
(null, 12)
(A, 13)

T1 最初读到 (A, 10),恢复后执行:

CAS((A, 10), (B, 11))

当前状态是 (A, 13),引用虽然仍是 A,但版本不同,因此 CAS 失败。

下面是一个完整的、可编译的带版本栈示例:

import java.util.concurrent.atomic.AtomicStampedReference;

public class StampedStackDemo<E> {
    private static final class Node<E> {
        final E value;
        final Node<E> next;

        Node(E value, Node<E> next) {
            this.value = value;
            this.next = next;
        }
    }

    private final AtomicStampedReference<Node<E>> top =
            new AtomicStampedReference<>(null, 0);

    public void push(E value) {
        Node<E> newNode;

        for (;;) {
            int[] stampHolder = new int[1];
            Node<E> oldTop = top.get(stampHolder);
            int oldStamp = stampHolder[0];

            newNode = new Node<>(value, oldTop);

            if (top.compareAndSet(
                    oldTop,
                    newNode,
                    oldStamp,
                    oldStamp + 1)) {
                return;
            }

            Thread.onSpinWait();
        }
    }

    public E pop() {
        for (;;) {
            int[] stampHolder = new int[1];
            Node<E> oldTop = top.get(stampHolder);

            if (oldTop == null) {
                return null;
            }

            int oldStamp = stampHolder[0];
            Node<E> newTop = oldTop.next;

            if (top.compareAndSet(
                    oldTop,
                    newTop,
                    oldStamp,
                    oldStamp + 1)) {
                return oldTop.value;
            }

            Thread.onSpinWait();
        }
    }

    public static void main(String[] args) {
        StampedStackDemo<Integer> stack = new StampedStackDemo<>();

        stack.push(1);
        stack.push(2);

        System.out.println(stack.pop()); // 2
        System.out.println(stack.pop()); // 1
        System.out.println(stack.pop()); // null
    }
}

每次成功修改 top 都递增版本号,因此引用恢复为同一个对象时,版本仍然不同。

但版本号不是无限的。AtomicStampedReference 使用 int 版本,理论上经过足够多次修改后会溢出并再次出现相同版本。如果系统生命周期、修改次数或攻击模型使这种可能性具有现实意义,就需要更宽的版本设计、不可复用的对象身份、锁或其他内存回收方案。

AtomicMarkableReference 解决的是另一类问题

AtomicMarkableReference<V> 将引用和一个布尔标记组合:

(引用, marked)

它适合表示“某引用是否已经被逻辑删除”等一次性状态转换,但布尔标记不能记录任意多次版本变化。因此,它不是 AtomicStampedReference 的等价替代。

七、AtomicReference 不会保护对象内部字段

下面的代码只保证 reference 这个引用的原子更新:

AtomicReference<Account> reference =
        new AtomicReference<>(new Account());

它不保证:

reference.get().balance += 100;

是原子的。

如果多个线程取得同一个 Account 对象并修改其普通字段,仍然需要对字段本身建立同步协议。可以选择:

AtomicReference<Account> account;

并使用不可变对象整体替换:

for (;;) {
    Account oldAccount = account.get();
    Account newAccount = oldAccount.deposit(100);

    if (account.compareAndSet(oldAccount, newAccount)) {
        break;
    }
}

这里的安全性来自:

  1. Account 对象创建后不再修改;
  2. 每次更新都基于一个完整快照;
  3. CAS 确认快照仍然是当前值;
  4. 失败后重新读取最新快照。

如果 Account 内部仍然可变,引用 CAS 只能保护“换对象”这一步,不能保护对象内部的复合操作。

八、用 VarHandle 自己构造 CAS 状态机

VarHandle 适合需要直接控制字段访问模式的低级组件。例如,一个状态机可以把状态存放在普通字段中,再通过 VarHandle 进行原子转换:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class VarHandleStateDemo {
    private static final int NEW = 0;
    private static final int RUNNING = 1;
    private static final int STOPPED = 2;

    private int state = NEW;

    private static final VarHandle STATE;

    static {
        try {
            STATE = MethodHandles.lookup()
                    .findVarHandle(
                            VarHandleStateDemo.class,
                            "state",
                            int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public boolean start() {
        return STATE.compareAndSet(this, NEW, RUNNING);
    }

    public boolean stop() {
        return STATE.compareAndSet(this, RUNNING, STOPPED);
    }

    public int state() {
        return (int) STATE.getVolatile(this);
    }

    public static void main(String[] args) {
        VarHandleStateDemo demo = new VarHandleStateDemo();

        System.out.println(demo.start()); // true
        System.out.println(demo.start()); // false
        System.out.println(demo.stop());  // true
        System.out.println(demo.state()); // 2
    }
}

start() 的两个线程同时执行时,最多只有一个线程能把 NEW 改成 RUNNING

线程 T1:CAS(NEW, RUNNING) 成功
线程 T2:CAS(NEW, RUNNING) 失败,因为当前已经是 RUNNING

这里的状态转换具有明确的合法路径:

NEW -> RUNNING -> STOPPED

而不是允许任意写入:

STATE.setVolatile(this, STOPPED);

如果外部代码能够绕过状态机直接写字段,CAS 设计的前置条件就失效了。低级并发组件必须封装字段访问,否则其他代码可能写入算法未预期的状态。

九、内存顺序与 CAS 的组合

原子更新和发布数据经常同时出现。例如生产者要发布一个已经初始化的对象:

class MessageBox {
    private Object message;
    private boolean published;

    private static final VarHandle PUBLISHED;

    static {
        try {
            PUBLISHED = MethodHandles.lookup()
                    .findVarHandle(MessageBox.class, "published", boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    void publish(Object value) {
        message = value;
        PUBLISHED.setRelease(this, true);
    }

    Object receive() {
        if ((boolean) PUBLISHED.getAcquire(this)) {
            return message;
        }
        return null;
    }
}

如果 receive() 返回非空,说明 acquire 读取到了 release 发布的状态,此时 message 的初始化写入也被发布。

但是下面这种写法可能破坏协议:

void publish(Object value) {
    PUBLISHED.setRelease(this, true);
    message = value; // 太晚,未被 release 发布
}

读线程可能看到 published == true,却不能依赖该 release 操作看到后续的 message 写入。

另一个常见错误是只对数据使用 CAS,却没有定义“数据已经初始化”的状态含义:

reference.compareAndSet(null, object);

如果 object 的构造和内部可变字段的后续初始化没有遵守安全发布规则,那么“引用成功写入”并不等于对象的所有逻辑状态都已经可见。通常应在对象构造完成后再发布,并且对象发布过程使用满足要求的原子或 volatile 语义。

十、无锁、无等待和非阻塞不是同一个词

Lock-free:整体持续取得进展

无锁算法通常指一种进度保证:

无论单个线程是否被挂起,系统中总有某个线程能够在有限步骤内完成操作。

一个典型 CAS 循环是:

for (;;) {
    int oldValue = value.get();
    int newValue = calculate(oldValue);

    if (value.compareAndSet(oldValue, newValue)) {
        return newValue;
    }
}

当 CAS 失败时,通常意味着其他线程成功更新了共享状态。因此,只要竞争线程持续运行,整体可能持续前进。

但这不是对每个线程公平的保证。某个线程可能持续失败:

T1 成功
T2 失败
T1 成功
T2 失败
……

这称为饥饿或不公平,系统整体可能是 lock-free,但 T2 可能长期得不到进展。

Wait-free:每个线程都有有限上界

Wait-free 更强,要求每个线程都能在有限且通常有上界的步骤内完成操作,不会因为其他线程持续竞争而无限重试。

普通 CAS 自旋循环通常不能直接证明为 wait-free。

Obstruction-free:没有竞争时能完成

Obstruction-free 只要求某个线程独占运行时能够完成。如果其他线程持续竞争,它可能一直失败。

Java API 不自动承诺整个程序是 lock-free

即使底层 AtomicReference 使用了原子硬件操作,整个操作仍可能受到以下因素影响:

  • CAS 循环无限重试;
  • 线程被操作系统调度出去;
  • 线程因安全点、GC 或运行时事件暂停;
  • 算法在重试中分配对象;
  • 访问内存时发生缓存未命中、缺页或其他系统延迟;
  • 失败路径调用了阻塞队列、日志、锁或 I/O。

因此,“算法没有显式 synchronized”只说明源码中没有使用某种锁,不等于整个组件满足严格的无锁进度保证。

某些 Atomic 类提供 isLockFree(),它用于报告当前实现是否具有该类操作的无锁属性,但这仍然不能证明由多个原子操作组成的高层算法是 lock-free,更不能证明业务流程不会阻塞。

十一、自旋 CAS 的边界

自旋适合非常短、竞争有限的更新:

for (;;) {
    int oldValue = counter.get();
    if (counter.compareAndSet(oldValue, oldValue + 1)) {
        return;
    }
    Thread.onSpinWait();
}

它的失败原因通常是:

读取旧值
其他线程先更新
CAS 发现期望值过期
重新读取

但如果临界逻辑很长,或者竞争激烈,自旋会持续消耗 CPU。此时互斥锁虽然会造成线程阻塞,却可能通过挂起竞争线程减少 CPU 浪费。

CAS 也不能自动解决高竞争计数器的缓存一致性争用。多个线程频繁更新同一个原子变量时,变量所在缓存行会在处理器之间反复转移。统计场景通常可以考虑 LongAdder,但它牺牲了某些“读取瞬间的精确单点值”特性,不适合需要精确 CAS 状态转换的场景。

十二、常见错误及其失败表现

错误一:用 AtomicInteger 替代整个复合条件

if (stock.get() > 0) {
    stock.decrementAndGet();
}

两个线程都可能先看到库存为 1,然后都执行递减。修正方式是把“检查和扣减”放进一次 CAS:

for (;;) {
    int oldStock = stock.get();

    if (oldStock == 0) {
        throw new IllegalStateException("库存不足");
    }

    if (stock.compareAndSet(oldStock, oldStock - 1)) {
        break;
    }
}

这里 oldStock == 0 是基于本次读取的判断;如果期间库存被其他线程改变,CAS 会失败,循环重新判断。

错误二:把 volatile 当作复合操作原子性

volatile int count;

count = count + 1;

volatile 只强化了读写的可见性和顺序,不会把读、加一、写回合并为原子操作。

错误三:忽略 ABA

AtomicReference<Node> top;

如果节点引用可能被移除后重新放回,单独比较引用可能无法发现中间变化。根据算法需要选择:

  • 版本戳;
  • 标记位;
  • 不可变快照;
  • 不复用节点;
  • 锁;
  • 更完整的生命周期和内存回收协议。

错误四:在 CAS 更新函数中执行副作用

state.updateAndGet(old -> {
    metrics.increment();
    return old + 1;
});

竞争失败时函数可能重复执行,导致指标多记、消息重复发送或审计记录重复。

错误五:把“CAS 成功”理解成“业务操作成功”

CAS 只表示共享状态从某个期望值转换到了新值。它不代表:

  • 数据库事务已经提交;
  • 消息已经持久化;
  • 网络调用已经成功;
  • 外部副作用只执行了一次;
  • 其他对象的状态与该变量一致。

如果原子状态和外部系统之间需要一致性,就必须设计补偿、幂等、事务消息或其他跨边界协议。

十三、如何验证并发算法

并发错误往往无法通过一次运行复现。验证时应同时检查状态不变量和进度性质。

例如对无锁栈,可以验证:

1. push 后元素最终可被 pop;
2. 同一个元素不会被两个线程成功 pop;
3. 链表不会出现断链;
4. 空栈 pop 返回 null;
5. 竞争失败不会丢失已提交更新;
6. ABA 防护状态不会被错误接受。

仅依赖:

for (int i = 0; i < 1_000_000; i++) {
    // 并发执行
}

不能证明算法正确,因为“没有复现”不是内存模型层面的证据。应结合:

  • jcstress 等并发测试工具;
  • 随机调度和人为屏障;
  • 状态不变量检查;
  • 失败重试次数和成功率统计;
  • 线程转储与 CPU 采样;
  • 高竞争和长时间运行测试。

诊断自旋问题时,重点观察:

CAS 失败率是否很高
单次重试工作是否过长
是否存在固定线程长期饥饿
是否在重试路径分配对象或执行 I/O
是否把阻塞调用隐藏在更新函数中

十四、Atomic、VarHandle 与锁的取舍边界

Atomic 类适合常见、边界清晰的单变量原子状态:

AtomicInteger state;
AtomicReference<Node> head;

VarHandle 适合需要以下能力的组件:

  • 直接操作字段或数组元素;
  • 对不同路径选择 plain、opaque、acquire/release 或 volatile;
  • 实现自定义状态机;
  • 减少为一个字段额外包装 Atomic 对象;
  • 构造底层并发数据结构。

锁更适合以下情况:

  • 一次需要维护多个变量的一致性;
  • 操作包含复杂条件和多个步骤;
  • 需要阻塞等待、条件队列或公平性;
  • 竞争时间较长;
  • 算法难以证明不会丢失更新或出现 ABA;
  • 外部副作用必须与状态变化放在同一个明确的协调范围内。

“无锁”不是天然更快,也不是天然更可靠。它减少了某类互斥阻塞,却把复杂性转移到了状态表示、内存顺序、重试、饥饿、ABA 和故障恢复上。

结语

Atomic 类提供的是经过封装的原子状态操作;VarHandle 提供的是对变量位置和内存顺序的细粒度控制;CAS 提供的是“只有状态仍然符合预期时才提交更新”的条件转换。

要构造正确的并发算法,需要完整回答以下问题:

共享状态是什么?
一次成功操作的线性化点在哪里?
CAS 比较的状态是否足够完整?
状态是否可能发生 ABA?
发布方和获取方通过什么内存顺序通信?
CAS 失败后如何重试?
重试函数是否允许重复执行?
单个线程是否可能饥饿?
整个业务操作是否包含阻塞或外部副作用?

只有这些问题同时有明确答案时,代码才不仅是“用了 Atomic”或“用了 CAS”,而是真正具有可验证的并发语义。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。