CAS 与原子类
volatile 解决了可见性,却解决不了 count++ 的原子性。加锁可以,但阻塞和唤醒有成本。CAS(Compare-And-Swap)提供了一条无锁路径:用一条 CPU 指令完成"比较并交换",失败就重试。java.util.concurrent.atomic 包下的所有原子类都建立在这个原语之上。本文从硬件指令讲到 AtomicInteger 的源码,再说明高竞争下为什么 LongAdder 更合适。
一、CAS 的语义与硬件支持
CAS 包含三个操作数:内存位置 V、期望值 A、新值 B。仅当 V 的当前值等于 A 时,才把 V 更新为 B,否则不做任何事并返回失败。整个过程是一条原子指令。
x86 上对应 cmpxchg 指令,多核环境下会加上 lock 前缀,保证缓存一致性:
lock cmpxchg [addr], new_value
lock 前缀会锁住缓存行(或总线),使其他核心的缓存副本失效,从而保证读-比较-写三步不可分割。这也是 CAS 在极高竞争下并不便宜的原因:缓存行会在核心间来回弹跳。
Java 通过 sun.misc.Unsafe(JDK 9 起逐步迁移到 VarHandle)暴露这套能力:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
offset 是字段在对象中的内存偏移量,通过 objectFieldOffset 获取。
二、AtomicInteger 的实现
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe U = Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value; // volatile 保证可见性
public final int get() {
return value;
}
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}
}
incrementAndGet 的底层是一个自旋循环(getAndAddInt):
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 读当前值
} while (!weakCompareAndSetInt(o, offset, v, v + delta)); // CAS 失败就重试
return v;
}
value 必须声明为 volatile:CAS 只保证"比较并交换"这一步原子,读取当前值这一步需要 volatile 保证读到的是主内存最新值,否则自旋可能一直基于过期值失败。
JDK 9 之后,AtomicInteger 内部改用 VarHandle,但语义完全一致,只是把 Unsafe 的裸偏移量封装得更安全。
AtomicInteger 还提供了 lazySet,语义上是"最终可见":
public final void lazySet(int newValue) {
U.putIntRelease(this, VALUE, newValue); // release 语义,不保证立即刷主存
}
它比 set 便宜,因为不需要 StoreLoad 全屏障,适合"我改了,别人晚一点看到没关系"的场景,比如更新一个统计标记。但用它做锁释放或状态切换会出问题,因为后继的读可能看不到这次写。这个 API 名字里的 "lazy" 容易让人误以为只是"延迟执行",实际上它是一个弱化版的 volatile 写。
三、ABA 问题
CAS 只比较值是否相等,无法感知"值变过又变回来"。这就是 ABA 问题:
线程 1:读取 value = A
线程 2:把 A 改成 B,再改回 A
线程 1:CAS(A, C) 成功
线程 1 以为没人动过,实际上中间已经发生了变化。对单纯的计数器无所谓,但对"值代表状态或引用"的场景可能是致命的。典型例子是栈的 pop:
// 线程 1 准备 pop,读到栈顶是 A,next 是 B
// 线程 2 pop 了 A 和 B,又 push 了 A
// 线程 1 CAS 把栈顶从 A 换成 B,但 B 已经被弹出,栈结构损坏
JDK 提供了 AtomicStampedReference,给值附加一个版本号:
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
int stamp = ref.getStamp();
String value = ref.getReference();
// 只有值和版本号都匹配才更新
boolean ok = ref.compareAndSet(value, "C", stamp, stamp + 1);
另一个选择是 AtomicMarkableReference,用一个 boolean 标记代替版本号,适合"是否被删除/标记"这类二值状态。
四、常用原子类一览
| 类 | 作用 | 备注 |
| --- | --- | --- |
| AtomicInteger / AtomicLong | 整型原子操作 | 最常用 |
| AtomicBoolean | 布尔原子操作 | 一次性开关 |
| AtomicReference<T> | 对象引用原子更新 | 配合 updateAndGet 做函数式更新 |
| AtomicIntegerArray / AtomicLongArray | 数组元素原子操作 | 元素级可见性 |
| AtomicReferenceArray<E> | 对象数组原子操作 | |
| AtomicStampedReference | 带版本号的引用 | 解决 ABA |
| AtomicMarkableReference | 带标记的引用 | 二值状态 |
| LongAdder / LongAccumulator | 分片累加 | 高并发计数 |
| DoubleAdder | 浮点分片累加 | |
AtomicReference 的 updateAndGet 在 JDK 8 中是这样实现的:
public final V updateAndGet(UnaryOperator<V> updateFunction) {
V prev, next;
do {
prev = get();
next = updateFunction.apply(prev); // 注意:函数可能被多次执行
} while (!compareAndSet(prev, next));
return next;
}
updateFunction 必须是无副作用的纯函数,因为 CAS 失败时它会被重复调用。如果函数里有计数、日志、远程调用,就会执行多次。
五、LongAdder:高竞争下的替代方案
AtomicLong 的所有线程都在 CAS 同一个 value,竞争激烈时大量自旋失败,吞吐量骤降。LongAdder 采用分片思想:
LongAdder
├── base = 10
└── Cell[] cells
├── Cell[0] = 3
├── Cell[1] = 5
├── Cell[2] = 0
└── Cell[3] = 7
sum() = base + 3 + 5 + 0 + 7 = 25
每个线程根据自己的 probe 值散列到不同的 Cell 上累加,只有在散列冲突时才 CAS 同一个槽。sum() 时把 base 和所有 Cell 加起来。
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[getProbe() & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
取舍:
| 维度 | AtomicLong | LongAdder |
| --- | --- | --- |
| 低竞争 | 快,单次 CAS | 略慢,有分片判断开销 |
| 高竞争 | 大量自旋,吞吐下降 | 接近线性扩展 |
| 读取 | O(1) 精确值 | O(n) 汇总,且不精确 |
| 内存 | 一个变量 | 每个 Cell 一个缓存行(通常填充到 64 字节) |
sum() 在并发累加过程中返回的是近似值,不能用于需要强一致的场景(如库存扣减的余额判断)。适合统计 QPS、请求计数这类"最终准确即可"的指标。
LongAdder 的 sum() 不是原子的,遍历 cells 时如果有线程正在累加,可能读到某个 Cell 的旧值。sumThenReset() 同样不是原子的,并发调用会丢失部分计数。需要"读取并清零"的原子语义时,用 AtomicLong.getAndSet(0),或在外层加锁。
LongAccumulator 是更通用的版本,可以自定义累加函数:
LongAccumulator max = new LongAccumulator(Math::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(3);
System.out.println(max.get()); // 10
它要求累加函数满足交换律和结合律,因为分片累加的顺序不确定。减法、除法这类不满足交换律的运算不能直接用作累加函数。
六、可运行的对比示例
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class CounterBenchmark {
private static final int THREADS = 16;
private static final int LOOPS = 1_000_000;
public static void main(String[] args) throws Exception {
run("AtomicLong", () -> {
AtomicLong c = new AtomicLong();
runThreads(c::incrementAndGet);
return c.get();
});
run("LongAdder", () -> {
LongAdder c = new LongAdder();
runThreads(c::increment);
return c.sum();
});
run("synchronized", () -> {
long[] c = {0};
runThreads(() -> { synchronized (c) { c[0]++; } });
return c[0];
});
}
private static void runThreads(Runnable task) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
pool.execute(() -> {
try {
start.await();
for (int j = 0; j < LOOPS; j++) task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
done.await();
pool.shutdown();
}
private static void run(String name, Callable<Long> action) throws Exception {
long t0 = System.nanoTime();
long value = action.call();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("%-14s value=%-12d %d ms%n", name, value, ms);
}
}
在 16 线程、1600 万次自增下,LongAdder 通常明显快于 AtomicLong,而 synchronized 最慢。具体数字取决于机器,但趋势稳定。这个例子同时验证了结果正确性:三者最终值都等于 THREADS * LOOPS。
七、并发场景分析
自旋的 CPU 代价。CAS 失败会立即重试,竞争激烈时多个线程空转烧 CPU。这也是 AtomicLong 在高并发下吞吐反而下降的原因。缓解手段是分片(LongAdder)或退化为锁。
只保证单个变量的原子性。多个原子变量之间没有整体原子性。下面这段代码不是原子的:
AtomicInteger x = new AtomicInteger();
AtomicInteger y = new AtomicInteger();
x.incrementAndGet(); // 两个操作之间可能被其他线程插入
y.incrementAndGet();
需要多个变量一致时,用 AtomicReference 包一个不可变对象,或直接加锁。
CAS 与对象发布。用 AtomicReference 做无锁栈或队列时,节点的 next 必须在发布前设置完成。AtomicReference 的 CAS 本身提供 volatile 语义,能保证之前的写入对读到该引用的线程可见,这是无锁数据结构正确性的基础。
与 volatile 的关系。原子类的 get/set 具备 volatile 读写语义,compareAndSet 还额外保证原子性。所以用原子类时不需要再给字段加 volatile。
八、常见坑 / 最佳实践
updateAndGet的函数必须无副作用。CAS 失败会重试,函数可能执行多次。- 注意 ABA。值代表状态或引用时用
AtomicStampedReference,纯计数场景不必。 - 高并发计数优先
LongAdder,但读取是近似值,不能用于强一致判断。 - 原子类不保证多变量原子性。多个字段需要一致时用
AtomicReference包不可变对象,或加锁。 - 不要用原子类替代所有锁。临界区包含多个操作或需要条件等待时,锁更清晰也更安全。
AtomicInteger不能当锁对象。虽然能用,但包装类缓存和身份语义容易引发误用,锁对象用专用Object。- JDK 9+ 可用
VarHandle替代Unsafe,API 更安全,但原理相同。
九、小结
- CAS 是
lock cmpxchg提供的一条原子指令,Java 通过Unsafe/VarHandle调用。 AtomicInteger用volatile value加 CAS 自旋实现,value的volatile不可省略。- ABA 问题用
AtomicStampedReference加版本号解决,纯计数场景通常不需要。 LongAdder用base加Cell[]分片降低竞争,适合高并发统计,但sum()是近似值。- 原子类只保证单变量原子性,多变量一致性仍需
AtomicReference包不可变对象或加锁。 updateAndGet的更新函数会被重复执行,必须无副作用。
- 点赞
- 收藏
- 关注作者
评论(0)