并发编程里聊到无锁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 LockFreeStackT { private static class NodeT { final T value; NodeT next; Node(T value) { this.value value; } } private final AtomicReferenceNodeT top new AtomicReference(null); public void push(T value) { NodeT newNode new Node(value); NodeT oldTop; do { oldTop top.get(); newNode.next oldTop; } while (!top.compareAndSet(oldTop, newNode)); } public T pop() { NodeT oldTop; NodeT newTop; do { oldTop top.get(); if (oldTop null) return null; newTop oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); return oldTop.value; } }这个实现看起来没问题但它有两个隐患。第一个是ABApop里读到oldTop后如果别的线程把oldTop弹出又压回compareAndSet会误判成功导致栈结构错乱。第二个是newNode.next oldTop这行写在循环里如果top是普通引用而非AtomicReference这行赋值和后面的CAS之间可能存在重排问题不过这里AtomicReference的CAS自带屏障基本能兜住。要彻底解决ABA得换成带版本号的引用。JDK提供了AtomicStampedReference用法如下import java.util.concurrent.atomic.AtomicStampedReference; public class StampedStackT { private static class NodeT { final T value; NodeT next; Node(T value) { this.value value; } } private final AtomicStampedReferenceNodeT top new AtomicStampedReference(null, 0); public void push(T value) { NodeT newNode new Node(value); int[] stampHolder new int[1]; NodeT 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用多更重要。