并发编程里聊到锁,聊到原子类,聊到无锁队列,最后都绕不开一个名字——CAS。我见过太多同学,背面试题的时候能把“比较并交换”这五个字说得字正腔圆,真到了线上出现自旋开销飙升、ABA问题导致数据错乱的时候,却完全不知道从哪儿下手排查。这篇就围绕CAS原理本身,把它拆开揉碎讲清楚:它到底是什么、为什么能保证原子性、底层靠什么指令支撑、有哪些看着不起眼但能坑死人的缺陷,以及在实际编码里怎么用、怎么避坑。不管你是刚接触并发、还在被synchronized和volatile绕晕的新手,还是已经写过不少原子类、想回头把底层原理补齐的老手,下面这些内容应该都能让你有收获。
1. 从一段计数器代码说起:CAS到底解决了什么问题
1.1 一个看似无害的累加操作
先看一段几乎所有人都写过的代码:
public class Counter { private int count = 0; public void increment() { count++; } public int get() { return count; } }单线程跑,这段代码一点毛病没有。但只要有两个线程同时调increment(),结果就会开始飘。原因不复杂:count++在字节码层面并不是一条指令,它至少拆成三步——读取count的当前值、把值加一、把新值写回内存。这三步之间任何一步都可能被其他线程插队。
假设count当前是5,线程A读到5,还没来得及写回,线程B也读到5,两个线程各自加一写回,最终结果就是6而不是7。这就是典型的竞态条件,丢失了一次更新。
要解决这个问题,最直接的办法是加锁:
public synchronized void increment() { count++; }锁能解决问题,但代价也摆在那儿:线程拿不到锁就得阻塞,阻塞意味着上下文切换,上下文切换意味着内核态和用户态的来回折腾。在竞争不激烈、临界区又特别短的场景下,加锁的开销甚至可能比业务逻辑本身还大。这就引出了一个很自然的想法:有没有一种办法,不加锁也能保证这个“读-改-写”过程不被打断?
CAS就是冲着这个目标来的。
1.2 CAS的三个操作数与语义
CAS全称Compare And Swap,比较并交换。它包含三个操作数:
- 内存位置V(也就是要修改的那个变量的地址)
- 预期原值A(expected value,你“以为”这个位置现在应该是多少)
- 新值B(new value,你想把它改成多少)
整个操作的语义可以概括成一句话:当且仅当内存位置V的当前值等于预期值A时,才把V的值更新为B;否则什么都不做。而且不管更新成没成功,操作本身都会返回V在操作那一刻的真实值,让你知道到底成没成。
关键在于,这一整串“比较+更新”的动作是原子的,中间不会被任何其他线程打断。你可以把它想象成一个特别较真的柜员:你把“我猜我账户里现在有100块,帮我改成120块”这个请求递过去,柜员先看一眼账户余额,如果确实是100,就改成120;如果不是100,就把当前真实余额告诉你,让你重新猜。整个过程柜员不会中途去接别的业务,你也不用担心他看余额和改余额之间被别人插一脚。
用CAS重写上面的计数器,逻辑就变成了这样:
public class CasCounter { private volatile int count = 0; public void increment() { int oldValue; int newValue; do { oldValue = count; newValue = oldValue + 1; } while (!compareAndSwap(oldValue, newValue)); } private boolean compareAndSwap(int expected, int update) { // 底层由硬件指令保证原子性,这里用伪代码表示语义 if (count == expected) { count = update; return true; } return false; } }这段代码里有个很典型的模式:循环重试。CAS失败说明在你读取旧值和尝试更新之间,有别的线程把值改了,那你就得重新读一遍最新的值,再算一次,再试一次。这个循环就是后面要重点聊的“自旋”。
1.3 为什么CAS能省掉锁的开销
锁的本质是“独占”,一个线程占着,其他线程排队等着,排队就要挂起,挂起就要切换。CAS的本质是“乐观”——我先假设没人跟我抢,直接算、直接改,改的时候发现被抢了再重来。它不阻塞任何线程,失败的一方不会被挂起,而是立刻拿到最新值继续尝试。
这个差别在低竞争、临界区极短的场景下体现得特别明显。比如一个简单的状态标志位翻转、一个引用的一次性替换,用CAS可能就是几条CPU指令的事,而加锁要走的路径长得多。这也是为什么java.util.concurrent.atomic包下那一堆原子类,底层几乎清一色是CAS。
但要注意,这个优势是有前提的。竞争一激烈,CAS的重试次数会急剧上升,CPU空转的开销可能反超锁。这一点在后面讲自旋缺陷时会展开。
2. 底层支撑:CAS凭什么能做到原子
2.1 从CPU指令说起
CAS不是Java发明的,它是CPU层面就提供的原子指令。不同架构上名字不一样:
| 架构 | 典型指令 | 说明 |
|---|---|---|
| x86 | CMPXCHG | 比较并交换,配合LOCK前缀保证多核下的原子性 |
| ARM | LDREX/STREX | 独占加载与独占存储配对使用,实现原子更新 |
| PowerPC | LWARX/STWCX | 与ARM类似的加载链接/条件存储机制 |
x86上的CMPXCHG指令本身在单核下是原子的,但在多核环境下,如果只是执行这条指令,其他核心可能同时读写同一块内存,还是会有问题。所以通常要加LOCK前缀,LOCK CMPXCHG会锁住总线或者锁住缓存行,确保这条指令执行期间,其他核心不能碰这块内存。这个“锁”是硬件级别的,粒度极小,开销远低于操作系统层面的互斥锁。
ARM的做法更巧妙一些,用的是“加载链接/条件存储”(Load-Link/Store-Conditional)机制。LDREX读一个值并标记这块内存“我在盯着”,STREX写回时会检查这块内存从上次LDREX之后有没有被别人动过,动过就写失败返回0,没动过才写成功。这套机制不需要锁总线,扩展性更好。
2.2 Java层面的落地:Unsafe与VarHandle
Java代码不能直接写汇编指令,所以早期是通过sun.misc.Unsafe这个类来调用底层的CAS能力。Unsafe里有一批compareAndSwapInt、compareAndSwapLong、compareAndSwapObject之类的方法,它们最终会映射到上面说的CPU指令。
Unsafe的问题在于它太“危险”也太“内部”了,普通开发者不该直接碰,而且它在不同JDK版本间的行为并不完全稳定。所以从JDK 9开始,官方推出了VarHandle作为更规范、更安全的替代方案:
import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle; public class VarHandleCounter { private volatile int count = 0; private static final VarHandle COUNT_HANDLE; static { try { COUNT_HANDLE = MethodHandles.lookup() .findVarHandle(VarHandleCounter.class, "count", int.class); } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } } public void increment() { int oldValue; do { oldValue = (int) COUNT_HANDLE.getVolatile(this); } while (!COUNT_HANDLE.compareAndSet(this, oldValue, oldValue + 1)); } }VarHandle的compareAndSet语义和CAS完全一致,但它提供了更细粒度的内存语义控制(比如compareAndSet是volatile级别的,还有compareAndExchange能拿到失败时的实际值),而且不依赖内部API,是现在写底层并发代码的推荐方式。
2.3 内存语义:CAS和volatile的关系
这里有个容易被忽略的点:CAS操作本身带有内存屏障语义。以compareAndSet为例,它等价于一次volatile读加一次volatile写,也就是说,CAS成功时,你之前的所有写操作对后续读到这个新值的线程都是可见的。
这解释了为什么原子类里的变量通常声明成volatile。volatile保证了可见性和有序性,CAS保证了“读-比较-写”这个复合操作的原子性,两者配合才能构成一个完整的线程安全更新。少了volatile,你可能读到的是缓存里的旧值,CAS的预期值判断就失去了意义;少了CAS,volatile只能保证单次读写的原子性,管不住复合操作。
注意:
volatile只保证单次读或单次写的原子性,count++这种复合操作它管不了。很多人以为加了volatile就线程安全了,这是并发编程里最常见的误解之一。
3. 三大经典缺陷:ABA、自旋开销与单变量局限
CAS看着很美,但它在真实工程里暴露出来的问题同样经典。这一节把三个最主要的坑讲透,每个都配上能复现的场景和应对思路。
3.1 ABA问题:值变回来了,但世界已经不同
ABA是CAS最出名的问题。描述起来很简单:线程1读到内存值是A,准备改成C;在这期间,线程2把A改成了B,又改回了A;线程1执行CAS时发现内存还是A,于是认为“没人动过”,成功把值改成了C。但实际上,中间已经发生过变化了。
在单纯的数值累加场景里,ABA通常无害,因为值确实回到了A,改成C也没错。但在依赖中间状态的场景里,ABA会引发严重问题。最典型的例子是链表或栈的节点操作:
假设一个用CAS实现的无锁栈,栈顶是节点A,A的next是B。线程1想弹出A,它读到栈顶是A,准备把栈顶CAS成B。这时线程2抢先弹出了A和B,又压入了A(注意是同一个A对象,只是next被改成了null)。线程1的CAS执行时发现栈顶还是A,于是成功把栈顶设成了B——可B早就被弹出了,这个B可能已经被回收或者复用了,栈结构直接损坏。
解决ABA有两条路:
第一条是加版本号。每次修改不仅改值,还改一个单调递增的版本号,CAS时同时比较值和版本号。这样即使值变回A,版本号也变了,CAS会失败。Java里AtomicStampedReference就是干这个的:
AtomicStampedReference<Node> top = new AtomicStampedReference<>(nodeA, 0); int[] stampHolder = new int[1]; Node current = top.get(stampHolder); int currentStamp = stampHolder[0]; // CAS时同时校验引用和版本号 boolean success = top.compareAndSet(current, newNode, currentStamp, currentStamp + 1);第二条是用不可变对象。每次修改都创建一个全新的对象,而不是原地改。这样“同一个对象”和“同一个值”就绑定在一起了,值变回来意味着对象也换了,CAS自然失败。函数式数据结构就是这么干的。
实操心得:绝大多数业务场景其实碰不到ABA,因为你的原子变量通常就是个计数器或者状态标志。但只要你用CAS操作的是引用类型,尤其是链表、栈、队列这类结构,就必须认真考虑ABA。我见过一个用CAS实现的无锁队列,上线跑了半年才在一次高并发压测里暴露出节点复用导致的ABA,排查了两天才定位到。
3.2 自旋开销:高竞争下的CPU空转
CAS失败要重试,重试就是循环,循环就是自旋。低竞争时自旋几次就成功了,开销可以忽略。但竞争一激烈,情况就变了。
设想100个线程同时对同一个原子变量做累加。每个线程读到一个值,算好新值,去CAS,结果发现只有第一个能成功,其余99个全部失败,重新读、重新算、重新试。下一轮又是1个成功99个失败。总的CAS尝试次数会接近线程数的平方级别,大量CPU时间花在“读了算、算了失败、失败了再读”的空转上。
这个问题在AtomicLong上尤其明显,因为所有线程抢的是同一个缓存行,还会引发伪共享和缓存行来回失效,进一步放大开销。JDK为此提供了LongAdder,思路是把一个计数器拆成多个Cell,不同线程更新不同的Cell,最后求和。这样竞争被分散了,自旋大幅减少:
LongAdder adder = new LongAdder(); // 多线程并发调用 adder.increment(); // 最终读取总和 long total = adder.sum();LongAdder的代价是sum()不是原子的,它返回的是一个近似值(遍历所有Cell累加时可能有并发更新)。所以它适合“高频写、低频读总和”的统计场景,不适合需要精确实时值的场景。
应对自旋开销的通用思路有这么几条:
- 分散热点:像
LongAdder那样把竞争拆开,或者用分段锁的思想。 - 限制自旋次数:自旋若干次后主动让出CPU(
Thread.yield())或者退化成阻塞,避免无谓空转。 - 降低竞争:从设计上减少共享可变状态,能线程本地化就本地化。
3.3 单变量局限:一次只能保证一个变量
CAS只能保证单个内存位置的原子更新。如果你有两个相关联的变量需要一起改,比如一个账户的余额和交易流水号,单独对每个变量做CAS是保证不了整体一致性的。
这个问题的标准解法是把多个变量打包成一个对象,然后对对象引用做CAS。AtomicReference就是为此存在的:
class AccountState { final long balance; final long version; AccountState(long balance, long version) { this.balance = balance; this.version = version; } } AtomicReference<AccountState> stateRef = new AtomicReference<>(new AccountState(1000, 0)); // 更新时构造新对象,整体替换 AccountState oldState; AccountState newState; do { oldState = stateRef.get(); newState = new AccountState(oldState.balance - 100, oldState.version + 1); } while (!stateRef.compareAndSet(oldState, newState));注意这里AccountState设计成了不可变对象,每次更新都创建新实例。这既解决了多变量一致性问题,又顺带规避了ABA(因为对象换了)。代价是每次更新都要分配新对象,GC压力会上升,在高频更新场景下要权衡。
4. 从原理到落地:原子类与无锁结构的实现拆解
4.1 AtomicInteger的内部结构
理解了CAS原理,再看AtomicInteger就一目了然了。它的核心就三样东西:一个volatile修饰的value字段、一个记录字段偏移量的valueOffset、以及基于Unsafe(新版本用VarHandle)的CAS调用。
public class AtomicInteger extends Number { private volatile int value; private static final long valueOffset; static { try { valueOffset = Unsafe.objectFieldOffset( AtomicInteger.class.getDeclaredField("value")); } catch (Exception e) { throw new Error(e); } } public final int getAndIncrement() { return U.getAndAddInt(this, valueOffset, 1); } }valueOffset的作用是告诉底层“要操作的是这个对象里偏移量为多少的那块内存”,这样CAS指令就能精确定位到目标字段。getAndIncrement最终会走到一个自旋循环里,反复执行compareAndSwapInt直到成功。
AtomicInteger提供的方法大致分几类:get/set是普通读写,getAndSet是原子替换,compareAndSet是裸CAS,getAndIncrement/getAndDecrement/getAndAdd是原子算术,还有updateAndGet/accumulateAndGet这类接受函数式接口的版本,方便做自定义的原子更新。
4.2 手写一个简易原子类
光看源码不如自己写一遍。下面这个简化版原子类,把CAS的核心骨架完整呈现出来:
import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle; public class SimpleAtomicInteger { private volatile int value; private static final VarHandle VALUE; static { try { VALUE = MethodHandles.lookup() .findVarHandle(SimpleAtomicInteger.class, "value", int.class); } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } } public SimpleAtomicInteger(int initial) { this.value = initial; } public final int get() { return value; } public final boolean compareAndSet(int expected, int update) { return VALUE.compareAndSet(this, expected, update); } public final int getAndIncrement() { int oldValue; do { oldValue = value; } while (!VALUE.compareAndSet(this, oldValue, oldValue + 1)); return oldValue; } public final int incrementAndGet() { int oldValue; int newValue; do { oldValue = value; newValue = oldValue + 1; } while (!VALUE.compareAndSet(this, oldValue, newValue)); return newValue; } }几个细节值得说:
value必须是volatile,否则CAS的预期值判断可能基于过期数据。getAndIncrement和incrementAndGet的区别只在返回值,前者返回旧值,后者返回新值,但内部自旋逻辑是一样的。- 自旋循环里每次都要重新读
value,不能把读到的值缓存到循环外,否则永远在拿旧值做比较,死循环。
4.3 无锁栈的CAS实现
CAS最经典的应用之一是无锁栈。下面这个实现能跑,但故意保留了ABA隐患,方便对照理解:
public class ConcurrentStack<T> { private AtomicReference<Node<T>> top = new AtomicReference<>(); private static class Node<T> { final T item; Node<T> next; Node(T item) { this.item = item; } } public void push(T item) { Node<T> newHead = new Node<>(item); Node<T> oldHead; do { oldHead = top.get(); newHead.next = oldHead; } while (!top.compareAndSet(oldHead, newHead)); } public T pop() { Node<T> oldHead; Node<T> newHead; do { oldHead = top.get(); if (oldHead == null) { return null; } newHead = oldHead.next; } while (!top.compareAndSet(oldHead, newHead)); return oldHead.item; } }push的逻辑是:新建节点,把它的next指向当前栈顶,然后CAS把栈顶换成新节点。pop的逻辑是:读到当前栈顶,把栈顶CAS成它的next。两个操作都靠自旋重试来应对并发冲突。
这个实现的问题前面提过——pop里存在ABA风险。如果两个线程交替弹出和压入同一个节点,CAS可能基于过期的next成功,导致栈结构错乱。生产环境要用的话,得换成AtomicStampedReference加版本号,或者用JDK自带的ConcurrentLinkedStack(实际上JDK没有这个类,但ConcurrentLinkedQueue是现成的参考实现)。
注意:自己手写无锁数据结构,一定要在压测环境里用高并发反复验证,单线程测试通过不代表并发安全。ABA这类问题往往要特定时序才触发,非常隐蔽。
5. 排查实录:CAS相关问题的定位与避坑清单
5.1 常见问题速查表
实际工作中和CAS打交道,遇到的问题大多集中在下面这几类。整理成表,方便对照排查:
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 原子类累加结果偏小 | 变量没加volatile,或误用了非原子操作 | 检查字段修饰符和调用方法 | 改用原子类方法,确认volatile |
| CPU使用率异常飙高 | 高竞争下CAS自旋空转 | 用性能分析工具看热点方法 | 换LongAdder分散热点,或降低竞争 |
| 引用类型CAS后结构错乱 | ABA问题 | 检查是否有对象复用/回收 | 加版本号或用不可变对象 |
| CAS一直失败陷入死循环 | 自旋循环里缓存了旧值 | 检查循环体是否每次重读 | 确保每轮重新读取最新值 |
| 多字段更新不一致 | CAS只能保证单变量 | 检查是否分开CAS多个字段 | 打包成对象整体CAS |
5.2 一个真实的自旋飙升排查过程
之前遇到过一个服务,压测时CPU直接打满,但吞吐量不升反降。用性能分析工具抓热点,发现大量时间耗在一个原子计数器的getAndIncrement上。当时第一反应是“原子类不是很快吗”,后来才想明白:那个计数器被所有工作线程共享,用来统计总请求数,线程数一多,CAS冲突率飙升,自旋次数呈平方级增长。
定位过程分三步:先看热点方法确认是CAS自旋,再看线程数和竞争程度确认是高竞争场景,最后看这个计数器的用途——只是做统计,对实时精确性要求不高。于是换成LongAdder,CPU立刻降下来,吞吐量恢复正常。
这个案例的教训是:原子类不是银弹,它的性能优势建立在低竞争前提上。共享热点越集中,CAS的性价比越低。选型时要先问自己:这个变量真的需要所有线程共享吗?能不能线程本地累加、最后汇总?能不能用分段思想拆开?
5.3 几条踩过坑才懂的经验
第一,别在自旋循环里做重活。CAS循环里如果夹带了复杂计算、对象分配甚至IO,一旦冲突重试,这些活全部白干。正确做法是把能提前算的都算好,循环里只留“读-比较-写”最精简的部分。
第二,compareAndSet的返回值一定要处理。我见过有人写了atomic.compareAndSet(a, b);然后直接往下走,完全不管返回的布尔值。CAS失败意味着更新没生效,后续逻辑如果依赖这个更新,就会出问题。要么放进循环重试,要么根据返回值走不同分支。
第三,注意伪共享。多个原子变量如果挨得近,可能落在同一个缓存行里,一个线程更新变量A会导致变量B所在的缓存行失效,引发不必要的缓存同步。高频更新的独立变量之间可以加填充(padding)隔开,JDK里LongAdder的Cell就做了类似处理。
第四,优先用现成的原子类,别自己造轮子。java.util.concurrent.atomic包覆盖了绝大多数需求,AtomicInteger、AtomicLong、AtomicReference、AtomicStampedReference、LongAdder、LongAccumulator,基本够用。自己手写CAS逻辑,ABA、内存语义、可见性这些坑一个都躲不掉,除非有明确理由,否则没必要。
第五,理解CAS和锁的适用边界。临界区短、竞争低,CAS更划算;临界区长、竞争激烈,锁反而更稳。这不是非此即彼的选择,很多高性能组件内部是两者混用的——比如ConcurrentHashMap,桶为空时用CAS插入,桶非空时用synchronized锁住桶头节点。这种“能乐观就乐观,乐观不了再悲观”的混合策略,才是工程上的常态。
6. 把CAS放回并发工具箱里看
CAS不是一个孤立的知识点,它是理解整个java.util.concurrent体系的钥匙。AQS的底层状态更新用的是CAS,ConcurrentHashMap的扩容和插入用的是CAS,各种无锁队列、无锁缓存的骨架都是CAS。把CAS的原理、缺陷、适用边界吃透,再去看这些组件的源码,很多设计决策就变得顺理成章——为什么这里要加版本号,为什么那里要分段,为什么这个场景选了锁而不是CAS,答案都藏在CAS的特性里。
我自己在实际项目里的体会是,CAS真正难的不是理解它的语义,而是判断什么时候不该用它。看到共享可变状态就想上原子类,看到计数器就想上AtomicLong,这种条件反射往往会埋下性能隐患。多问一句“竞争有多激烈”“这个变量真的需要全局共享吗”“失败了重试的代价能不能接受”,比记住CAS的定义有用得多。原子类用对了是利器,用错了就是CPU的绞肉机,这个分寸感只能靠一次次压测和排查慢慢磨出来。