我写并发代码有五六年了,一直觉得CAS是个很神奇的东西——明明只是一个“比较再交换”的简单动作,却撑起了JUC半边天。无论是AtomicInteger、LongAdder,还是面试必考的AQS,底层都离不开它。很多新手学到这里,记了一堆“CAS是无锁编程”“CAS是乐观锁”的概念,可真要问他“CAS到底怎么保证原子性”“ABA问题怎么解”,又答不上来。这篇文章我就结合源码、字节码和实际项目踩坑经历,把Java中的CAS机制彻底讲透,顺便说说面试八股里那些高频追问到底想考什么。
这篇文章适合正在准备Java面试的人,也适合已经在写并发代码但总觉得底层缺一块的开发者。我不打算只讲结论,会把CAS的硬件基础、Unsafe实现、JUC里的典型应用、三大经典陷阱,以及实战调优经验全部串起来。看完之后,你不仅能应付“CAS底层原理”“ABA问题”“CAS和synchronized区别”这类问题,还能在真正的并发项目里用对CAS。
1. CAS机制核心原理拆解
1.1 CAS的三个操作数与内存模型
CAS全称是Compare And Swap,翻译过来就是“比较并交换”。它做的事情非常简单:先读一个内存位置的值V,再拿它和期望值E比较,如果相等就把新值N写进去,否则什么都不做。整个过程中,V、E、N就是CAS的三个操作数。
这个模型对应到Java代码里,通常是这样用的:
boolean compareAndSet(int expect, int update)调用方把“我期望当前值是多少”传进去,如果实际值真和期望值一样,就更新为目标值,并返回true;否则返回false。这里的关键是“比较”和“交换”这两个动作必须是原子的,不能拆开。为什么不能拆开?因为如果在比较结束后、交换开始前,另一个线程把这个值改了,那这次操作就失真了。
理解CAS的最好方式是把它想成“乐观锁”的极致形态。悲观锁是“我先锁住资源,谁也别动”;乐观锁则是“我默认没人跟我抢,先试着改一下,改的过程中发现冲突了再重试”。CAS就是一种硬件级别的乐观锁原语,它不是靠加锁来互斥,而是靠一条CPU指令来保证比较和交换的整体性。
内存模型这块也要说清楚。CAS操作必然涉及主内存和线程工作内存的交互。虽然我们平时用AtomicInteger的时候感觉不到,但在底层,CAS是通过CPU缓存和主内存之间的缓存一致性协议来协调的。线程A修改了某个值,线程B要能立刻看到,这个可见性由volatile语义来保障。所以你会发现,AtomicInteger内部实际上是把value声明成了volatile,CAS就是在这个volatile变量上做比较交换。没有volatile的可见性,CAS即使原子地改了值,其他线程也看不到。
1.2 硬件层面如何保证原子性
Java层面只是调用了本地方法,真正干活的是一系列CPU指令。以x86平台为例,核心指令是cmpxchg,也就是“compare and exchange”。这条指令接收一个内存地址、一个期望寄存器值、一个新值寄存器值,CPU会在指令执行期间锁定总线或者锁缓存行,保证比较和交换的原子性。
早期的CPU是直接锁总线,代价比较大,因为锁住总线意味着其他CPU都无法访问内存。后来优化成锁缓存行,也就是MESI缓存一致性协议参与下的cache line lock,只有当前缓存行被标记为Modified状态时,其他核心才无法修改这段数据。这种优化让CAS在大部分场景下都能跑得很快。
需要注意的是,虽然命令是原子的,但“读-比较-写”这一整套逻辑在Java代码里往往表现为一个循环。比如AtomicInteger的incrementAndGet,实际执行的是“死循环里反复调用compareAndSet,直到成功为止”。因此CAS虽然本身是原子操作,但围绕它构建的算法往往是“乐观重试”模式,这就带来了后面要讲的自旋开销问题。
这里还有一个很实际的概念:CAS不是“完全没有锁”,它只是“没有线程阻塞”。底层硬件确实用了总线锁或缓存锁,只是这种锁的粒度极小、持有时间极短,宏观上感知不到线程被挂起唤醒。理解了这一点,你就能明白为什么CAS在高并发下可能比synchronized更高效,也可能更差——因为自旋会让CPU空转。
1.3 Java中的CAS实现:Unsafe类与Atomic原子类
Java的CAS魔法藏在sun.misc.Unsafe里面。这个类虽然名字叫“不安全”,但并发框架到处都用它。核心方法就这几个:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x); public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);注意这几个方法的参数:第一个是对象本身,第二个是字段的内存偏移量,第三个是期望值,第四个是目标值。为什么要传对象和偏移量?因为CAS最终要在内存层面操作,需要定位到那个字段的准确内存位置。这种写法对普通开发者来说很不友好,所以JUC包在Unsafe之上封装了各种Atomic原子类,把offset计算都隐藏了。
我们平时用得最多的AtomicInteger,本质上就是一个封装了volatile int值+Unsafe CAS逻辑的类。它的内部有这样一段代码:
// setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } private volatile int value;静态代码块里通过反射拿到value字段的偏移量,之后CAS操作就直接在该偏移量上做。这也解释了为什么AtomicInteger的性能比普通加锁操作高:没有线程上下文切换,只有CPU指令级的自旋比较。
除了AtomicInteger,还有AtomicLong、AtomicReference、AtomicBoolean,它们原理一致,只是操作的数据类型不同。AtomicReference在处理对象引用的时候特别有用,但同时也更容易踩ABA的坑。至于更高级的LongAdder和Striped64,它们的核心依然离不开CAS,只是把竞争分散到了多个cell上,这个概念后面再展开。
2. CAS的典型应用场景与代码实战
2.1 AtomicInteger的incrementAndGet源码走读
很多人在面试时能把AtomicInteger的用法背出来,但源码里到底怎么自旋的,可能没细看。我先把核心方法贴出来:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next; } }这个循环就是典型的“自旋CAS”。每次先读当前值,然后计算目标值,接着尝试CAS。如果CAS失败,说明在读取和尝试更新之间,其他线程已经把值改了,于是循环回到起点,重新读取最新值,再试一次。整个过程不需要锁,线程始终在用户态运行,不会被操作系统挂起。
从实际性能角度看,这个设计对低并发场景特别友好。比如一个计数器,每秒只有几百次更新,几乎每次CAS都能一次成功,性能比synchronized高了一个数量级。但如果是超高并发场景,比如上千个线程同时往一个AtomicInteger上累加,CAS就会频繁失败,每个线程都在循环里空转,CPU占用率直线上升。这个时候AtomicInteger反而不如LongAdder。
顺带说一句,incrementAndGet和getAndIncrement的差别只在于返回值顺序:前者返回自增后的新值,后者返回自增前的旧值。实现逻辑几乎一样,只是返回值不同。这个细节也可能出现在面试题里,注意别混淆。
2.2 自旋锁的手写实现
理解CAS最好的方式是自己写一个自旋锁。虽然真实项目里很少让你手写,但这个练习能让你彻底搞清楚“CAS实现锁”到底是怎么回事。下面是一个最简版本:
public class SpinLock { private final AtomicReference<Thread> owner = new AtomicReference<>(); public void lock() { Thread current = Thread.currentThread(); // 如果owner从null变成current,说明抢锁成功 while (!owner.compareAndSet(null, current)) { // 自旋等待,可以加Thread.yield()让出CPU } } public void unlock() { Thread current = Thread.currentThread(); // 只有持有锁的线程才能解锁 owner.compareAndSet(current, null); } }这段代码里,锁状态就是owner引用是否为null。lock时尝试把owner从null改为当前线程,成功就代表获得锁;失败就继续自旋。unlock时把owner从当前线程改回null,这里用compareAndSet可以避免其他线程错误释放锁。整个锁只有几十行代码,但功能上确实实现了互斥。
不过我在实际测试中发现,自旋锁有个很大的问题:如果持有锁的线程被阻塞了,比如在锁里面做了一次远程调用,那么其他线程会在循环里疯狂空转,把CPU资源白白吃光。所以自旋锁只适合临界区非常小、执行时间极短、线程竞争不激烈的场景。JUC里面的AbstractQueuedSynchronizer在获取锁失败后也不是无限自旋,而是先自旋几次再进入等待队列,就是这种思路。
2.3 CAS在AQS队列同步器中的角色
AQS是Java并发框架的基石,ReentrantLock、Semaphore、CountDownLatch都是基于它实现的。AQS里有一个volatile int state字段,表示同步状态,还有一个双向队列,用来存放等待线程。CAS在AQS里的作用,就是原子地修改state。
以ReentrantLock的加锁为例,核心操作是先尝试用CAS把state从0改为1。如果成功,当前线程就获得了锁;如果失败,说明锁被占用,线程进入队列等待。改state的方式就是CAS,比如:
protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }这里的stateOffset也是通过反射提前计算好的。AQS的设计精妙之处在于,它把“直接CAS抢锁”和“排队阻塞唤醒”结合在了一起:线程先尝试两次CAS,抢不到再排队。这样既保留了CAS的高吞吐,又避免了长时间自旋消耗CPU。
你在面试里可以说AQS就是“CAS+CLH队列”的经典组合。CLH队列是一种基于链表节点的等待队列,每个节点保存着当前线程和等待状态。CAS保证节点状态切换的原子性,队列保证等待线程的有序性,两者配合起来,就是整个JUC同步器家族的底层骨架。理解了AQS里CAS的使用位置,很多面试题都能迎刃而解。
3. CAS的三大经典陷阱:ABA、自旋开销、只能单变量
3.1 ABA问题复现与AtomicStampedReference解决
CAS最出名的坑就是ABA问题。它的发生条件非常隐蔽:线程T1读到当前值是A,刚要CAS更新时被打断;线程T2把值从A改成B,然后又从B改回A;这时T1恢复执行,发现值还是A,于是CAS成功。但问题是,值虽然变回了A,中间却经历了一段“被修改过”的历程,某些依赖历史状态的场景就会出错。
举个业务例子。假设你用AtomicReference维护一个“账户余额”,线程A准备把余额从100元扣到50元。它先读到余额是100,这时线程B给账户充了50元变成150,然后又消费退回50元,最终余额还是100。线程A醒来后发现余额还是100,就执行扣款,余额变成50。但事实上账户中间经历过一次充值又退款,业务上可能需要重新审核。这个例子有点牵强,但足以说明ABA的危害在于“值相等不代表状态等价”。
解决ABA问题的标准方案是加版本号。Java里提供了AtomicStampedReference和AtomicMarkableReference。前者可以存“引用+版本戳”,后者可以存“引用+布尔标记”。用版本号或标记来区分“值相同但版本不同”的情况。示例代码如下:
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0); // 每次更新时,需要同时检查引用和版本戳 boolean success = ref.compareAndSet("A", "B", 0, 1);注意这里的compareAndSet有四个参数:期望引用、新引用、期望版本、新版本。只要中间发生过任何修改,版本戳就会改变,即使引用值回到“A”,版本戳也不会是0,所以CAS会失败。这就能完美规避ABA问题。
不过我实际工作中用AtomicStampedReference的概率并不高,因为很多场景下ABA根本无伤大雅。比如统计访问次数,只要最终计数是准的,中间跳变几次无所谓。做技术选型时,先判断业务是否关注中间状态,别为了消灭ABA而过度设计。
3.2 自旋竞争激烈的性能瓶颈与LongAdder优化思路
CAS虽然避免了线程切换,但自旋是要消耗CPU的。竞争越激烈,自旋次数越多,线程一直在for循环里打转,宏观表现就是CPU占用飙高、吞吐量下降。为什么LongAdder能优化这个问题?因为它把单一热点拆成了多个热点。
LongAdder的内部设计是Striped64:它维护一个base值和一个Cell数组。当竞争不激烈时,线程直接CAS更新base;当发现CAS失败次数多了,就会把线程通过hash分散到不同的Cell槽位,每个线程只更新自己槽位里的值。最后的sum操作把base和所有Cell值加起来。这样多个线程不会都去抢同一个内存地址,CAS失败率大大降低。
举个例子:之前有次压测,我用AtomicInteger作为统计数据采集器的计数器,模拟500个线程并发累加,结果吞吐量只有二十几万一秒,CPU还烧到90%。后来换成LongAdder,同样的场景,吞吐量直接翻了两倍,CPU降到60%左右。这个优化点的核心思想就是“化整为零”,降低共享变量的写冲突。
不过LongAdder也有缺点。因为计数被分散到多个槽位,它在sum()的时候不是精准快照,也就是说,如果你需要保证“累加结果在某一瞬间绝对一致”,LongAdder不适合。它适合“最终一致”的统计场景,比如QPS计数器、日志埋点统计。而如果你需要不断读取当前精确值来做判断,还是老老实实用AtomicInteger。
3.3 只能保证单个变量的原子性:AtomicReference与可变对象组合问题
CAS还有一个隐形的限制——它能保证的是“单个变量”的原子操作。如果你要同时原子地更新两个变量,比如“用户积分”和“积分等级”,CAS就无能为力。
虽然AtomicReference可以包装一个自定义对象,把两个字段放进对象里,然后原子替换整个引用,但这要求每次更新都创建一个新对象,频繁GC不说,代码也变得很别扭。更麻烦的是,如果对象内部字段是可变的,比如List,你在拿到引用后对List进行增删,其他线程可能同时也在操作这个List,这时CAS只能保证引用本身的原子替换,保证不了List内部的线程安全。
这里要特别提醒:AtomicReference的CAS比较的是“引用地址”,不是对象内容。两个对象内容完全一样,但只要不是同一个地址,CAS就会失败。所以用AtomicReference做“值比较”时必须小心,最好配合不可变对象使用,或者在更新时重新构建一个新对象。
如果要真正实现“多字段一致更新”,有几个方案:一是用锁,简单直接;二是用AtomicReference包装一个不可变的“状态快照对象”,每次更新都创建新对象;三是用LongAdder等特殊结构的类来分散更新。最忌讳的就是拿多个AtomicXXX变量做组合操作,比如先更新一个再更新另一个,中间一旦出问题,两个变量的值就对不上了。
4. 面试八股与高频追问:CAS相关题点拆解
4.1 面试官视角的连环追问清单
CAS是Java并发面试的必考点,面试官很少直接问“CAS是什么”,他们通常采用连环追问的方式,比如:
- 介绍一下CAS的原理?
- CAS是怎么实现原子性的?
- CAS和synchronized的区别是什么?
- CAS会带来什么问题?ABA是什么?
- 怎么解决ABA问题?AtomicStampedReference的原理?
- CAS在JUC里有哪些应用?
- 为什么AtomicInteger在高并发下性能不好?
- LongAdder相比AtomicInteger做了哪些优化?
- volatile和CAS的关系?
- AQS和CAS是什么关系?
这些问题环环相扣,从基础概念到底层原理,再到性能优化,覆盖了整个Java并发核心。面试官其实想考察的不仅仅是你会不会背概念,而是你有没有真正理解“无锁并发”的底层逻辑和适用边界。哪怕答不出来全部,能沿着某一条线深入下去,也会加分。
我见过很多候选人在“CAS和synchronized区别”这里翻车。他们脱口而出“CAS无锁、synchronized有锁”,但实际上CAS底层也有缓存锁,synchronized在锁升级后也可能用CAS来抢偏向锁,两者不是完全对立的。更好的答案是:CAS是一种乐观同步机制,synchronized是一种悲观同步机制;CAS面向的是细粒度、短临界区、低竞争场景,synchronized经过锁升级优化后,在不同竞争程度下都有较好表现。
4.2 典型回答模板与避坑要点
我整理一个比较稳妥的回答思路,你可以在面试里参考:
“CAS的全称是Compare And Swap,它包含三个操作数:内存位置V、期望值E、新值N。只有V的实际值等于E时,才会把N写入V,否则什么都不做。这三个步骤在硬件层面通过cmpxchg指令保证原子性。Java里通过Unsafe类的compareAndSwapInt等方法暴露了这个能力,JUC的原子类再封装一层,给开发者提供了便捷的CAS操作。”
“CAS的使用模式通常是自旋循环,比如AtomicInteger的incrementAndGet,就是for循环里不断尝试compareAndSet,直到成功。CAS的好处是减少了线程阻塞和上下文切换,坏处是ABA问题、自旋CPU浪费、只能保证单个变量的原子性。其中ABA可以用AtomicStampedReference增加版本号来解决;自旋问题可以用LongAdder对热点进行拆分;多变量一致性问题可以引入锁或用不可变对象+引用替换。”
这样回答既完整又清晰。注意几个容易踩的坑:
第一,不要说“CAS不使用锁”,要说是“硬件层面的原子操作,没有线程阻塞”。第二,不要说“CAS永远比synchronized快”,实战中竞争激烈时CAS可能更差。第三,不要把ABA问题解释成“线程安全”问题,ABA本质是“逻辑状态被覆盖”的问题,而不仅仅是“值被覆盖”。
4.3 CAS与synchronized、volatile的关系和选型
这三者经常被混在一起问,其实分工完全不同。
volatile解决的是“可见性”和“禁止指令重排序”,不保证原子性。所以在多线程环境下,volatile不能替代CAS实现类似i++这样的操作。CAS解决的是“单个变量的原子更新”,它本身依赖volatile的可见性来获取最新值。synchronized解决的则是“多行代码的互斥执行”,它能保证原子性、可见性和有序性,但代价是可能引起线程阻塞。
在我实际的选型思路里,优先从这三个维度判断:如果只是标志位、状态开关,用volatile;如果是单一变量的计数、累加、比较更新,用CAS原子类;如果是复合操作、多变量联动、需要锁的语义,用synchronized或ReentrantLock。
举一个我之前做过的状态机例子。订单状态有PENDING、PAID、SHIPPED、COMPLETED,每个状态只能按顺序流转。我用一个AtomicReference 加上一个状态机校验方法,每次更新前先校验当前状态是否合法,然后CAS替换。这个方案省去了加锁,性能很好。但要是同样的状态流转里需要同时更新订单金额、积分、库存等多个字段,那CAS就不好使了,还是老老实实加锁。
5. 实操排查与性能调优经验
5.1 从JIT角度看CAS伪共享(@Contended)
很多人在写高并发代码时容易忽略一个性能杀手:伪共享。简单说,CPU加载数据是按缓存行来的,一个缓存行通常是64字节。如果两个变量被分配在同一个缓存行里,即使它们之间没有逻辑关系,其中一个变量被修改时,另一个变量的缓存也会失效,导致其他线程被迫重新加载。
CAS操作特别容易受到伪共享影响,因为CAS会反复触发缓存行的写操作。如果你有两个AtomicLong,一个记录请求数,一个记录成功数,它们很可能紧挨着存放在内存里。线程A疯狂更新请求数,线程B疯狂更新成功数,两个线程虽然在逻辑上互不干扰,但物理上它们共享同一个缓存行,于是互相拖累。
JVM提供了一个注解@sun.misc.Contended,可以给字段做缓存行填充,让这个字段独占缓存行。JDK 8的LongAdder内部Cell数组就用了这个注解。我自己在遇到类似场景时,也会用这个注解来做优化。但要注意,@Contended在普通API代码里默认是无效的,需要加JVM参数-XX:-RestrictContended才能生效,而且打开后可能会浪费内存,要权衡使用。
5.2 实际项目中的CAS使用场景复盘(计数器、状态机、锁)
我在项目中用CAS的场景主要有三类。
第一类是计数器。比如网关统计接口调用量、监控系统统计埋点数据,用AtomicLong或LongAdder非常合适。我已经把LongAdder作为默认的计数类,因为它兼顾了低竞争的简洁性和高竞争的扩展性。但要注意,LongAdder的sum返回值不是强一致快照,所以一旦对应到财务、订单这类需要精确值的场景,就要用AtomicLong。
第二类是状态机。许多业务实体的状态流转可以用AtomicReference维护。比如工单状态、流程节点状态。每次操作前先CAS判断当前状态是不是期望的,如果是就替换成下一状态。这种方式天然避免了重复提交,也比加锁轻量。但前提是状态流转路径不能太复杂,否则校验逻辑会变得难以维护。
第三类是自旋锁和轻量级锁。某些框架里为了尽量减少线程切换,在临界区极短的地方使用自旋锁。我在写一个本地缓存刷新的场景时,用AtomicBoolean实现过一个“单飞”逻辑:多个线程同时发现缓存过期,只有一个线程能通过CAS把状态从false改为true,其余线程直接返回旧值。这样保证只有一个线程去重建缓存,其他线程不阻塞,效果很好。
5.3 常见错误与排查技巧
我在帮助团队排查并发问题时,发现CAS导致的问题主要有几个特征:程序偶尔出现错误数据、CPU占用高但无明显死锁、问题只在高峰期出现。
最容易犯的错误是把CAS当成“万能原子操作”。比如有人在代码里写了这样一段:
if (atomic.get() == 0) { atomic.compareAndSet(0, 1); }这种“先get再CAS”的组合不是原子的。两个线程可能同时读到0,然后先后CAS成功。正确做法是直接CAS,用返回值判断是否成功,不要让get先做判断。这是“检查后执行”的竞态条件经典问题。
排查技巧方面,先说CPU飙高。如果线上多个线程在CAS循环里自旋,jstack会看到大量线程处于RUNNABLE状态,并且堆栈停留在compareAndSet上。这时优先排查共享变量是否被多个线程高频访问,竞争是否过度集中。如果竞争集中在单一变量,考虑用LongAdder或分段结构。
再说数据正确性。如果用了AtomicReference但业务状态偶尔错乱,优先怀疑ABA问题。可以临时打日志记录每次CAS前后的值和版本,或者把AtomicReference换成AtomicStampedReference试一下,看问题是否消失。我遇到过因为ABA导致缓存更新丢失的案例,排查了很久才发现是两个线程一个先更新再回滚,另一个以为状态没变继续走完了整个流程。
最后提醒一个新手常犯的错误:AtomicInteger的compareAndSet并不等于“如果当前值等于x就执行某个动作”。CAS只负责原子性的“比较-替换”,不负责你替换之外的其他逻辑。如果你在CAS成功后还要操作其他共享变量,那不是原子操作。要么把所有共享变量封装到不可变对象里一次替换,要么老老实实加锁。
结尾
我个人这几年的体会是:CAS不是银弹,但它是理解并发本质的一把钥匙。你越深入它,越能体会到“无锁”并不是真的无锁,而是把锁的粒度缩小到了CPU指令级别。面试问CAS,表面上考的是机制,实际考的是你对并发边界条件的敏感性。最后再分享一个小技巧:当你写任何CAS相关代码时,先问自己三个问题——这个操作需要保证原子性吗?失败重试的开销能接受吗?如果值连续变化多次后回到原值,业务上会有影响吗?把这三个问题想清楚,你就能在绝大多数场景下做出正确的选择。