CAS无锁编程实战:从原理到避坑,彻底吃透原子操作

发布时间:2026/10/31 18:36:26
CAS无锁编程实战:从原理到避坑,彻底吃透原子操作

并发编程里聊到无锁,CAS是绕不过去的一道坎。很多同学背面试题的时候都能说出“比较并交换”五个字,但真到了线上排查一个自旋开销过高的接口,或者自己动手写一个无锁队列的时候,才发现对CAS的理解只停留在名词层面。这篇内容我就从一线开发的视角,把CAS这个东西从原理到落地拆开讲一遍,既讲清楚它为什么能保证原子性,也讲清楚它在什么场景下会拖垮你的服务。适合已经写过一些多线程代码、想真正吃透无锁编程基础的读者,也适合正在准备技术评审、需要把底层机制讲明白的从业者。读完你至少能搞清楚三件事:CAS的原子性到底由谁保证、ABA问题在真实业务里长什么样、以及什么时候该果断放弃CAS换回锁。

1. 内容整体设计与思路拆解

1.1 为什么要在无锁方案里选CAS

要理解CAS的价值,得先看它解决了什么问题。传统的互斥锁方案,线程拿不到锁就阻塞挂起,等锁释放了再被唤醒。这个过程中涉及用户态和内核态的切换,一次上下文切换的开销在微秒级别,如果临界区本身只有几十条指令,那切换成本可能比业务逻辑本身还高。更麻烦的是阻塞带来的线程调度不确定性,高并发下容易出现大量线程排队等锁,吞吐量上不去。

CAS的思路完全不同:它不阻塞任何线程,而是让每个线程都去尝试修改,改成功了就走,改失败了就重试或者放弃。这种“乐观”的策略在竞争不激烈的场景下优势非常明显,因为省掉了挂起和唤醒的开销。我在实际项目里做过一个粗略对比,同样是累加一千万次,低竞争下基于CAS的原子类比synchronized快出接近一个数量级,当然这个数字随竞争程度变化很大,不能当成通用结论。

选择CAS作为无锁编程的基石,核心考量有三点。第一是原子性由硬件指令兜底,不依赖软件层面的锁协议,可靠性高。第二是无阻塞特性,单个线程的失败不会导致其他线程停滞,天然避免了死锁。第三是粒度细,可以只针对一个变量做原子操作,不像锁那样容易把临界区放大。理解了这三点,后面所有的坑其实都是这三点的反面代价。

1.2 CAS的三要素与执行语义

CAS全称Compare And Swap,比较并交换。它接收三个操作数:内存位置V、预期旧值A、新值B。执行逻辑用伪代码表达就是:如果V当前的值等于A,就把V更新为B并返回成功;否则什么都不做并返回失败。整个“比较加更新”是一个不可分割的原子操作,中间不会被其他线程插入。

这里有个容易被忽略的细节:“比较”和“交换”必须是同一个原子步骤。如果拆成两步,先读V判断等于A,再写V为B,那在判断和写入之间就可能被别的线程改掉V,整个语义就崩了。所以CAS的原子性不是靠代码顺序保证的,而是靠处理器提供的一条指令完成。x86架构下对应的是cmpxchg指令,配合lock前缀保证多核环境下的内存可见性和原子性。ARM架构下则有LDREX和STREX这对指令组合来实现类似效果。

从内存语义角度看,CAS通常带有完整的内存屏障效果,也就是说它之前的读写不会被重排到它之后,之后的读写也不会被重排到它之前。这一点在写无锁数据结构时至关重要,很多看起来“莫名其妙”的bug,根源就是忽略了CAS隐含的内存序约束。我在早期写一个无锁栈的时候就栽过跟头,节点入栈的顺序和指针更新的顺序没处理好,压测时偶发丢数据,最后定位到就是内存重排的问题。

1.3 方案选型的边界:CAS不是万能药

很多资料把CAS吹成高并发银弹,这是有误导性的。CAS有明确的适用边界,超出边界用它反而更糟。我总结下来,判断要不要用CAS,看三个维度。

第一看竞争强度。CAS在低竞争下表现优异,但高竞争下大量线程反复失败重试,CPU空转严重,这时候锁的排队机制反而更高效。第二看临界区大小。CAS只能保护单个变量的原子更新,如果你的业务逻辑涉及多个变量的联动修改,硬用CAS会写出极其复杂的版本号管理代码,可维护性直线下降。第三看操作复杂度。简单的赋值、累加适合CAS,涉及条件分支的复合操作,用CAS实现起来容易出错。

提示:不要因为“无锁”两个字听起来高级就无脑选CAS。我见过一个团队为了追求性能,把一个本来用锁很清晰的计数器改成了CAS自旋,结果在流量高峰期CPU使用率飙到百分之九十多,最后又老老实实改回锁。选型要看场景,不看名词。

