首页 / Java 学习笔记 / 21

JAVA · Vol.II · DAY 21 · 并发编程

CAS 与原子操作:AtomicInteger 到 LongAdder

高级高频#并发#原子#CAS
第 1 站

面试官:不加锁怎么保证原子性?

"多线程对共享变量做 i++,除了 synchronized 和 Lock,还有什么办法保证原子性?CAS 是什么?它有什么问题?AtomicLong 在高并发下为什么性能差,LongAdder 又是怎么解决的?" —— 面试官想考察的是你对无锁并发编程的理解深度。

在并发编程中,synchronized 和 Lock 是悲观策略——假定冲突一定会发生,所以先加锁再操作。而 CAS(Compare And Swap)是乐观策略——假定冲突很少发生,更新时检查是否被别人改过,没改过就原子更新,改过就重试。

基于 CAS 实现的原子类(AtomicIntegerAtomicLongAtomicReference 等)是 Java 并发工具箱中最高效的同步手段。它们无需加锁、无需线程上下文切换,在低竞争场景下性能远超锁方案。但 CAS 并非银弹——ABA 问题、自旋开销、只能保证单个变量原子性是它的三大局限。

本文从 CPU 指令级原理出发,逐层拆解 CAS → AtomicInteger → ABA 问题 → Unsafe → LongAdder,构建完整的无锁并发知识体系。

本文主线
CPU 指令 CMPXCHG → CAS 算法 → AtomicInteger 源码 → ABA 问题与解决方案 → Unsafe 与 VarHandle → LongAdder 分段优化 → 生产打点实践
第 2 站

CAS 原理:从 CPU 指令到 Java API

CAS 的全称是 Compare And Swap(比较并交换),它是一条 CPU 原子指令(x86 上是 CMPXCHG),能在单条指令内完成"读-比较-写"三步操作,不会被其他线程打断。

CAS 的算法逻辑用伪代码表示极其简洁:

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 CAS 使用示例
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 失败(别人在我读和写之间改了值),重试
CAS 操作流程:乐观锁的核心循环 1. 读取当前值 V = 10 2. 计算新值 N = V + 1 = 11 3. CAS(V, 10, 11) 内存值仍=10? = expected(10)? YES 4a. V = 11, 成功 原子更新完成 NO 4b. CAS 失败 值已被别人改了 自旋重试:重新读取当前值,重新计算,重新 CAS CAS = 乐观锁:假定无冲突,失败才重试。无加锁/解锁开销,无上下文切换
图 1CAS 操作流程:读取当前值 → 计算新值 → 原子比较交换,失败则自旋重试
第 3 站

CMPXCHG 的硬件真相:一条指令怎么做到「不可打断」

第 2 站说「读-比较-写三步原子完成」,但 CPU 是多核的,别的核凭什么不插进来?答案在 lock 前缀指令——和「volatile 深入」篇讲的同一个东西

  • x86 上,Java 的 compareAndSwapInt 最终编译成 lock cmpxchg。普通 cmpxchg 只保证「单核内不被打断」,加上 lock 前缀后,硬件保证整个操作期间独占这条缓存行:其他核要么看不到操作前的旧值、要么看到操作后的新值,不存在中间态。
  • 独占的手段就是缓存一致性协议(MESI):执行 lock cmpxchg 的核先把缓存行置为 Modified 状态并向总线广播「我要独占」,其他核的副本作废——和 volatile 写的「锁缓存行 + 广播失效」是同一套硬件机制

所以 CAS 的原子性不是 JVM 的魔法,是硬件承诺:一条带 lock 前缀的指令,在一个 CPU 核上执行期间,别的核对该地址的访问被硬件排队。这也解释了为什么 CAS 快又稳:没有软件锁的「申请-排队-唤醒」流程,失败的成本只是一条指令返回 false,线程连停都不用停,继续下一次 CAS 即可。

串一下知识链:volatile 写 = lock 前缀指令(可见性/有序性);CAS = lock cmpxchg(原子性);JMM 的「原子性、可见性、有序性」三件套,在硬件层最终都收敛到 lock 前缀这一个点上。面试能把这条链说出来,volatile 篇 + CAS 篇 + JMM 篇就打通了。
第 4 站

AtomicInteger 结构:volatile + CAS 的标配组合

AtomicInteger 是 CAS 最经典的应用。它的全部核心建立在两个要素之上:volatile 保证可见性,CAS 保证原子性——两者缺一不可:

