并发 04 · CAS 与原子类

上一篇讲锁升级时,轻量级锁那一级反复出现一个词------CAS 。线程抢轻量级锁靠它、自旋重试也靠它。这一篇就把这个藏在幕后的主角请到台前:CAS 到底是什么?它凭什么能在"不加锁"的情况下,保证 count++ 这种复合操作的原子性? 要知道,第 3 篇我们才刚说 volatile 做不到原子的自增、得上 synchronized------可整个 java.util.concurrent 包里,有一大批类偏偏做到了"无锁 + 原子",它们靠的正是 CAS。

CAS 是理解 Java 并发的一道分水岭 。它上承 synchronized(互斥的悲观思路),下启 AtomicIntegerConcurrentHashMapAQS(无锁/乐观的思路)------可以说,不懂 CAS,后面 AQS、并发容器就都是空中楼阁 。面试里 CAS 也是必考,而且考得深:不光问"是什么",还会追着问 ABA、问自旋开销、问 LongAdder 为什么比 AtomicLong 快。这一篇就沿着这条追问链,把 CAS 从原理到坑、再到它的进化形态一次讲清。

这篇按这条线索展开:先讲清 CAS 这条"硬件指令级的乐观主义"到底怎么工作、它和锁的思路差在哪;再拆开 AtomicInteger,看它如何用"自旋 CAS"实现无锁自增;然后正视 CAS 的三个软肋(ABA、自旋开销、只能保一个变量)以及各自的解法;接着俯瞰整个 Atomic 家族;最后落到高并发下的进化版 LongAdder,看它怎么用"分而治之"把 CAS 的性能再压榨一截。