2. 核心细节解析与实操要点

2.1 原子性到底由谁保证

这是理解CAS最关键的一环,也是面试里区分“背过”和“真懂”的分水岭。CAS的原子性不是Java语言层面给的,也不是JVM给的,而是处理器指令集给的。Java里的Unsafe.compareAndSwapInt最终会调用到本地方法,映射到对应平台的原子指令上。

以x86为例,cmpxchg指令本身在单核下是原子的,但多核环境下还需要lock前缀。lock前缀会做两件事:一是锁住总线或者锁住缓存行,保证同一时刻只有一个核心能操作该内存;二是充当内存屏障,禁止指令重排。这两件事合起来,才让CAS在多核环境下真正可靠。

理解这一点有实际意义。比如你在写JNI代码或者做跨语言移植时,如果目标平台没有对应的原子指令,CAS的实现就得靠软件模拟,性能和可靠性都会打折扣。再比如某些嵌入式场景,处理器不支持原子指令,那基于CAS的无锁算法根本没法用,只能退回锁方案。所以“CAS一定比锁快”这个结论是有前提的,前提就是硬件支持。

2.2 ABA问题:最隐蔽的那个坑

ABA问题是CAS最经典的缺陷。场景是这样的:线程1读到内存值A,准备改成C;这时候线程2把值从A改成B,又改回A;线程1执行CAS时发现值还是A,以为没人动过,就成功改成了C。但实际上中间已经发生过变化,线程1的判断依据失效了。

这个问题在计数器场景下通常无害,因为数值最终是对的。但在引用类型的场景下就可能出大问题。举个我实际遇到过的例子:一个无锁栈,线程1准备弹出栈顶节点A,读到了A的引用和它的next指针;此时线程2把A弹出、又把A重新压入(或者A被回收后新对象复用了同一地址);线程1执行CAS时发现栈顶还是A,就成功把栈顶设成了A的next,结果把整个栈结构搞乱了。

解决ABA的常规手段是加版本号。每次修改不仅更新值,还递增一个版本计数,CAS时同时比较值和版本号。Java里AtomicStampedReference就是干这个的,它把引用和一个int戳打包在一起做CAS。代价是每次操作要多维护一个字段,内存占用和操作开销都上去了。所以我的经验是:只有引用类型且存在“值被改回原样”风险的场景才需要版本号,普通数值计数器没必要上,别为了防一个不存在的风险把代码搞复杂。

2.3 自旋开销与退让策略

CAS失败后怎么办?最直接的做法是循环重试,也就是自旋。自旋的好处是没有线程切换开销,坏处是失败次数多了会白白烧CPU。高并发下如果几十个线程同时对同一个变量做CAS,只有一个能成功,其余全部失败重试,CPU时间大量浪费在无效的比较上。

这里就涉及退让策略的设计。简单粗暴的写法是while(!cas(...)){},这种在竞争激烈时非常危险。稍微好一点的做法是失败后调用Thread.yield()让出时间片,或者用指数退避,失败次数越多等待越久。JDK里的LongAdder就是这方面的典范,它把热点分散到多个Cell上,每个线程尽量操作自己的Cell,从根本上降低了单点竞争,这比在单个变量上死磕自旋聪明得多。

我在实际调优中总结的经验是:自旋次数要设上限。纯自旋适合临界区极短、竞争极低的场景;一旦发现自旋次数经常超过某个阈值(比如十次以上),就该考虑换方案了,要么用锁,要么用分段思想分散竞争。无脑自旋是新手最容易犯的错,线上表现为某个接口CPU占用异常但QPS上不去,排查时看线程栈全是CAS重试的循环。

2.4 内存序与可见性的隐性约束

CAS操作本身带有内存屏障语义,但这不代表你写的无锁代码就自动线程安全了。屏障保证的是CAS这条指令前后的内存操作顺序,但你代码里其他普通变量的读写顺序,仍然需要你自己用volatile或者显式屏障来约束。

举个典型场景:你要发布一个不可变对象,先初始化对象的各个字段,再把引用赋给一个共享变量。如果这个共享变量不是volatile,即使赋值用了CAS,其他线程也可能看到引用已经更新但字段还是默认值的“半初始化”状态。原因是字段初始化的写操作可能被重排到引用赋值之后。正确做法是把共享引用声明为volatile,利用它的写屏障保证之前的初始化全部完成。

这个点在写无锁数据结构时尤其重要。我见过不少自研的无锁队列,压测时偶发读到空对象,追根溯源都是可见性没处理好。记住一句话:CAS管的是那一个变量的原子性,管不了你整个对象状态的发布顺序。

3. 实操过程与核心环节实现

3.1 手写一个基于CAS的原子计数器

