CAS 并发控制全解:从一条 CPU 指令到企业级乐观锁,彻底搞懂无锁编程
一个
count++引发的超卖事故,最后追到 CPU 的一条指令上。这篇文章把 CAS 从硬件讲到业务代码,配 Python / Java / SQL 可运行示例,看完能直接上手。

文章目录
- [CAS 并发控制全解:从一条 CPU 指令到企业级乐观锁,彻底搞懂无锁编程](#CAS 并发控制全解:从一条 CPU 指令到企业级乐观锁,彻底搞懂无锁编程)
-
- 一、痛点场景:那些「加了锁也没用」的线上事故
-
- 场景一:库存超卖,两个人买走了最后一件
- [场景二:单机锁,在三个 Pod 面前形同虚设](#场景二:单机锁,在三个 Pod 面前形同虚设)
- 场景三:状态机重复流转,同一笔订单发了两次货
- 场景四:乐观锁用成重试风暴,把数据库打挂
- [二、CAS 是什么](#二、CAS 是什么)
- [三、为什么用 CAS:它和锁的本质区别](#三、为什么用 CAS:它和锁的本质区别)
- 四、它是怎么演进过来的
- [五、CAS 的三个缺陷,以及怎么补](#五、CAS 的三个缺陷,以及怎么补)
-
- [缺陷一:ABA ------ 值回去了,世界没有回去](#缺陷一:ABA —— 值回去了,世界没有回去)
- [缺陷二:自旋开销 ------ 空转也是要烧 CPU 的](#缺陷二:自旋开销 —— 空转也是要烧 CPU 的)
- 缺陷三:只能保护一个变量
- 六、怎么用:四种落地的完整代码
-
- [6.1 Python 的现实:没有原生 CAS](#6.1 Python 的现实:没有原生 CAS)
- [6.2 路径一:用锁实现一个语义等价的 CAS 容器](#6.2 路径一:用锁实现一个语义等价的 CAS 容器)
- [6.3 路径二:跨进程的自增,用共享内存](#6.3 路径二:跨进程的自增,用共享内存)
- [6.4 路径三(生产主流):把 CAS 下沉到数据库](#6.4 路径三(生产主流):把 CAS 下沉到数据库)
- [6.5 Java:`AtomicInteger` 源码里的自旋](#6.5 Java:
AtomicInteger源码里的自旋) - [6.6 分布式:Redis 里的 CAS](#6.6 分布式:Redis 里的 CAS)
- 七、企业项目里怎么用:三层接力
-
- 第一层:应用层(进程内)
- 第二层:缓存层(跨进程)
- 第三层:存储层(最终真相)
- [兜底层:对账 + 补偿 + 幂等键](#兜底层:对账 + 补偿 + 幂等键)
- 八、竞品对比:六种并发控制手段怎么选
- [九、常用场景:这八个地方最适合 CAS](#九、常用场景:这八个地方最适合 CAS)
- 十、面试高频题(附答题要点)
- 十一、收尾:一张能贴到工位上的决策清单
一、痛点场景:那些「加了锁也没用」的线上事故

先看四个真实味道很重的事故。它们有个共同特征:代码看起来都做了保护,但都没护住。
场景一:库存超卖,两个人买走了最后一件
活动开始 3 秒,库存 1 件,卖出 2 件,仓库发不出货。
python
# 错误示范:读和写之间有窗口
def deduct_stock(product_id):
stock = query_stock(product_id) # 读到 stock = 1
if stock > 0: # 判断通过
update_stock(product_id, stock - 1) # 写回 0
两个请求同时进来,都读到 stock = 1,都判断通过,都写回 0。库存从 1 直接跳到 0,但订单生成了两笔。这在并发领域有个正式名字:丢失更新(Lost Update)。
如果两个请求一前一后把 stock 写成 0 和 -1,那就是更常见的负库存。
场景二:单机锁,在三个 Pod 面前形同虚设
python
import threading
lock = threading.Lock() # 进程内的锁
def deduct_stock(product_id):
with lock:
stock = query_stock(product_id)
if stock > 0:
update_stock(product_id, stock - 1)
单机压测怎么都复现不出来,一上生产就超卖。原因很简单:这把锁只活在当前进程的内存里。三个 Pod 就是三把锁,互相看不见。锁的边界必须与共享资源的边界一致,共享的是数据库里的行,锁却只覆盖了一个进程。
场景三:状态机重复流转,同一笔订单发了两次货
python
def ship(order):
if order.status == "PAID":
order.status = "SHIPPED"
call_logistics(order)
订单支付后进入 PAID,用户申请退款变成 REFUND,退款被驳回又回到 PAID。这时一个延迟的重试请求进来,看到状态还是 PAID,于是又发了一次货。
注意这里的关键:值从 PAID 变成 REFUND 又变回 PAID,判断条件依然成立。这不是锁没加对,是判断依据本身选错了 ------ 这就是后面要讲的 ABA 问题。
场景四:乐观锁用成重试风暴,把数据库打挂
python
for _ in range(3):
row = select_one("SELECT stock, version FROM product WHERE id = 1")
affected = execute(
"UPDATE product SET stock = %s, version = version + 1 "
"WHERE id = 1 AND version = %s",
(row["stock"] - 1, row["version"]),
)
if affected == 1:
break
思路是对的,但秒杀场景下一万个人抢同一个 version,9999 次更新都是 affected = 0。每个请求重试 3 次,数据库瞬间承受 3 万次无效写入。乐观锁在冲突率高的场景会自我放大压力,这是新手最容易踩的坑。
图 1:三个事故的共同点 ------ 都在「先读后写」,读到的值在写回去之前已经被别人改过了
看清楚这一点,CAS 存在的理由就非常直白了:它要消灭的就是"读"和"写"之间那个窗口。
二、CAS 是什么
一句话定义
CAS(Compare-And-Swap,比较并交换)是一条原子指令:它检查某个内存位置的当前值是否等于你期望的值,相等才写入新值,整个过程不可被打断。
它需要三个操作数:
| 符号 | 含义 | 例子 |
|---|---|---|
| V | 要操作的内存地址(Value address) | count 字段在对象中的偏移量 |
| E | 期望值(Expected) | 你刚才读到的 stock = 1 |
| N | 新值(New) | 你想写入的 stock = 0 |
语义可以用一段伪代码表示,但真实实现里这三步是一条指令,中间不允许任何线程插队:
text
CAS(V, E, N):
if V == E: # 比较
V = N # 交换
return true
else:
return false # 不写,把当前最新值带回去
专业解释
CAS 属于读-改-写(Read-Modify-Write)原子操作,是构建无锁(lock-free)数据结构的基础原语。它在硬件层由 CPU 直接支持:
- x86 / x86-64:
CMPXCHG,配合LOCK前缀保证多核间的缓存一致性(走 MESI 协议让其他核心的缓存行失效) - ARM:
LDREX/STREX(Load-Link / Store-Conditional),ARMv8.1 起也有直接的CAS指令 - RISC-V:
LR.W/SC.W
上层语言的封装都建立在它之上:Java 的 AtomicInteger.compareAndSet、C++ 的 std::atomic::compare_exchange_weak、Rust 的 AtomicUsize::compare_exchange。它们的语义完全一致,区别只在内存序(memory order)的可调参数。
大白话
我要改一个值,但我怕改错。所以我在改之前先问一句:
「现在这个值,还是我刚才看到的那个吗?」
是 ------ 那说明没人动过,我改。
不是 ------ 那说明有人抢先了,我重新看一眼,再问一次。
就这么简单。CAS 的本质是给自己的写入加一个"基于哪个版本"的前提条件。
生活案例:停车场里的最后一个车位
你开车进地库,看到 B1-08 空着(这是你读到的快照)。但你倒车入库需要十秒,这十秒里可能有别人先停进去了。
有两种做法:
- 悲观的做法(加锁) :进场时就去管理处把 B1-08 锁下来,挂个牌子"已被预订",别人看到牌子直接走开。安全,但你要跑一趟管理处,别人也被你的牌子挡了一下 ------ 这就是
synchronized。 - 乐观的做法(CAS):直接倒车,倒进去之前扭头确认一眼"还是空的吗"。是就停(成功),不是就绕一圈重新找(失败重试)。全程不用跑管理处,代价是可能白绕一圈 ------ 这就是 CAS。
现实中的共享单车扫码开锁、电影院选座的"确认订单",都是这个思路:先按看到的状态提交,提交时由系统校验状态是否还成立。

图 2:CAS 的三个操作数与两条分支 ------ 失败不是异常,是"别人比你快"的正常返回
一个容易被忽略的细节:失败时要带回最新值
真正的 CAS 指令在失败时,会把内存里的当前值 写回给你的寄存器(x86 的 cmpxchg 在比较失败时会把目标值装入 rax)。这不是设计冗余,而是为了让你不用再读一次内存就能重算。
AtomicInteger.incrementAndGet 的自旋循环正是靠这个特性写得又短又快。
三、为什么用 CAS:它和锁的本质区别
悲观与乐观,是两种世界观
| 维度 | 互斥锁(悲观) | CAS(乐观) |
|---|---|---|
| 假设 | 冲突一定会发生 | 冲突很少发生 |
| 做法 | 先独占,再操作 | 先操作,提交时校验 |
| 冲突时 | 排队等待,线程被挂起 | 立即失败,自己重试 |
| 状态 | 内核态 / 用户态切换 | 全程用户态 |
| 死锁 | 可能(需要顺序、超时兜底) | 不存在(不持有资源) |
三个实打实的好处
1. 没有上下文切换。
synchronized 竞争失败会把线程挂起,交给操作系统调度,一次唤醒的代价在微秒级。CAS 失败只是循环再来一次,全程在用户态。对于"临界区只有一行代码"的场景(比如计数器 +1),这个差距是数量级的。
2. 不会死锁,也不会有优先级反转。
CAS 不持有任何资源,自然不存在"持有 A 等 B"的死锁链条。高优先级线程也不会被一个恰好持锁的低优先级线程卡住。
3. 天然适配"状态只能前进一次"这类业务。
订单从 PAID 到 SHIPPED 只能发生一次,这个语义用锁表达很别扭(要判断、要加锁、要防重入),用 CAS 表达就一句话:
sql
UPDATE orders SET status = 'SHIPPED' WHERE id = 1001 AND status = 'PAID';
影响行数 1 就是"我抢到了这次流转",0 就是"别人已经改过了"。判断和修改合并成了一个原子动作,业务语义和并发控制完美重合。
三个必须知道的代价
- ABA 问题:值改回来了,CAS 察觉不到中间的变化。这是最隐蔽的坑,第五节专门讲。
- 自旋开销:冲突激烈时线程反复空转,CPU 占用飙升,吞吐反而下降。
- 只能保护一个变量:两个字段要一起改,单条 CAS 无能为力。
记住一句话:CAS 不是"更好的锁",它是"适用范围更窄但更快"的工具。把它的适用边界搞清楚,比记住它有多快重要得多。
四、它是怎么演进过来的
并发控制这门技术,六十年只做了一件事:把"等待"的范围越缩越小。

图 3:六个阶段,每一代都不是推翻上一代,而是把上一代缩到更小的范围里
第一代:互斥锁(1970s)
多核处理器出现后,最先想到的办法是"排队"。Dijkstra 在 1965 年提出信号量,此后 mutex、monitor、synchronized 一路发展。思路简单粗暴:把临界区围起来,一次只放一个人。
问题也很明显:它保护的是"代码块",而不是"数据"。锁的粒度靠人拍脑袋决定,粒度大了性能差,粒度小了护不住。
第二代:硬件原子指令(1990s)
CPU 厂商意识到,如果"比较并交换"能做成一条指令,软件层的大量锁就可以省掉。于是有了 CMPXCHG(x86)、LL/SC(ARM / POWER)。
这一代的关键突破是:把"读-比较-写"三步压缩成硬件层面不可分割的一步,从根上消灭了窗口。ABA 问题也是在这个阶段被发现的 ------ 它不是你代码写得不好,而是 CAS 语义本身的天花板。
第三代:语言封装(2004 前后)
cmpxchg 是汇编,普通业务开发用不了。Java 5 引入 java.util.concurrent.atomic 包,把 CAS 自旋循环封在 AtomicInteger 里;C++11 引入 std::atomic;Java 9 之后有了更安全的 VarHandle。
这一代的意义是把并发原语从"系统程序员专属"变成"业务程序员可用" ,count.incrementAndGet() 从此可以放心替代 synchronized。
第四代:补缺陷(2008 之后)
人们发现两个新问题:ABA 和高竞争下的自旋浪费。于是有了 AtomicStampedReference(值 + 版本戳一起 CAS)、LongAdder(把热点拆成多个 Cell 分段累加,最后求和)、以及各种 backoff 策略。
这一代的主题是承认 CAS 有短板,然后在它上面打补丁。
第五代:语义外溢到存储层(2010s)
内存里的 CAS 思路被搬到了数据库和缓存:version 字段乐观锁、Redis 的 WATCH/MULTI 与 Lua 脚本、ZooKeeper 的版本号比较(setData 带 version 参数)。同一个"比较版本再写入"的思想,换了个战场。
第六代:在架构层消灭竞争(2020s)
最彻底的做法不是让冲突变得更快,而是让冲突不发生:按用户 ID 分片、单线程消费队列(Disruptor)、Actor 模型、把扣减改成"预占 + 异步落库"。
这也是我在企业项目里最推荐的思路:能靠架构拆掉的竞争,就不要靠锁去扛。
五、CAS 的三个缺陷,以及怎么补
缺陷一:ABA ------ 值回去了,世界没有回去
线程 T1 读到 V = A,刚准备 CAS,被调度走了。T2 把 A 改成 B,又改回 A。T1 醒来发现 V 还是 A,兴高采烈地 CAS 成功 ------ 但它基于的那个"世界"已经不是原来的世界了。

图 4:ABA 的完整时序 ------ T1 以为自己改的是那个 A,实际上是另一个 A
为什么计数器场景不怕 ABA? 因为 1 → 2 → 1 之后重新加一,结果依然是 2,值相等就是真的相等。但指针和状态机怕:地址相同不代表节点相同,状态字符串相同不代表中间没发生过退款。
三种解法:
| 解法 | 原理 | 典型实现 |
|---|---|---|
| 版本戳(最常用) | 值 + 单调递增计数一起 CAS,计数不回退 | AtomicStampedReference、数据库 version 字段 |
| 加宽原子字 | 用 CMPXCHG16B 把指针和计数打包成 128 位 |
无锁栈顶指针、带标记的引用 |
| 延迟回收 | 保证被读的节点不会被释放复用(hazard pointer / epoch) | 无锁队列、内存回收器 |
一句话总结:能让版本号单调递增,就不要只比较业务值。
缺陷二:自旋开销 ------ 空转也是要烧 CPU 的
CAS 失败就重试,冲突越激烈,重试越多。极端情况下几百个线程围着一个变量空转,CPU 100%,吞吐还不如加锁。

图 5:CAS 的完整循环 ------ 循环体只该有「读 - 算 - CAS」,多一行 IO,冲突概率就翻倍
四个工程解法:
- 缩短循环体:循环里只放纯计算,绝不放 IO、日志、RPC。
- 限制自旋次数 + 退避 :失败 N 次后
sleep随机毫秒,或直接降级为锁。 - 分段(striping) :
LongAdder的思路,把热点拆成多个 Cell,各自 CAS,读取时求和。 - 换数据结构:高竞争场景直接用队列串行化,让竞争变成排队。
缺陷三:只能保护一个变量
stock 和 version 要一起改,单条 CAS 做不到。三种绕过方式:
- 打包 :把多个字段封成不可变对象,用
AtomicReference换整个引用。 - 下沉:交给数据库的一条 UPDATE 语句(数据库天然支持多字段原子更新)。
- 加锁:承认这里不适合无锁,老老实实加锁反而更清晰。
六、怎么用:四种落地的完整代码
先明确一个前提,这决定了 Python 程序员该怎么用 CAS。
6.1 Python 的现实:没有原生 CAS
CPython 有 GIL(全局解释器锁),同一时刻只有一个线程在执行字节码。这带来两个后果:
- 大部分"看起来的并发"其实是串行的,很多场景不加锁也碰巧不出错 ------ 这是最危险的错觉。
- 语言层没有暴露 CAS 原语 。
ctypes.atomic属于实验性提案,主流项目里不能依赖。
关键认知:GIL 保证的是"单条字节码"原子,不是"一段逻辑"原子 。count += 1 会被拆成 LOAD / ADD / STORE 三条字节码,线程完全可能在中间被切走。
python
import threading
import time
count = 0
def add():
global count
for _ in range(1000):
tmp = count # 读
time.sleep(0) # 一次主动让出:真实业务里这里是一行日志、一次查库
count = tmp + 1 # 写
ts = [threading.Thread(target=add) for _ in range(8)]
for t in ts:
t.start()
for t in ts:
t.join()
print(count) # 期望 8000,实测往往只有 1400 左右
这段代码每次跑出来的数都不一样,但都远小于 8000。砍掉的那几千次自增,就是被"读和写之间"的窗口吃掉的。
这里有个特别值得说清楚的细节:如果你把 time.sleep(0) 换成 count += 1 的紧凑循环,结果往往是对的。 因为 CPython 的 GIL 切换点通常在循环回边,紧凑循环里几乎不会被打断。
这恰恰是 Python 并发最危险的地方 ------ 错的代码在测试里能跑通 。等你哪天在中间加了一行日志、一次 Redis 查询、一个 time.sleep,事故就出现了。所以判断标准不能是"我测了没问题",而是"这段逻辑在任意时刻被打断,结果还对不对"。
所以 Python 里用 CAS 思想,有三条现实路径:用锁模拟 CAS 语义 、用共享内存 + 锁做跨进程 、把 CAS 下沉到数据库或 Redis 。生产项目里,第三条是绝对主流。
6.2 路径一:用锁实现一个语义等价的 CAS 容器
这不是为了性能,是为了让"读-改-写"变成一个不可分割的动作,把自旋逻辑封在类内部,调用方就再也不用担心窗口问题。
python
import threading
class AtomicValue:
"""CAS 容器的教学实现:语义与硬件 CAS 一致,性能不等价"""
def __init__(self, value):
self._value = value
self._mutex = threading.Lock()
def get(self):
with self._mutex:
return self._value
def compare_and_set(self, expect, update):
"""等价于 CAS(V, E, N),返回是否写入成功"""
with self._mutex:
if self._value != expect:
return False
self._value = update
return True
def update(self, func):
"""自旋 CAS:把「读 - 算 - CAS」打包成一个原子动作"""
while True:
current = self.get()
target = func(current)
if self.compare_and_set(current, target):
return target
if __name__ == "__main__":
counter = AtomicValue(0)
def work():
for _ in range(50000):
counter.update(lambda v: v + 1)
threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter.get()) # 稳定输出 400000
这段代码有两个值得注意的设计:
update里没有break之外的分支,失败就静默重来。调用方看到的接口是"原子自增",看不到重试。func必须是纯函数 。如果func里有副作用(写日志、发消息),重试就会重复执行 ------ 这是把 CAS 用到业务代码里最常见的 bug。
6.3 路径二:跨进程的自增,用共享内存
多进程场景下 threading.Lock 完全无效(每个进程一把)。Python 标准库提供了带锁的共享内存,这是最省事的做法:
python
import multiprocessing as mp
def worker(counter, lock, times):
for _ in range(times):
with lock: # 跨进程的互斥量
counter.value += 1
if __name__ == "__main__":
counter = mp.Value("i", 0) # 共享内存中的一个 int
lock = mp.Lock()
procs = [mp.Process(target=worker, args=(counter, lock, 20000)) for _ in range(4)]
for p in procs:
p.start()
for p in procs:
p.join()
print(counter.value) # 80000
要注意 mp.Value 默认 lock=True,它自带一把锁;上面显式再传一把锁,是为了让"读-改-写"整体受保护 ------ 只保护单个读取没用,窗口依然存在。
6.4 路径三(生产主流):把 CAS 下沉到数据库
这是我在所有企业项目里的默认选择:应用层不做并发控制,让数据库的一条语句承担原子性。
先建表,version 就是那个"不会回退的戳记":
sql
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
stock INT NOT NULL,
version INT NOT NULL DEFAULT 0
);
第一步:复现一次真实的版本冲突。
python
import sqlite3
conn = sqlite3.connect("cas_demo.db")
conn.executescript("""
DROP TABLE IF EXISTS product;
CREATE TABLE product (id INTEGER PRIMARY KEY, stock INTEGER, version INTEGER);
INSERT INTO product VALUES (1, 10, 3);
""")
conn.commit()
# 两个请求几乎同时读到同一份快照
row_a = conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone()
row_b = conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone()
print("A 读到", row_a, " B 读到", row_b) # 都是 (10, 3)
# B 先提交:版本号 3 -> 4
cur = conn.execute(
"UPDATE product SET stock = ?, version = version + 1 WHERE id = 1 AND version = ?",
(row_b[0] - 1, row_b[1]),
)
conn.commit()
print("B 影响行数", cur.rowcount) # 1,提交成功
# A 拿着过期的 version=3 提交,必然失败
cur = conn.execute(
"UPDATE product SET stock = ?, version = version + 1 WHERE id = 1 AND version = ?",
(row_a[0] - 1, row_a[1]),
)
conn.commit()
print("A 影响行数", cur.rowcount) # 0,说明快照已过期
print("最终数据", conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone())
# 最终 (9, 4):只扣了一次,超卖被挡住
WHERE version = 3 这一行就是 CAS 的 V == E。影响行数为 0 不是错误,是"你看到的世界过期了"的信号。
第二步:把重试封装好,别让业务代码满地 for 循环。
python
import random
import sqlite3
import time
from contextlib import contextmanager
class VersionConflict(Exception):
"""版本冲突:快照过期,需要重新读取"""
@contextmanager
def transaction(conn):
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def deduct_stock(conn, product_id, qty, max_retry=3):
"""带退避的乐观锁扣减,返回是否成功"""
for attempt in range(1, max_retry + 1):
row = conn.execute(
"SELECT stock, version FROM product WHERE id = ?", (product_id,)
).fetchone()
if row is None:
return False, "商品不存在"
stock, version = row
if stock < qty:
return False, "库存不足" # 业务失败,不重试
with transaction(conn):
cur = conn.execute(
"UPDATE product SET stock = stock - ?, version = version + 1 "
"WHERE id = ? AND version = ? AND stock >= ?",
(qty, product_id, version, qty),
)
if cur.rowcount == 1:
return True, f"第 {attempt} 次尝试成功"
# 冲突:指数退避 + 随机抖动,避免所有线程同时重来
time.sleep((2 ** attempt) * 0.001 * random.random())
return False, f"重试 {max_retry} 次仍冲突,请稍后再试"
三个细节决定了这段代码在生产里能否活下来:
max_retry必须有限,并且返回明确的失败语义。无限重试在高峰期会把数据库彻底压垮。- 退避要带随机抖动,否则所有失败线程会在同一时刻再次涌入,形成"重试风暴"的驻波。
- 重试必须幂等。扣减类操作天然幂等(每次都基于最新库存减),但"创建订单"这类操作必须有幂等键,否则重试就是重复下单。
第三步:能用条件更新,就别用版本号。
很多时候业务规则本身就够用,不需要 version:
sql
-- 原子扣减:库存不足时影响行数为 0,天然防超卖
UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock >= 1;
这条语句比"查出来判断再写回"可靠一百倍,而且并发性能更好 ------ 数据库只锁一次行,不需要额外的版本字段维护。判断条件能写进 WHERE,就说明它根本不需要在应用层加锁。
6.5 Java:AtomicInteger 源码里的自旋
看懂了 Python 的模拟版,再看 JDK 的原生实现,会发现结构一模一样。
java
// java.util.concurrent.atomic.AtomicInteger (JDK 8)
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// sun.misc.Unsafe#getAndAddInt
public final int getAndAddInt(Object o, long offset, int delta) {
int prev;
do {
prev = getIntVolatile(o, offset); // 读,并且保证可见性
} while (!compareAndSwapInt(o, offset, prev, prev + delta)); // 失败就重来
return prev;
}
再往下就是一行汇编,LOCK 前缀让操作在多核之间原子:
text
lock cmpxchg dword ptr [rdx], ecx
两个关键点:
value必须声明为volatile。CAS 只保证原子性,不保证写完立刻对其他线程可见;volatile补上可见性,两者配合才是完整的。valueOffset是字段在对象内存中的偏移量。Java 不能直接传指针,所以用"对象引用 + 偏移量"来定位内存地址。
Java 里修 ABA 的标准写法:
java
import java.util.concurrent.atomic.AtomicStampedReference;
public class OrderState {
// 值 + 版本戳,一起 CAS
private final AtomicStampedReference<String> state =
new AtomicStampedReference<>("PAID", 0);
public boolean ship() {
int[] stampHolder = new int[1];
while (true) {
String current = state.get(stampHolder); // 值和戳一起读
int stamp = stampHolder[0];
if (!"PAID".equals(current)) {
return false; // 状态不对,直接拒绝
}
// 值相等还不够,戳也必须相等
if (state.compareAndSet(current, "SHIPPED", stamp, stamp + 1)) {
return true;
}
}
}
}
6.6 分布式:Redis 里的 CAS
单机 CAS 只能护住一个进程,跨服务就得靠 Redis。两种写法,优先用 Lua。
写法一:WATCH + MULTI(乐观事务)
python
import redis
r = redis.Redis(decode_responses=True)
def cas_set(key, expect, update):
with r.pipeline() as pipe:
while True:
try:
pipe.watch(key) # 开始监视
current = pipe.get(key)
if current != expect:
pipe.unwatch()
return False
pipe.multi() # 进入事务
pipe.set(key, update)
pipe.execute() # key 被改过则抛 WatchError
return True
except redis.WatchError:
continue # 重来
WATCH 的语义和 CAS 完全一致:监视的 key 在 EXEC 之前被改过,事务就整体失败。
写法二:Lua 脚本(推荐)
Redis 执行 Lua 是单线程原子的,脚本内部不会有任何命令插进来,天然就是 CAS。
lua
-- cas_set.lua
local key = KEYS[1]
local expect = ARGV[1]
local update = ARGV[2]
local current = redis.call('GET', key)
if current == expect or (current == false and expect == '__NIL__') then
redis.call('SET', key, update)
return 1
end
return 0
python
import redis
r = redis.Redis(decode_responses=True)
with open("cas_set.lua", encoding="utf-8") as f:
cas_script = r.register_script(f.read())
# 抢占一个分布式任务:只有把 PENDING 改成 RUNNING 的那个实例才执行
acquired = cas_script(keys=["job:9527:status"], args=["PENDING", "RUNNING"])
if acquired == 1:
run_job(9527)
else:
print("已被其他实例抢走,本次跳过")
这个"状态抢占"模式是企业项目里 CAS 最高频的用法:定时任务多副本部署时,靠它保证同一时刻只有一个实例在跑。
七、企业项目里怎么用:三层接力
真实项目里 CAS 从来不是单点技术,而是三层配合。上层的目标是"挡住绝大部分竞争",下层的目标是"兜住所有漏网的并发"。

图 6:应用层挡同进程、缓存层挡流量洪峰、存储层兜最终正确,兜底层负责收口
第一层:应用层(进程内)
- 计数器 / 限流令牌 / 序号发号 :
AtomicLong、AtomicInteger。别用synchronized包一个++,那是杀鸡用牛刀。 - 配置热更新:用不可变对象 + 引用替换。读侧永远拿到一个完整快照,不需要加锁,也不会读到"改了一半"的配置。
- 状态位 :带版本戳的
AtomicStampedReference,或者干脆把状态迁移收敛到数据库。
注意边界:这一层只在单进程内有效。多副本部署时它挡不住任何跨进程的竞争,别拿它当分布式锁用。
第二层:缓存层(跨进程)
- 热点扣减前置:秒杀场景先在 Redis 用 Lua 脚本原子扣减,扣成功才放行到数据库,把 99% 的无效请求挡在 DB 之外。
- 分布式任务抢占 :
SETNX或上面的 Lua CAS,保证同一时刻只有一个实例执行。 - 幂等控制 :用业务唯一键做
SET NX,重复请求直接短路。
注意:Redis 的原子扣减和数据库不是同一个事务,必须有"预扣成功但落库失败"的补偿逻辑(回滚预扣 + 对账)。
第三层:存储层(最终真相)
UPDATE ... WHERE version = ?:低冲突场景的标准解法。UPDATE ... WHERE stock > 0:条件更新,比版本号更简单,优先考虑。- 唯一索引 / 状态机约束:最后的物理防线。重复插入直接被数据库拒绝,比任何应用层判断都可靠。
兜底层:对账 + 补偿 + 幂等键
任何一层都可能因为网络超时、进程被杀而"看起来失败、其实成功"。最终一致性靠这一层收口,不靠重试次数硬扛。 我见过太多项目把 max_retry 从 3 调到 10 来"解决"并发问题,结果只是把故障从"偶发超卖"变成了"数据库雪崩"。
八、竞品对比:六种并发控制手段怎么选
| 维度 | CAS 原子类 | 互斥锁 synchronized / Lock | 数据库悲观锁 FOR UPDATE |
数据库乐观锁 version | 分布式锁 Redis / ZK | 串行化(队列 / 单线程) |
|---|---|---|---|---|---|---|
| 阻塞行为 | 不阻塞,失败重试 | 阻塞,线程挂起 | 阻塞,等行锁 | 不阻塞,失败返回 | 阻塞或快速失败 | 排队,但调用方通常异步 |
| 跨进程 | 否 | 否 | 是 | 是 | 是 | 是 |
| 冲突高时 | 差(空转、CPU 飙高) | 一般(上下文切换多) | 较好(排队有序) | 差(重试风暴) | 一般(依赖 Redis 性能) | 好(天然削峰) |
| 死锁风险 | 无 | 有 | 有(需固定加锁顺序) | 无 | 有(需过期 + 续约) | 无 |
| 性能 | 极高(纳秒级) | 中(微秒级) | 低(毫秒级,含 IO) | 中(一次 UPDATE) | 中低(一次网络往返) | 中(取决于消费速度) |
| 一致性强度 | 单变量强一致 | 单机强一致 | 强一致 | 强一致(提交成功即生效) | 依赖实现,可能脑裂 | 最终一致 |
| 实现复杂度 | 低(用现成类) | 低 | 低 | 中(要写重试) | 高(要考虑续约、误删) | 高(要建链路、处理堆积) |
| 典型场景 | 计数、状态位、限流 | 单机临界区 | 转账、强互斥 | 库存、资料编辑、状态流转 | 定时任务抢占、跨服务互斥 | 秒杀、订单创建 |
| 最大的坑 | ABA、自旋 | 锁粒度、死锁 | 长事务拖垮连接池 | 高冲突重试风暴 | 锁过期业务没跑完 | 消息堆积、延迟 |

图 7:选型只看两个变量 ------ 冲突有多密,失败有多贵
一句口诀记牢:冲突少就赌一把,冲突多先拆热点,代价高就别赌。
九、常用场景:这八个地方最适合 CAS
1. 计数器与统计
QPS 统计、点赞数、播放量。低冲突用 AtomicLong,高冲突换 LongAdder(分段累加,读取时求和)。
java
LongAdder qps = new LongAdder();
qps.increment(); // 高并发下比 AtomicLong 快数倍
long total = qps.sum();
2. 状态机的一次性流转
订单、工单、审批流。核心写法就一句 UPDATE ... WHERE id = ? AND status = ?,用影响行数判断"这次流转归不归我"。
3. 库存扣减与优惠券核销
优先用条件更新(WHERE stock >= 1),需要记录版本轨迹时才加 version 字段。
4. 分布式定时任务抢占
多副本部署时,用 Redis Lua 把 PENDING 改成 RUNNING,只有一个实例能改成功。
5. 一次性初始化
懒加载单例、缓存预热标记、配置加载开关。用 AtomicBoolean.compareAndSet(false, true) 保证只执行一次。
java
private final AtomicBoolean initialized = new AtomicBoolean(false);
public void init() {
if (initialized.compareAndSet(false, true)) {
loadConfig(); // 只有一个线程能进来
}
}
6. 配置热更新
把配置封成不可变对象,改的时候构造新对象、整体替换引用。读侧完全无锁,也不会读到半成品。
7. 限流与令牌桶
令牌数的增减用 CAS,避免为了一次减法去抢一把锁。
8. 无锁数据结构
无锁队列、无锁栈、RingBuffer。这类场景建议直接用成熟库(Disruptor、JCTools),自己写无锁结构在生产环境翻车概率极高 ------ 内存序、伪共享、安全回收,每一个都能让你排查一周。
十、面试高频题(附答题要点)
Q1:CAS 是什么?三个操作数分别是什么?原子性由谁保证?
比较并交换。V 是内存地址,E 是期望值,N 是新值。相等则写入 N 返回成功,否则不写返回失败。原子性由 CPU 指令 保证(x86 的 lock cmpxchg、ARM 的 LDREX/STREX),不是软件层的锁。
Q2:CAS 为什么比 synchronized 快?什么时候反而更慢?
快在不阻塞 :没有线程挂起/唤醒,没有内核态切换,失败只是循环重试。慢在高冲突 :大量线程空转,CPU 占用飙升,缓存行来回同步(伪共享),吞吐反而不如锁。判断标准是临界区长度 和冲突概率:临界区越短、冲突越少,CAS 越划算。
Q3:ABA 问题是什么?怎么解决?
值从 A 变成 B 又变回 A,CAS 检查值相等就成功,但中间状态已经变了。计数器场景无害(相等就是相等),指针和状态机场景有害 (地址相同不代表节点相同)。解法是加版本戳:AtomicStampedReference、数据库 version 字段、CMPXCHG16B 打包 128 位。
Q4:CAS 只能操作一个变量,要同时改两个字段怎么办?
三种:把字段封成不可变对象用 AtomicReference 换整个引用;下沉到数据库用一条 UPDATE;直接加锁。别硬凹无锁,复杂度会失控。
Q5:Java 里 CAS 的底层实现是什么?为什么要加 lock 前缀?
Unsafe.compareAndSwapInt 是 native 方法,最终编译成 lock cmpxchg。lock 前缀做两件事:保证指令的原子性(多核之间通过缓存一致性协议独占缓存行),并提供内存屏障语义(禁止指令重排、保证写入对其他核心可见)。
Q6:CAS 能保证可见性吗?为什么 value 还要 volatile?
CAS 保证的是原子性 和有序性 ,不保证"写完立刻被其他线程看到"。volatile 负责可见性和禁止重排。AtomicInteger 内部的 value 就是 volatile 的,两者配合才完整。
Q7:LongAdder 为什么比 AtomicLong 快?
AtomicLong 所有线程抢同一个变量,冲突高时大量 CAS 失败。LongAdder 继承 Striped64,内部维护一个 Cell 数组,不同线程按 probe 值落到不同 Cell 上各自 CAS,把热点分散开;读取时把 base 和各 Cell 求和。代价是 sum() 不是强一致的快照。
Q8:数据库乐观锁怎么实现?影响行数为 0 怎么办?
表上加 version 字段,更新时 WHERE version = 旧值,同时 version = version + 1。影响行数 1 表示成功,0 表示快照过期。处理方式:有限次重试 + 指数退避 + 随机抖动,超过次数返回明确的业务失败。绝不能无限重试。
Q9:为什么说"条件更新比乐观锁更好"?
如果业务规则能写进 WHERE(比如 stock >= 1),就不需要维护 version 字段,也不需要重试循环 ------ 数据库一次原子 UPDATE 就完成了判断和修改。乐观锁是"不得不比较多个字段"时的退路。
Q10:分布式场景下 CAS 怎么落地?Redis Lua 为什么是原子的?
用 Redis 的 Lua 脚本:脚本里先 GET 比较再 SET。Redis 单线程执行命令,脚本执行期间不会插入其他命令,所以整个"比较 + 写入"是原子的。WATCH/MULTI 也能实现,但在高并发下 WatchError 重试率更高,Lua 更稳。
Q11:CAS 和自旋锁是什么关系?
自旋锁是用 CAS 实现的一把锁 :while (!state.compareAndSet(0, 1)) {}。区别在于 CAS 是原语(一次操作),自旋锁是用原语构建的同步结构(保护一段临界区)。自旋锁适合临界区极短的场景,否则空转浪费 CPU。
Q12:什么场景你会主动放弃 CAS,改用锁?
四种:临界区里有 IO 或 RPC(自旋会等到天荒地老);需要同时改多个变量且无法打包;冲突率极高(重试成本超过阻塞成本);逻辑复杂到需要人读懂(可维护性也是性能)。
十一、收尾:一张能贴到工位上的决策清单
看到并发问题,按这个顺序问自己四句话:
- 能不能不改共享状态? ------ 用不可变对象、用消息传递、按 key 分片,把竞争从源头拆掉。这是最优解。
- 能不能用一条 SQL 解决? ------ 条件更新、唯一索引、
WHERE version = ?。数据库的原子性最可靠,也最好维护。 - 是不是只在单进程内? ------ 是就用
AtomicXxx;有 ABA 风险就上AtomicStampedReference;冲突高就换LongAdder。 - 是不是跨服务? ------ 用 Redis Lua 做抢占或预扣,但一定要配补偿和对账。
最后送一句我在生产环境里反复验证过的话:
乐观锁不是"不加锁",而是"把冲突检测推迟到提交那一刻"。
推迟得好,你省下了等待;推迟得不好,你只是把等待换成了重试风暴。
CAS 的全部威力,都藏在这句话里。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。