AtomicInteger.java · 核心字段与构造
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 管「读的及时性」

记忆点凡是「无锁 + 共享可变」的类(Atomic 全家、LongAdder 的 base/Cell.value、AQS 的 state),字段全是 volatile——这不是巧合,是这套组合拳的固定搭配。
第 5 站

循环 CAS 详解:getAndIncrement 为什么是 while 循环

逐行拆解 getAndIncrement()(等价于 i++)的实现:

AtomicInteger.java · getAndIncrement() 自旋 CAS
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 成功为止。在竞争不激烈时,通常一两次就能成功,性能远优于加锁

AtomicInteger · 其他核心方法
// 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)从哪来,哪一步就必须放进循环里重做

第 6 站

CAS vs 锁:同一件事的两种性格

维度AtomicInteger(CAS)synchronized
加锁无锁(乐观)有锁(悲观)
竞争低时极快(1~2 次 CAS)有 monitor 操作开销
竞争高时自旋浪费 CPUpark 让出 CPU,更合理
阻塞不会阻塞线程可能阻塞(上下文切换)
原子变量数只能保证一个变量可保护多个变量
可中断/超时不支持(自旋直到成功)lockInterruptibly / tryLock(timeout)

选型的心法就一句话:临界区越短、竞争越低,CAS 越划算;临界区越长、竞争越高,锁越划算。临界区长意味着每次 CAS 失败后「重新读+重算」的窗口大、失败率高,自旋烧掉的 CPU 比 park 一次的代价高得多——这是「乐观 vs 悲观」的本质:赌冲突发生的概率。

面试回答要点"AtomicInteger 底层是 volatile int value 加 CAS 自旋。getAndIncrement() 用 do-while 循环,先 volatile 读当前值,再 CAS 尝试将其加 1,失败则重新读重试。低竞争时性能远超 synchronized,高竞争时自旋开销增大——所以高并发计数器要用 LongAdder 分散竞争(第 13 站)。
第 7 站

自旋的艺术:什么时候值得空转,什么时候该睡觉

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+ 移除了偏向锁,但「短则自旋、长则挂起」的思路贯穿整个并发设计。
面试追问「自旋和 park 的代价怎么量化」:一次 park/unpark ≈ 一次上下文切换 ≈ 10~100 微秒级(取决于 OS 负载);一次失败的 CAS ≈ 几十纳秒。临界区耗时在微秒级以下时,哪怕竞争率 50%,自旋的期望成本也往往低于 park 一次——这就是「临界区短 → CAS」的定量依据。

这套取舍在锁框架层还有一个更漂亮的解法:AQS 没有二选一,而是让赢家付自旋的钱(一两次 CAS 定输赢),让输家付阻塞的钱(入队 park)。于是「N 个线程抢一个变量」被重组成「1 个赢 + N−1 个睡」,对 state 的 CAS 频率与排队人数解耦。详见「AQS 框架与 ReentrantLock」篇第 11 站——那一站正好在回答「锁竞争最后不还是变成 CAS 竞争吗」这个追问。

第 8 站

ABA 问题:CAS 最大的隐患

CAS 只检查"值有没有变",但不关心值是否变过又变回来了。这就是 ABA 问题:

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 到底有什么危害?值不是一样吗?" —— 在简单计数器场景下,ABA 确实无害。但在链表/栈等数据结构中,ABA 可能导致灾难性后果。

真实场景:无锁栈的 ABA 问题。假设用 CAS 实现一个栈(Treiber Stack):

ABA 导致链表断裂
// 栈: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 无关紧要」,它照样致命,只是死法从崩溃变成了脏数据。

第 9 站

ABA 的解法:给值配一个版本号

解决方案:加版本号。每次修改不仅比较值,还比较版本号(stamp),即使值相同,版本号不同也视为"被改过"。

AtomicStampedReference.java · 带版本号的 CAS
// 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.java · 布尔标记的 CAS
// 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 的底层原理

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 风险(无锁结构、跨线程读改写)时才用它。

ABA 问题面试回答"CAS 只比较值,如果值从 A 变成 B 再变回 A,CAS 会认为没变过,这就是 ABA 问题。典型危害场景是无锁栈/链表,可能导致节点被弹出后仍被引用、结构破坏。解决方案是使用 AtomicStampedReference,每次修改递增版本号,即使值相同版本不同也算被改过。底层是 CAS 一个包含值和版本号的 Pair 对象;只关心改没改过可以用 AtomicMarkableReference。
第 10 站

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
第 11 站