光说不练假把式,我们从一个最简单的原子计数器开始,把CAS的调用链路走一遍。下面这段代码用Java的AtomicInteger演示,重点看它的自增实现逻辑。

import java.util.concurrent.atomic.AtomicInteger; public class CasCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { int oldValue; int newValue; do { oldValue = count.get(); newValue = oldValue + 1; } while (!count.compareAndSet(oldValue, newValue)); } public int get() { return count.get(); } }

这段代码的核心就是那个do-while循环。先读当前值,算出目标值,然后CAS尝试更新。如果CAS返回false,说明在读取和更新之间有人改过,那就重新读、重新算、重新试。这个模式叫“读取-计算-CAS”循环,是无锁编程最基础的套路。

这里有个细节值得说:count.get()读到的值可能已经过期,但这不影响正确性,因为CAS会再校验一次。过期了顶多白算一次,不会写错。真正要保证的是“计算”这一步必须是纯函数式的,不能有副作用,否则重试会导致副作用重复执行。比如你在循环里做了日志打印或者远程调用,重试就会重复执行,这是大忌。

3.2 用CAS实现一个无锁栈并处理ABA

计数器太简单,体现不出CAS的坑。我们来看一个无锁栈的实现,这里能同时遇到ABA和内存序两个问题。

import java.util.concurrent.atomic.AtomicReference; public class LockFreeStack<T> { private static class Node<T> { final T value; Node<T> next; Node(T value) { this.value = value; } } private final AtomicReference<Node<T>> top = new AtomicReference<>(null); public void push(T value) { Node<T> newNode = new Node<>(value); Node<T> oldTop; do { oldTop = top.get(); newNode.next = oldTop; } while (!top.compareAndSet(oldTop, newNode)); } public T pop() { Node<T> oldTop; Node<T> newTop; do { oldTop = top.get(); if (oldTop == null) return null; newTop = oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); return oldTop.value; } }

这个实现看起来没问题,但它有两个隐患。第一个是ABA:pop里读到oldTop后,如果别的线程把oldTop弹出又压回,compareAndSet会误判成功,导致栈结构错乱。第二个是newNode.next = oldTop这行写在循环里,如果top是普通引用而非AtomicReference,这行赋值和后面的CAS之间可能存在重排问题,不过这里AtomicReference的CAS自带屏障,基本能兜住。

要彻底解决ABA,得换成带版本号的引用。JDK提供了AtomicStampedReference,用法如下:

import java.util.concurrent.atomic.AtomicStampedReference; public class StampedStack<T> { private static class Node<T> { final T value; Node<T> next; Node(T value) { this.value = value; } } private final AtomicStampedReference<Node<T>> top = new AtomicStampedReference<>(null, 0); public void push(T value) { Node<T> newNode = new Node<>(value); int[] stampHolder = new int[1]; Node<T> oldTop; int oldStamp; do { oldTop = top.get(stampHolder); oldStamp = stampHolder[0]; newNode.next = oldTop; } while (!top.compareAndSet(oldTop, newNode, oldStamp, oldStamp + 1)); } }

注意get方法要把戳读到一个数组里带出来,这是Java里返回多值的一种变通写法。每次CAS成功戳加一,这样即使引用地址被复用,戳也对不上,ABA就被挡住了。代价是每次操作多读写一个int,内存和指令数都增加,所以还是那句话,按需使用。

3.3 参数选择:自旋上限与退避间隔怎么定

自旋策略的参数没有标准答案,得根据实际压测数据调。我一般按这个流程来定:先写一个纯自旋版本,在目标并发量下压测,记录平均自旋次数和P99自旋次数;如果P99超过十次,就引入退避;退避间隔从纳秒级开始试,逐步放大到微秒级,观察吞吐和CPU占用的拐点。

下面是一个带退避的自旋示例,用Thread.onSpinWait()提示处理器当前在自旋,这在支持该指令的平台上能降低功耗和总线压力。