目录

  1. CAS:一条硬件指令的乐观主义
  2. AtomicInteger:无锁自增怎么做到的
  3. [CAS 的三个软肋](#CAS 的三个软肋)
  4. [Atomic 家族全景](#Atomic 家族全景)
  5. LongAdder:把热点打散
  6. 小结:无锁世界的地基

一、CAS:一条硬件指令的乐观主义

CAS 是 Compare-And-Swap(比较并交换) 的缩写。它是一个原子操作,接收三个操作数:

  • V:要修改的内存位置(变量的当前值);
  • A:预期的旧值(expected);
  • B:想写入的新值(new)。

它干的事一句话说清:"如果 V 现在的值 == A,就把它更新成 B;否则什么都不做。" 而这个"比较 + 交换"整体是原子的,中间不会被打断。 无论成功失败,它都会返回 V 原本的值(或一个 boolean 表示成没成)。

关键在于为什么它是原子的 。CAS 不是 Java 层面用什么巧妙代码拼出来的,而是直接对应一条 CPU 指令 (x86 上是 cmpxchg,加 lock 前缀保证多核下的原子性)。"比较"和"交换"这两步在硬件层面就是一体的、不可分割的------正因为落到了硬件那一层,它才能绕开操作系统的锁,天然原子。这是理解 CAS 的第一块基石:它的原子性来自硬件,不是来自锁。

理解 CAS,最好的方式是把它和 synchronized 的思路对照------这是悲观锁 vs 乐观锁的经典分野:

  • synchronized 是悲观锁 :它悲观地假设"我改这个数据时,一定有人会来抢",所以先把锁拿到手、把别人挡在门外,再安心修改。代价是别人得阻塞等待。
  • CAS 是乐观锁 :它乐观地假设"我改的时候,多半没人来抢",所以不加锁直接改 ,只在真正写入的那一刻用 CAS 检查一下"我读到值之后、到现在为止,有没有人动过它"。没人动过(V 还等于 A),改成功;有人动过(V 变了),说明这次冲突了,那就放弃重来,一切照旧、不阻塞任何人。

这个对照很关键:悲观锁把成本花在"事前防范"(加锁阻塞),乐观锁把成本花在"事后重试"(CAS 失败再来)。 所以它们的适用场景天然不同------竞争激烈时,悲观锁更好 (重试太多次不如老实排队);竞争不激烈时,乐观锁完胜 (大概率一次成功,省掉了加锁、阻塞、上下文切换的全部开销)。这也正好解释了上一篇锁升级的逻辑:轻度竞争用 CAS(轻量级锁),竞争激烈了才升级去阻塞(重量级锁)------锁升级本质上就是"乐观打不过了,退回悲观"。

二、AtomicInteger:无锁自增怎么做到的

理论说完,看它怎么解决第 3 篇那个悬案------不加锁地实现原子自增java.util.concurrent.atomic.AtomicInteger 就是答案:

java 复制代码
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();   // 原子地 +1,线程安全,且无锁

它内部怎么做到的?看 incrementAndGet 的核心逻辑(简化版,本质就是"自旋 CAS"):

java 复制代码
public final int incrementAndGet() {
    int prev, next;
    do {
        prev = get();              // ① 读当前值(比如读到 100)
        next = prev + 1;           // ② 算出新值(101)
    } while (!compareAndSet(prev, next));   // ③ CAS:若当前还是 prev 就写入 next,否则重来
    return next;
}

把这三步和第 3 篇那个出 bug 的 count++ 对照,精髓就出来了:

  • 普通 count++ 的三步(读、改、写)之间门户大开,别人能插进来,于是丢更新;
  • AtomicInteger 也是读、改、写三步,但它把"写"换成了 CAS ------写入前会验货 :"我读的时候是 100,现在还是 100 吗?" 如果是,说明这期间没人动过,安全写入;如果不是(被别人改成了别的值),CAS 失败,while 循环自旋重试:重新读最新值、重新计算、再 CAS,直到成功为止。

这个"读 → 改 → CAS,失败就转圈重来 "的循环,就是自旋 CAS,是所有原子类的核心套路:

再往底层扒一层:compareAndSet 最终调用的是 sun.misc.Unsafe 类的本地方法 compareAndSwapIntUnsafe 是 JDK 内部一个"能直接操作内存"的后门类 ------它能拿到对象字段的内存偏移量、直接对那块内存做硬件级 CAS。整个 java.util.concurrent 的无锁能力,几乎都建立在 Unsafe 提供的 CAS 之上。(Unsafe 名副其实地"不安全",绕过了 JVM 的安全检查,所以官方不让应用代码直接用,但了解它的存在,能帮你看懂原子类的底裤。)

JDK 9 起的小变化 :从 JDK 9 开始,官方提供了 VarHandle 作为 Unsafe 那些 CAS 操作的正式、安全的替代 ,原子类内部实现也逐步迁了过去。原理完全一样(还是硬件 CAS),只是换了个不那么"危险"的门面。本系列主线 JDK 8 里用的还是 Unsafe,知道 JDK 9+ 有 VarHandle 这回事即可。

三、CAS 的三个软肋

CAS 很美,但不是银弹。它有三个必须知道的软肋,也是面试的高频追问点。

软肋一:ABA 问题------"值没变"不等于"没被动过"。

CAS 判断的依据是"值等不等于预期",但这里藏着一个漏洞:如果一个值从 A 被改成了 B、又被改回了 A,CAS 会以为"它没变过",照样交换成功------可实际上它已经被人动过一轮了。 这就是 ABA 问题。

打个比方:你出门前看杯子里有半杯水(A),回来一看还是半杯水(A),就以为没人碰过。但其实中间有人把水喝光了(B)、又给你续了半杯(A)。对纯数值的自增来说,ABA 通常无害(100 变成 100 就是 100);但在涉及引用、涉及"过程"的场景(比如无锁栈的出栈入栈、对象被回收又重新分配到同一地址)里,ABA 会造成真实的逻辑错误。

解法是给值加一个"版本号/时间戳" ,让每次修改都让版本号 +1------这样即便值转了一圈回到 A,版本号也变了,CAS 就能识破。Java 提供了现成的 AtomicStampedReference (带 int 版本戳)和 AtomicMarkableReference(带一个 boolean 标记,只关心"动没动过"):

java 复制代码
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);  // 值100,版本0
// CAS 时要同时比对"值"和"版本号",两者都对才成功
ref.compareAndSet(100, 101, 0, 1);   // 期望值100+期望版本0 → 新值101+新版本1

软肋二:自旋开销------竞争激烈时,转圈也很费。

自旋 CAS 的前提是"大概率一次成功"。可一旦竞争非常激烈 ,大量线程会一遍遍 CAS 失败、一遍遍空转重试,白白烧 CPU 却推进不了几个。这时候,自旋的总开销可能反而超过直接用锁阻塞。所以 CAS 更适合"竞争不激烈、临界区操作简单 "的场景------这也再次印证了乐观锁的适用边界。(LongAdder 就是专门来救这个场的,下一节讲。)

软肋三:只能保证一个变量的原子性。

CAS 一次只能对一个 内存位置做原子操作。如果你需要"同时 "原子地更新多个变量(比如转账要同时改两个账户余额),CAS 就无能为力了------它保不了"多个变量作为一个整体"的原子性。这种场景要么老实用锁,要么把多个变量封装进一个对象 ,然后用 AtomicReference 对这个对象的引用做 CAS(把"多变量"变成"单引用")。

把三个软肋记牢,你对 CAS 的认知才算完整:它是乐观锁,怕 ABA、怕激烈竞争、只能管一个变量。 用对地方是神器,用错地方是灾难。

四、Atomic 家族全景

java.util.concurrent.atomic 包里是一整个家族,全都基于 CAS,按用途分成几类。不用死记,理解每类解决什么问题即可:

类别 代表类 用途
基本类型 AtomicIntegerAtomicLongAtomicBoolean 对单个基本类型变量做原子操作
引用类型 AtomicReference 对一个对象引用做原子 CAS(把"多变量"打包成"单引用"的手段)
带版本引用 AtomicStampedReferenceAtomicMarkableReference 解决 ABA 问题(上一节)
数组 AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray 对数组里某个元素做原子操作
字段更新器 AtomicIntegerFieldUpdater 把某个类里已有的 volatile 字段"升级"成能原子更新,省去改成 Atomic 类型
累加器(JDK 8+) LongAdderLongAccumulatorDoubleAdder 高并发计数场景,性能优于 AtomicLong

几个实用提醒:

  • AtomicBoolean 常用来做"只执行一次"的开关(比如保证某个初始化只跑一次):if (inited.compareAndSet(false, true)) { init(); }
  • AtomicReference 是解决"CAS 只能保一个变量"的标准手段------把要一起改的字段塞进一个不可变对象,然后 CAS 整个引用。
  • 字段更新器 用得少,但在"不想把字段类型从 volatile long 改成 AtomicLong、又想偶尔原子更新"时能省内存(每个 AtomicLong 对象都有额外开销,字段更新器是共享的静态工具)。

家族虽大,地基只有一个------CAS。下一节的 LongAdder 是这个家族里最值得单独讲的一个,因为它代表了 CAS 的一次重要进化。

五、LongAdder:把热点打散

AtomicLong 在高并发计数下有个天生的瓶颈,正是第三节的"软肋二":所有线程都盯着同一个变量做 CAS,竞争一激烈,绝大多数线程都在自旋失败、反复重试 ,CPU 空转严重。设想一个高 QPS 接口用 AtomicLong 做全局计数器,几百个线程抢着 CAS 同一个 value------这就成了性能热点。

LongAdder(JDK 8 引入)的解法非常漂亮,核心思想是分而治之 / 空间换时间

既然大家抢一个变量抢得凶,那就别让大家抢同一个------把这个计数"拆散"成多个,让不同线程去改不同的那份,各改各的,最后要总数时再把它们加起来。

具体来说,LongAdder 内部维护一个 base 值和一个 Cell[] 数组(每个 Cell 是一个独立的计数单元):

  • 竞争不激烈时 :直接 CAS 更新 base,和 AtomicLong 一样简单;
  • 竞争激烈(CAS base 失败)时 :不再死磕 base,而是根据线程去散列(hash)到 Cell[] 数组里的某个格子 ,去 CAS 那个格子。不同线程被分散到不同 Cell 上,把"一个热点"打散成"多个冷点",CAS 冲突概率骤降;
  • 求总数时sum() = base + 所有 Cell 之和

这个"分段"的思路,和 ConcurrentHashMap 1.7 的分段锁、和数据库的分库分表,本质是同一种智慧:降低单点竞争,就把单点拆成多点。 记住这个模式,后面会反复见到它。

不过 LongAdder 有一个必须知道的代价 ------它的 sum()并发环境下不是精确的强一致值 :因为求和时它逐个读取 base 和各个 Cell,而这期间别的线程可能还在修改某些 Cell,所以 sum() 拿到的是一个"最终一致"的近似快照。这带来一个清晰的选型标准:

  • 需要高并发下频繁计数、且能容忍统计值有瞬时误差 (如监控埋点、访问量统计、限流计数)→ LongAdder,吞吐高得多;
  • 需要每一步都拿到精确、强一致的当前值 (如严格的库存扣减,读了就要立刻据此决策)→ 老实用 AtomicLong(或加锁),别为了性能牺牲正确性。

一句话:LongAdder 用"求和不精确"换"更新更快",是典型的场景权衡------高频写、低频读且容忍误差,它就是最优解。

六、小结:无锁世界的地基

这一篇我们把并发编程的另一条大动脉------无锁(lock-free) ------的源头讲清楚了。它和上一篇的 synchronized 是并发的两种世界观:一个悲观地加锁挡人,一个乐观地直接改、冲突了再重试。

回顾这条链子:CAS 是硬件提供的原子"比较并交换"指令 → 乐观锁的思路,用它把"读-改-写"的写入变成"验货后再写、失败就自旋重来" → AtomicInteger 等原子类由此实现无锁的线程安全 → 但 CAS 有 ABA、自旋开销、单变量三个软肋 → LongAdder 用分段把高并发下的自旋热点打散。 更重要的是,CAS 是后面 AQS (第 5 篇)、ConcurrentHashMap(第 9 篇)的共同地基------它们的"无锁"底气全来自这里。

带走三句话:① CAS 是乐观锁,原子性来自硬件指令,不是来自锁;② 它的三个软肋要张口就来------ABA(用带版本号的 AtomicStampedReference 解)、激烈竞争下自旋烧 CPU、只能保一个变量;③ 高并发计数用 LongAdder(分段打散热点),但它 sum() 不精确,要精确值仍用 AtomicLong

下一篇,我们去啃并发包里最硬的一块骨头------AQS(AbstractQueuedSynchronizer)ReentrantLockCountDownLatchSemaphore、线程池......半个 java.util.concurrent 都建在它身上。而它的骨架,正是"一个 volatile 的 state + 一个 CAS + 一个等待队列"------你会发现,这一篇的 CAS,就是打开那扇门的钥匙。

相关推荐
哦虎!7 小时前
【数据库】事务
java·数据库·mysql
liulilittle7 小时前
并发与内存安全术语:定义、分类与关联
c++·安全·并发·术语
CDN3607 小时前
流媒体加速实践:出海东南亚短视频点播频繁缓冲,HLS 分片与 Nginx 配置调优
java·网络·nginx·流媒体加速
程序员黑豆7 小时前
Java中的null与NullPointerException完全指南:安全处理、实战排查与面试题
java·前端·ai编程
晴天168 小时前
Electron面试题-Day19
java·javascript·electron
GeekZHR8 小时前
C语言指针进阶补充6:动态内存管理、mem系列内存函数、复杂指针声明,一次补齐指针的“三大盲区“
java·c语言·算法·指针
侧耳倾听1118 小时前
java 日志框架简介
java·开发语言
她的男孩9 小时前
我用 LLM 把后台 CRUD 效率提升 10 倍:AI 代码生成器的架构与落地实践
java·后端·架构
土司大王9 小时前
LeetCode hot100——移动零
java·算法·leetcode
thefool11226610 小时前
相同的树`
java