JAVA · Vol.II · DAY 21 · 并发编程
CAS 与原子操作:AtomicInteger 到 LongAdder
面试官:不加锁怎么保证原子性?
在并发编程中,synchronized 和 Lock 是悲观策略——假定冲突一定会发生,所以先加锁再操作。而 CAS(Compare And Swap)是乐观策略——假定冲突很少发生,更新时检查是否被别人改过,没改过就原子更新,改过就重试。
基于 CAS 实现的原子类(AtomicInteger、AtomicLong、AtomicReference 等)是 Java 并发工具箱中最高效的同步手段。它们无需加锁、无需线程上下文切换,在低竞争场景下性能远超锁方案。但 CAS 并非银弹——ABA 问题、自旋开销、只能保证单个变量原子性是它的三大局限。
本文从 CPU 指令级原理出发,逐层拆解 CAS → AtomicInteger → ABA 问题 → Unsafe → LongAdder,构建完整的无锁并发知识体系。
CPU 指令 CMPXCHG → CAS 算法 → AtomicInteger 源码 → ABA 问题与解决方案 → Unsafe 与 VarHandle → LongAdder 分段优化 → 生产打点实践
CAS 原理:从 CPU 指令到 Java API
CAS 的全称是 Compare And Swap(比较并交换),它是一条 CPU 原子指令(x86 上是 CMPXCHG),能在单条指令内完成"读-比较-写"三步操作,不会被其他线程打断。
CAS 的算法逻辑用伪代码表示极其简洁:
// 原子操作:不可被中断
boolean CAS(int* addr, int expected, int newValue) {
if (*addr == expected) {
*addr = newValue;
return true; // 交换成功
} else {
return false; // 值已被其他线程修改
}
}
三个操作数:内存地址 V、预期旧值 E、新值 N。仅当 V 处的值等于 E 时,才将 V 更新为 N;否则不做任何操作。整个过程是原子的。
在 Java 中,CAS 通过 sun.misc.Unsafe 类暴露给开发者:
Unsafe unsafe = Unsafe.getUnsafe();
int offset = unsafe.objectFieldOffset(MyClass.class.getDeclaredField("count"));
// 对 obj.count 执行 CAS:如果当前值等于 0,则改为 1
boolean success = unsafe.compareAndSwapInt(obj, offset, 0, 1);
// 典型用法:CAS 自旋循环(spin loop)
int oldVal;
do {
oldVal = obj.count; // 读取当前值
} while (!unsafe.compareAndSwapInt(obj, offset, oldVal, oldVal + 1));
// 如果 CAS 失败(别人在我读和写之间改了值),重试
CMPXCHG 的硬件真相:一条指令怎么做到「不可打断」
第 2 站说「读-比较-写三步原子完成」,但 CPU 是多核的,别的核凭什么不插进来?答案在 lock 前缀指令——和「volatile 深入」篇讲的同一个东西:
- x86 上,Java 的
compareAndSwapInt最终编译成lock cmpxchg。普通cmpxchg只保证「单核内不被打断」,加上lock前缀后,硬件保证整个操作期间独占这条缓存行:其他核要么看不到操作前的旧值、要么看到操作后的新值,不存在中间态。 - 独占的手段就是缓存一致性协议(MESI):执行
lock cmpxchg的核先把缓存行置为 Modified 状态并向总线广播「我要独占」,其他核的副本作废——和 volatile 写的「锁缓存行 + 广播失效」是同一套硬件机制。
所以 CAS 的原子性不是 JVM 的魔法,是硬件承诺:一条带 lock 前缀的指令,在一个 CPU 核上执行期间,别的核对该地址的访问被硬件排队。这也解释了为什么 CAS 快又稳:没有软件锁的「申请-排队-唤醒」流程,失败的成本只是一条指令返回 false,线程连停都不用停,继续下一次 CAS 即可。
AtomicInteger 结构:volatile + CAS 的标配组合
AtomicInteger 是 CAS 最经典的应用。它的全部核心建立在两个要素之上:volatile 保证可见性,CAS 保证原子性——两者缺一不可:
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 AtomicInteger(int initialValue) { value = initialValue; }
public final int get() { return value; } // volatile 读,无需加锁
}
为什么 value 必须是 volatile?CAS 指令本身只保证「比较-交换」这一步原子,不保证你读到的 oldVal 是最新值——如果读取走的是普通语义,可能拿到缓存里的旧值,拿旧值去做 CAS 的 expected,白白失败甚至误判。volatile 读保证「每次读的都是最新」,volatile 写保证「CAS 成功后别人立刻看得到新值」。一句话:CAS 管「改的原子性」,volatile 管「读的及时性」。
循环 CAS 详解:getAndIncrement 为什么是 while 循环
逐行拆解 getAndIncrement()(等价于 i++)的实现:
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
// Unsafe.getAndAddInt 的实现(JDK 8 中在 AtomicInteger 内部展开):
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // ① 读当前值(volatile 语义)
} while (!compareAndSwapInt(o, offset, v, v + delta));
// ② CAS: 如果当前值仍为 v,则改为 v+1
// ③ 失败说明别人改了,回到 ① 重新读
return v; // 返回旧值
}
这个 do-while 就是经典的 CAS 自旋循环(spin loop)。线程不断尝试,直到 CAS 成功为止。在竞争不激烈时,通常一两次就能成功,性能远优于加锁。
// compareAndSet:直接暴露 CAS 语义
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}
// incrementAndGet:等价于 ++i,返回新值
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
// getAndSet:原子设置新值并返回旧值
public final int getAndSet(int newValue) {
return U.getAndSetInt(this, VALUE, newValue);
}
// updateAndGet:函数式更新(JDK 8+)
public final int updateAndGet(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get();
next = updateFunction.applyAsInt(prev);
} while (!compareAndSet(prev, next));
return next;
}
注意 updateAndGet 把「怎么算新值」也放进了循环:如果 next 的计算依赖 prev 的最新值,就必须每轮重试都重新算——这和第 2 站伪代码里「读取→计算→CAS」是一个模式:期望值(expected)从哪来,哪一步就必须放进循环里重做。
CAS vs 锁:同一件事的两种性格
| 维度 | AtomicInteger(CAS) | synchronized |
|---|---|---|
| 加锁 | 无锁(乐观) | 有锁(悲观) |
| 竞争低时 | 极快(1~2 次 CAS) | 有 monitor 操作开销 |
| 竞争高时 | 自旋浪费 CPU | park 让出 CPU,更合理 |
| 阻塞 | 不会阻塞线程 | 可能阻塞(上下文切换) |
| 原子变量数 | 只能保证一个变量 | 可保护多个变量 |
| 可中断/超时 | 不支持(自旋直到成功) | lockInterruptibly / tryLock(timeout) |
选型的心法就一句话:临界区越短、竞争越低,CAS 越划算;临界区越长、竞争越高,锁越划算。临界区长意味着每次 CAS 失败后「重新读+重算」的窗口大、失败率高,自旋烧掉的 CPU 比 park 一次的代价高得多——这是「乐观 vs 悲观」的本质:赌冲突发生的概率。
自旋的艺术:什么时候值得空转,什么时候该睡觉
CAS 的失败处理方式是「自旋重试」——线程不睡、不停,原地转圈再试。自旋是把双刃剑,什么时候值得转、什么时候该去睡,是这一篇最容易答浅的地方:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 临界区极短(几条指令)、竞争低 | 自旋(CAS) | 冲突窗口小,通常 1~2 次 CAS 就过;park/unpark 的上下文切换(几十~几百微秒)反而是大开销 |
| 临界区短但竞争很高(16+ 线程抢同一变量) | 分散竞争(LongAdder)或退避自旋 | 单点自旋 15/16 线程空转烧 CPU;分片后每片竞争降低,自旋成功率回升 |
| 临界区长(IO、复杂计算) | 锁(park 挂起) | 等锁时间远超上下文切换成本,自旋纯浪费;线程挂起让 CPU 去跑别人 |
| 等待外部事件(队列取任务) | park / wait | 事件多久来不知道,没有重试语义可自旋 |
两个实践细节:
- 自旋可以「退避」:盲目原地重试会让所有失败线程在同一个 CPU 周期撞车。更聪明的做法是失败后短暂让路(x86 的
pausing/Thread.onSpinWait(),JDK 9+ 提示编译器「我在自旋」,JIT 可做专门优化)——线程池的 Worker、某些 CAS 库内部都这么做。 - 自旋有「次数兜底」:JVM 的轻量级锁(synchronized 升级链路的第一级)就是「先自旋 N 次再 park」的混合策略——短冲突自旋解决,长冲突交给操作系统。JDK 15+ 移除了偏向锁,但「短则自旋、长则挂起」的思路贯穿整个并发设计。
这套取舍在锁框架层还有一个更漂亮的解法:AQS 没有二选一,而是让赢家付自旋的钱(一两次 CAS 定输赢),让输家付阻塞的钱(入队 park)。于是「N 个线程抢一个变量」被重组成「1 个赢 + N−1 个睡」,对 state 的 CAS 频率与排队人数解耦。详见「AQS 框架与 ReentrantLock」篇第 11 站——那一站正好在回答「锁竞争最后不还是变成 CAS 竞争吗」这个追问。
ABA 问题:CAS 最大的隐患
CAS 只检查"值有没有变",但不关心值是否变过又变回来了。这就是 ABA 问题:
// 初始状态:共享变量 V = A
Thread-1: 读取 V = A
// 被挂起,还没执行 CAS...
Thread-2: 读取 V = A
CAS(A → B) 成功, V = B
CAS(B → A) 成功, V = A // 改回去了!
Thread-1: 执行 CAS(A → C)
// 内存值 = A == expected(A) → CAS 成功!
// 但实际上 V 经历过 A→B→A 的变化
真实场景:无锁栈的 ABA 问题。假设用 CAS 实现一个栈(Treiber Stack):
// 栈:top → A → B → C
Thread-1: pop()
读取 top = A, A.next = B
// 准备 CAS(top, A, B) 使 top 指向 B,被挂起
Thread-2: pop() A
CAS(top, A, B) 成功, top = B // 弹出 A
pop() B
CAS(top, B, C) 成功, top = C // 弹出 B
push() A
CAS(top, C, A) 成功, top = A // 重新压入 A(A.next 仍指向 B,但 B 已弹出)
Thread-1: 恢复执行
CAS(top, A, B) → top 当前值 = A → CAS 成功!
top = B // 但 B 已被 Thread-2 弹出并可能回收!
// 栈变成 top → B(已被释放的节点),A.next 仍指向 B
// 链表结构被破坏,可能访问已回收内存!
注意这里 Java 和 C++ 有个微妙差别:Java 有 GC,「访问已回收内存」不会像 C++ 那样直接段错误,但逻辑结构照样被破坏——top 指向了一个早该出栈的节点,数据错乱且极难排查。所以别以为「Java 没有悬垂指针所以 ABA 无关紧要」,它照样致命,只是死法从崩溃变成了脏数据。
ABA 的解法:给值配一个版本号
解决方案:加版本号。每次修改不仅比较值,还比较版本号(stamp),即使值相同,版本号不同也视为"被改过"。
// AtomicStampedReference:值 + 整数版本号
AtomicStampedReference<String> ref
= new AtomicStampedReference<>("A", 1); // 初始值"A",版本1
int stamp = ref.getStamp(); // 记录当前版本号
String val = ref.getReference(); // 记录当前值
// Thread-2 执行 A→B→A,版本号递增
ref.compareAndSet("A", "B", stamp, stamp + 1); // stamp: 1→2
ref.compareAndSet("B", "A", stamp + 1, stamp + 2); // stamp: 2→3
// Thread-1 尝试 CAS,版本不匹配 → 失败!ABA 被检测出来
boolean ok = ref.compareAndSet(val, "C", stamp, stamp + 1);
// stamp(1) != currentStamp(3) → CAS 失败,ABA 问题解决
// AtomicMarkableReference:值 + boolean 标记
// 适用于只关心"有没有被改过",不关心改了几次的场景
AtomicMarkableReference<Node> ref
= new AtomicMarkableReference<>(node, false);
boolean[] markHolder = new boolean[1];
Node val = ref.get(markHolder); // 同时读取值和标记
// compareAndSet(expectedRef, newRef, expectedMark, newMark)
ref.compareAndSet(val, newNode, false, true);
AtomicStampedReference 内部将值和版本号封装成一个 Pair 对象(private static class Pair<T> { final T reference; final int stamp; }),通过 AtomicReference<Pair<T>> 实现 CAS。每次修改都创建新的 Pair,版本号递增。CAS 比较的是 Pair 引用,因此即使"值"变回原样,Pair 对象不同,CAS 就能检测到变化。代价是每次更新多一次 Pair 对象分配——高并发下这也是一笔 GC 成本,所以只在真的存在 ABA 风险(无锁结构、跨线程读改写)时才用它。
CAS 第三大问题:一次只能保护一个变量
前两个问题(ABA、自旋)大家背得熟,第三个问题最容易漏答:CAS 只能保证单个共享变量的原子性。当「逻辑上的一个操作」需要同时改多个变量时,两条 CAS 之间没有原子性可言:
class Config {
volatile int version; // 配置版本号
volatile String content; // 配置内容
}
// ❌ 更新线程:两条 CAS 之间可能被读线程插进来
config.content = "v2-内容"; // ① 先写了内容
config.version = 2; // ② 再改版本号
// 读线程在 ① 和 ② 之间看到:version=1 + content="v2-内容" —— 版本和内容错配!
三种标准解法:
- 把多个字段封装成不可变对象,CAS 一个引用(最常用):每次更新都 new 一个新对象,用
AtomicReference<Config>原子换指针。读线程拿到的引用要么全旧、要么全新,永远不会错配——和「volatile 深入」篇讲的 volatile 引用「安全发布」是同一招。 - 用锁:两个字段必须一致地更新,直接 synchronized / ReentrantLock 包住两条赋值——锁天然保护任意多个变量。
- 单字段承载全部状态:能建模成一个值(比如「版本号+状态」压成一个 int 的不同位段,像 AQS 的 ctl、线程池的 ctl 那样),一次 CAS 搞定。
改 1 个变量 → AtomicXxx / CAS 就够
改 N 个变量要「同时」→ 封装不可变对象 + AtomicReference,或用锁
N 个变量其实是「一个状态的多视角」→ 压成一个 int,一次 CAS
Unsafe 类:CAS 的 JVM 层实现
sun.misc.Unsafe 是 JDK 内部类,提供了直接操作内存、线程调度、CAS 等"不安全"的底层能力。所有 Atomic 类的 CAS 操作最终都通过 Unsafe 完成:
public final class Unsafe {
// 获取字段在对象中的内存偏移量
public native long objectFieldOffset(Field f);
// 三种基本类型的 CAS 操作(native 方法,直接映射到 CPU 指令)
public final native boolean compareAndSwapInt(
Object o, long offset, int expected, int update);
public final native boolean compareAndSwapLong(
Object o, long offset, long expected, long update);
public final native boolean compareAndSwapObject(
Object o, long offset, Object expected, Object update);
// volatile 语义的读写
public native int getIntVolatile(Object o, long offset);
public native void putIntVolatile(Object o, long offset, int val);
// 线程调度
public native void park(boolean isAbsolute, long time);
public native void unpark(Object thread);
}
注意最后两个方法:park/unpark——AQS 整个队列同步的底层就是它们。Unsafe 一个类,同时是无锁并发(CAS)和阻塞并发(park)的地基,整个 JUC 站在它肩膀上。但 Unsafe 的问题在于它是 JDK 内部 API,Oracle 从未承诺其稳定性,且命名本身就在警告开发者:"这些操作可能破坏 JVM 安全模型"。
VarHandle:JDK 9+ 的官方替代
从 JDK 9 开始,Java 引入了正式的替代方案 java.lang.invoke.VarHandle:
// java.lang.invoke.VarHandle(JDK 9 引入)
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
private int count;
// 创建 VarHandle
private static final VarHandle COUNT;
static {
try {
COUNT = MethodHandles.lookup()
.findVarHandle(Counter.class, "count", int.class);
}
catch (Exception e) { throw new Error(e); }
}
public void increment() {
// 等价于 Unsafe.compareAndSwapInt 的自旋
int v;
do {
v = (int) COUNT.getVolatile(this);
} while (!COUNT.compareAndSet(this, v, v + 1));
}
}
| 维度 | sun.misc.Unsafe | java.lang.invoke.VarHandle |
|---|---|---|
| 定位 | JDK 内部类,非公开 API | JDK 9+ 正式公开 API |
| 稳定性 | 随时可能变更或移除 | 标准库,长期稳定 |
| 能力范围 | CAS、内存读写、线程调度、内存分配等全部 | 聚焦于变量访问模式(plain/volatile/acquire-release) |
| 安全性 | 可绕过访问控制、直接操作内存 | 类型安全,受访问控制约束 |
| 未来 | JEP 471 已计划逐步弃用移除 | 官方推荐的替代方案 |
Unsafe 提供 allocateMemory、putAddress 等 C 语言级别的内存操作,绕过类型系统和 GC,极易导致内存泄漏和 JVM 崩溃。VarHandle 的设计哲学是:只暴露必要的安全子集(CAS、volatile 读写、acquire/release 语义),不暴露危险操作,将底层能力纳入标准 API 管理体系。业务代码现在写新的底层访问逻辑,首选 VarHandle;Atomic 类源码从 JDK 9 起也逐步迁移到 VarHandle 实现。
LongAdder 结构:把热点拆成一排 Cell
AtomicLong 在高并发下有一个严重问题:所有线程都对同一个 volatile 变量做 CAS。16 个线程同时递增,只有一个能 CAS 成功,其余 15 个全部自旋重试——大量 CPU 时间浪费在空转上(第 7 站说的「高竞争自旋」的实锤)。
LongAdder(JDK 8)的核心思想借鉴自 ConcurrentHashMap:把单个热点变量分散到多个 Cell 中,每个线程更新自己的 Cell,求和时聚合。
public class LongAdder extends Striped64 {
// 继承自 Striped64 的核心字段:
transient volatile Cell[] cells; // 分段数组(惰性初始化)
transient volatile long base; // 无竞争时直接 CAS 更新 base
transient volatile int cellsBusy; // 自旋锁,保护 cells 扩容/初始化
// Cell 内部类(伪共享优化:通过 @Contended 填充缓存行)
static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return U.compareAndSetLong(this, VALUE, cmp, val);
}
}
}
add(long x) 方法展示了 LongAdder 的分段策略:
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
// ① cells 已初始化 → 尝试更新当前线程对应的 Cell
if ((cs = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
int h = getProbe(); // 线程的 hash 探测值
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[h & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
// ② Cell 为空或 CAS 竞争失败 → 进入长路径
longAccumulate(x, null, uncontended);
}
// ③ base CAS 成功:无竞争时的快速路径,直接返回
}
// longAccumulate:竞争时的慢路径
final void longAccumulate(long x, ...) {
for (;;) {
if (cells 未初始化) 初始化 cells;
else if (对应 Cell 为空) 创建新 Cell;
else if (Cell CAS 失败) 尝试 cells 扩容(翻倍,减少冲突);
else 对 base 做 CAS 重试;
}
}
读这段代码的主线:先走 base 快速路径,CAS 失败了才「升级」到 Cell 分片,分片还撞了就扩容——和「synchronized 轻量级→重量级升级」是同一种设计哲学:竞争出现之前,成本趋近于零。
伪共享、@Contended 与 LongAdder 的代价
CPU 缓存以缓存行(通常 64 字节)为单位加载。如果两个 Cell 恰好落在同一缓存行,一个线程修改 Cell[0] 会导致另一个线程的 Cell[1] 缓存行失效(MESI 协议),即使它们互不相关。LongAdder 的 Cell 类使用了 @Contended 注解(JVM 启动参数 -XX:+RestrictContended 开启后生效),在每个 Cell 前后填充空字段,确保每个 Cell 独占一条缓存行,消除伪共享。讽刺的是:不填 padding,分片本身就会被硬件「重新合并」成竞争——省了内存,赔了性能。
基准测试(16 线程并发递增 1 亿次,JDK 17,Intel i7-12700H):
| 实现 | 耗时(ms) | 吞吐量(M ops/s) | 说明 |
|---|---|---|---|
| AtomicLong | ~3200 | ~31 | 所有线程竞争同一变量,CAS 大量失败 |
| LongAdder | ~320 | ~312 | 分段更新,CAS 冲突大幅减少 |
| synchronized | ~4800 | ~21 | 悲观锁,上下文切换开销大 |
LongAdder 在高竞争下比 AtomicLong 快 5~10 倍,核心原因:用空间换时间——牺牲额外的 Cell[] 内存,将单点竞争分散到多个 Cell 上。
① sum() 返回的不是精确值——遍历 Cell[] 过程中其他线程仍在更新,得到的是弱一致性快照。② 额外内存开销——Cell[] 数组 + 缓存行填充。③ 不适合需要精确原子读的场景(如 compareAndSet),因为无法原子地读取所有 Cell 的总和。选型因此很清晰:只写不读(打点、计数)→ LongAdder;读写都要且要强一致 → AtomicLong。
生产落地:接口打点与 QPS 计数
CAS 家族在真实系统里的第一落点就是指标打点——每个请求都可能来自不同线程,高频累加,典型的高竞争计数器场景:
public final class MetricsCounter {
// 高竞争计数:LongAdder(只写不读,sum 弱一致完全够)
private final LongAdder qps = new LongAdder();
private final LongAdder errors = new LongAdder();
public void onRequest() {
qps.increment(); // 每次请求 +1,分散到 Cell,几乎零竞争
}
public void onError() {
errors.increment();
}
// 监控线程 10s 读一次:sum() 弱一致快照,打点场景完全够用
public long drainQps() {
return qps.sum(); // 或用 sumThenReset() 取「区间增量」做秒级 QPS
}
}
// 接入 Micrometer 的写法(Spring Boot 环境):
// Counter.builder("api.request")
// .register(meterRegistry); // 内部就是 LongAdder/DoubleAdder 累加
四条生产经验,全是踩出来的:
- 打点/计数用 LongAdder,业务状态用 AtomicXxx:区分标准是「读要不要精确」。QPS 曲线差一点没关系,库存扣减差一点就是资损——后者必须 AtomicLong/锁,甚至数据库层兜底。
- sumThenReset() 比 sum() 更适合做秒级指标:每次取「上次读数以来的增量」,天然对齐统计窗口,不用自己记上次值。
- 别在请求路径里读 sum():sum() 要遍历所有 Cell,O(分段数),放请求路径里等于把监控成本摊给每个请求——读操作放定时任务/监控线程。
- 计数器爆炸是隐形事故:给每个 orderId 建一个 LongAdder 的「动态打点」写法,内存会被计数器本身撑爆。维度多了就该用带限流的聚合结构(或干脆交给监控系统做聚合)。
原子类全景与选型决策树
Java 的 java.util.concurrent.atomic 包提供了完整的原子类家族,按功能分为四大类:
| 类别 | 类名 | 用途 |
|---|---|---|
| 基本类型 | AtomicBoolean, AtomicInteger, AtomicLong | 原子更新布尔值/整数/长整数 |
| 数组类型 | AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray | 原子更新数组中的元素 |
| 引用类型 | AtomicReference, AtomicStampedReference, AtomicMarkableReference | 原子更新对象引用(后两者解决 ABA) |
| 字段更新器 | AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater | 原子更新已有对象的 volatile 字段,避免为每个对象创建 Atomic 包装 |
JDK 8 新增的高性能累加器:
| 类名 | 适用场景 | 核心优势 |
|---|---|---|
| LongAdder / DoubleAdder | 高并发计数/求和 | 分段 CAS,吞吐量 5~10x |
| LongAccumulator / DoubleAccumulator | 高并发聚合(max/min/sum 等) | 支持自定义累加函数,通用性更强 |
低竞争计数器 → AtomicInteger / AtomicLong
高竞争计数器 → LongAdder(只需 add + sum)
高竞争 + 自定义聚合 → LongAccumulator
需要 CAS 引用 + 防 ABA → AtomicStampedReference
需要原子更新对象字段 → AtomicIntegerFieldUpdater(零额外对象开销)
需要精确原子读 → AtomicLong(LongAdder 的 sum 是弱一致的)
多个变量要一起改 → 封装不可变对象 + AtomicReference,或用锁
这一篇你掌握了什么
核心知识点回顾
- CAS 本质:CPU 的
lock cmpxchg单条指令,读-比较-写原子完成;和 volatile 写共用「lock 前缀 + MESI 缓存行独占」这套硬件。 - AtomicXxx 标配:volatile 字段(管读及时)+ CAS 自旋(管改原子);getAndIncrement 是 do-while 循环,expected 从哪来哪一步就得重做。
- 自旋的艺术:临界区短/竞争低 → 自旋;临界区长/竞争高 → park 挂起或分片分散;失败重试可以退避(onSpinWait)。
- 三大问题:ABA(版本号 AtomicStampedReference 解,Pair 对象实现)、自旋开销(LongAdder 分片解)、单变量限制(封装不可变对象 + AtomicReference 解)。
- LongAdder:base 快速路径 + Cell[] 分段 + hash 分散 + @Contended 防伪共享;sum() 弱一致,只写不读场景吞吐 5~10x。
- Unsafe → VarHandle:Unsafe 是地基但非公开 API,VarHandle 是 JDK 9+ 官方安全子集,JEP 471 推动迁移。
- 生产:打点计数 LongAdder(sumThenReset 做秒级指标,读放监控线程);业务状态 AtomicXxx/锁;计数器维度别爆炸。
一句话总结
CAS 是「赌没人和我抢」的乐观锁:一条 lock cmpxchg 指令赢下单变量的原子更新;赌输了三件事要接住——值变回去(版本号)、抢不过(自旋/分片)、变量不止一个(封装引用)。
🎤 面试 30 秒总结
Q:CAS 是什么?有什么问题?怎么解决?
"CAS(Compare And Swap)是 CPU 原子指令 CMPXCHG 的 Java 封装(x86 上带 lock 前缀,独占缓存行),通过比较内存值与预期值是否一致来决定是否更新,AtomicXxx 内部就是 volatile 读 + CAS 自旋循环。优点是无锁、无阻塞、低竞争时性能极好。三个问题:① ABA 问题——值变过又变回来,CAS 无法感知,无锁栈里会导致结构破坏,用 AtomicStampedReference 加版本号解决(底层是 CAS 一个 Pair 对象);② 自旋开销——高竞争时大量线程 CAS 失败空转,临界区短才适合自旋,计数器场景用 LongAdder 分段;③ 只能保证单个变量原子性——多变量要么封装成不可变对象 CAS 引用,要么用锁。"
Q:LongAdder 为什么比 AtomicLong 快?
"AtomicLong 所有线程 CAS 同一个 volatile long,高竞争下 16 个线程只有 1 个成功,其余自旋。LongAdder 用 Cell[] 数组分散竞争:无竞争走 base 快速路径,有竞争按线程 hash 映射到各自 Cell 独立 CAS,还撞就扩容;Cell 通过 @Contended 独占缓存行消除伪共享。高竞争下快 5~10 倍,但 sum() 是弱一致快照,只适合打点计数这类只写不读的场景,要强一致读用 AtomicLong。"
Q:Unsafe 和 VarHandle 的区别?
"Unsafe 是 JDK 内部类,提供 CAS、内存操作等全部底层能力,但非公开 API,JEP 471 计划逐步移除。VarHandle 是 JDK 9 引入的官方替代,只暴露安全的变量访问模式(plain/volatile/acquire-release 的读、写、CAS),类型安全且长期稳定,业务代码新写底层访问首选它。"
Comments · 评论