public boolean casWithBackoff(AtomicInteger target, int expect, int update) { int spins = 0; while (!target.compareAndSet(expect, update)) { if (spins < 10) { Thread.onSpinWait(); } else if (spins < 100) { Thread.yield(); } else { try { Thread.sleep(0, 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } spins++; expect = target.get(); } return true; }

这段代码的分层思路是:前几次纯自旋,利用缓存局部性快速重试;中间让出时间片,给其他线程机会;后面直接短暂休眠,避免长时间空转。参数十和一百是我在几个中等并发场景下试出来的经验值,你的场景不一定适用,一定要自己压测。另外注意expect在循环末尾要重新读,否则一直拿旧值去CAS永远失败。

3.4 从单点CAS到分段思想:LongAdder的启示

单变量CAS在高并发下的瓶颈很明显,所有线程抢同一个缓存行,不仅CAS失败率高,缓存行在核心之间来回同步的开销也巨大。LongAdder的思路是把一个计数器拆成多个Cell,每个线程根据自己的哈希落到不同Cell上累加,最后求和时把所有Cell加起来。这样竞争被分散,吞吐量随核心数近似线性增长。

这个思路可以迁移到很多场景。比如你要统计多个维度的指标,与其用一个大的原子对象,不如按维度拆成多个原子变量,各自独立更新。再比如无锁队列,可以用多个槽位分散入队竞争。核心思想就一句话:把单点热点打散成多个低竞争的点。当然分段也有代价,求和不是原子的,读到的可能是各段不同时刻的快照,对强一致要求高的场景要慎用。

4. 常见问题与排查技巧实录

4.1 CAS相关问题的速查表

实际开发和排查中,CAS引发的问题有一些典型症状。我把常见情况整理成表,方便对照定位。

症状表现可能原因排查方向处理建议
CPU占用高但QPS上不去高竞争下CAS自旋空转抓线程栈看是否大量CAS重试循环引入退避或改用分段/锁
偶发数据丢失或结构错乱ABA问题检查引用类型CAS是否有版本号换AtomicStampedReference
读到半初始化对象内存可见性缺失检查共享引用是否volatile补volatile或显式屏障
计数器结果偏小自旋上限触发后放弃更新检查退避逻辑是否吞掉了失败失败要重试或上报,不能静默丢
压测吞吐随线程数增加反而下降缓存行伪共享用工具看缓存行冲突填充或分段隔离热点

这张表里最值得展开的是伪共享。多个线程操作同一个缓存行里的不同变量时,虽然逻辑上不冲突,但硬件层面缓存行在核心间反复失效同步,性能急剧下降。CAS场景下这个问题尤其突出,因为原子变量往往被高频访问。解决办法是缓存行填充,把热点变量隔离到独立缓存行,JDK里LongAdder的Cell就做了填充处理。

4.2 一个真实排查案例的复盘

之前有个服务,压测时发现一个统计接口的耗时随并发上升呈非线性增长,单线程时几毫秒,五十并发时飙到几百毫秒。第一反应是数据库慢,但慢查询日志干干净净。抓线程栈发现大量线程卡在一个自研的原子累加方法里,就是纯自旋的CAS循环。

进一步看,那个累加器被所有请求线程共享,每次请求要累加十几次,五十并发下相当于每秒几千次CAS打在一个变量上,失败率极高。定位到问题后做了两件事:一是把累加器换成LongAdder,分散热点;二是把非关键的统计改成异步批量上报,减少实时竞争。改完之后同样压测,耗时回落到十毫秒以内。

这个案例给我的教训是:CAS的性能优势有前提,前提是竞争可控。一旦共享点成为全局热点,CAS的失败重试会形成正反馈,越忙越乱。所以设计阶段就要想清楚哪些变量会被高频并发访问,提前做分散设计,别等线上出问题再改。

4.3 几个容易踩的实操心得

第一个心得是关于compareAndSet的返回值。很多人写循环时忘了检查返回值,或者把返回值当摆设,导致CAS失败后直接往下走,数据就丢了。每次CAS调用都要明确处理成功和失败两条路径,失败要么重试要么上报,绝不能静默忽略。

第二个心得是关于对象复用。CAS比较的是引用地址,如果对象池复用了地址,ABA风险会放大。用对象池的场景要格外小心,要么加版本号,要么确保复用期间不会有并发的CAS操作指向同一地址。

第三个心得是关于调试。CAS相关的bug往往偶发,普通断点会改变时序导致问题复现不了。我的做法是加详细的日志埋点,记录每次CAS的期望值、实际值和结果,用日志回放来定位。另外可以用压力测试工具长时间跑,提高复现概率。这类问题急不得,得靠耐心和数据。

注意:不要在生产环境用打印日志的方式调试高频CAS循环,日志本身的IO开销会彻底改变程序的时序特征,你看到的现场已经不是真实现场了。用采样或者低开销的埋点方式。

4.4 什么时候该果断放弃CAS

最后说一个反向的判断。CAS虽好,但以下几种情况我会毫不犹豫地换回锁。一是临界区涉及多个变量的联动更新,用CAS写出来的版本号管理代码又长又容易错,可读性极差。二是操作本身耗时较长,比如涉及IO或者复杂计算,自旋等待纯属浪费。三是竞争强度极高且无法分段,这时候锁的排队机制反而更公平高效。四是团队对无锁编程经验不足,维护成本会远超性能收益。

技术选型从来不是选“最先进”的,而是选“最合适”的。CAS是一把好刀,但刀要用在对的地方。我在实际项目里的体会是,大部分业务场景用锁加合理的临界区划分就足够了,真正需要CAS的地方集中在底层基础设施和高频计数器这类特定场景。把CAS用对,比把CAS用多更重要。