Unsafe 类:CAS 的 JVM 层实现

sun.misc.Unsafe 是 JDK 内部类,提供了直接操作内存、线程调度、CAS 等"不安全"的底层能力。所有 Atomic 类的 CAS 操作最终都通过 Unsafe 完成:

Unsafe · CAS 相关方法
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 安全模型"。

第 12 站

VarHandle:JDK 9+ 的官方替代

从 JDK 9 开始,Java 引入了正式的替代方案 java.lang.invoke.VarHandle

VarHandle · JDK 9+ 的官方替代
// 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.Unsafejava.lang.invoke.VarHandle
定位JDK 内部类,非公开 APIJDK 9+ 正式公开 API
稳定性随时可能变更或移除标准库,长期稳定
能力范围CAS、内存读写、线程调度、内存分配等全部聚焦于变量访问模式(plain/volatile/acquire-release)
安全性可绕过访问控制、直接操作内存类型安全,受访问控制约束
未来JEP 471 已计划逐步弃用移除官方推荐的替代方案
为什么不直接开放 Unsafe?

Unsafe 提供 allocateMemoryputAddress 等 C 语言级别的内存操作,绕过类型系统和 GC,极易导致内存泄漏和 JVM 崩溃。VarHandle 的设计哲学是:只暴露必要的安全子集(CAS、volatile 读写、acquire/release 语义),不暴露危险操作,将底层能力纳入标准 API 管理体系。业务代码现在写新的底层访问逻辑,首选 VarHandle;Atomic 类源码从 JDK 9 起也逐步迁移到 VarHandle 实现。

第 13 站

LongAdder 结构:把热点拆成一排 Cell

AtomicLong 在高并发下有一个严重问题:所有线程都对同一个 volatile 变量做 CAS。16 个线程同时递增,只有一个能 CAS 成功,其余 15 个全部自旋重试——大量 CPU 时间浪费在空转上(第 7 站说的「高竞争自旋」的实锤)。

"AtomicLong 高并发下性能差,怎么优化?" —— 面试官在等你说出 LongAdder,以及它背后的分段累加思想。

LongAdder(JDK 8)的核心思想借鉴自 ConcurrentHashMap:把单个热点变量分散到多个 Cell 中,每个线程更新自己的 Cell,求和时聚合

LongAdder.java · 核心结构(简化)
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 的分段策略:

Striped64.longAccumulate() · 核心累加逻辑(简化)
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 轻量级→重量级升级」是同一种设计哲学:竞争出现之前,成本趋近于零。

LongAdder 分段累加结构 Thread-1 Thread-2 Thread-3 Thread-4 Thread-5 hash 分散 Cell[] cells(分段累加数组) Cell[0] value = 47 Cell[1] value = 123 Cell[2] value = 89 Cell[3] value = 56 base = 15 无竞争时的快速路径 sum() = 47 + 123 + 89 + 56 + 15 = 330 聚合所有 Cell 的 value + base(弱一致性快照)
图 2LongAdder 分段累加:每个线程通过 hash 映射到不同 Cell,sum() 聚合所有分段
第 14 站

伪共享、@Contended 与 LongAdder 的代价

Cell 为什么要做伪共享(False Sharing)优化?

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 上。

LongAdder 的代价是什么?

sum() 返回的不是精确值——遍历 Cell[] 过程中其他线程仍在更新,得到的是弱一致性快照。② 额外内存开销——Cell[] 数组 + 缓存行填充。③ 不适合需要精确原子读的场景(如 compareAndSet),因为无法原子地读取所有 Cell 的总和。选型因此很清晰:只写不读(打点、计数)→ LongAdder;读写都要且要强一致 → AtomicLong

第 15 站

生产落地:接口打点与 QPS 计数

CAS 家族在真实系统里的第一落点就是指标打点——每个请求都可能来自不同线程,高频累加,典型的高竞争计数器场景:

MetricsCounter.java —— 可直接抄的打点模板
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 的「动态打点」写法,内存会被计数器本身撑爆。维度多了就该用带限流的聚合结构(或干脆交给监控系统做聚合)。
第 16 站

原子类全景与选型决策树

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 · 评论