被人问得最多的并发问题里CAS一定排在前三名。不管是初级岗还是资深岗面试官只要想探你的并发功底大概率会从CAS切入聊无锁编程、聊JUC底层、聊乐观锁一环扣一环。“深入理解CAS无锁编程的核心面试必考点解析”这个标题不是我起的是几乎所有准备面试的人都会搜到的一个方向。今天这篇就围绕它展开我会把这几年看源码、写并发代码、当面试官问别人和被别人问的经验全揉在一起讲清楚。这篇内容适合谁一类是准备跳槽、正在刷面试题的开发者另一类是已经在写业务并发代码、但每次用到AtomicInteger和LongAdder都只是“会用”的人。看完之后你能做到三件事第一把CAS的原理用大白话讲给面试官听第二能说出CAS的坑和对应的工程解法第三再看到并发工具类时一眼能认出它底层是不是CAS那套思路。1. CAS是什么一个变量被安全修改的最短路径1.1 从一段线程不安全的代码说起先看一个最常见的例子。一个计数器多个线程往里加public class Counter { private int count 0; public void increment() { count; // 很多人都知道这行不是原子的 } }count实际上拆成了三步读取count的值在CPU里加1把结果写回内存。两个线程同时读到100各自加1再写回最后内存里还是101丢了一次更新。解决思路很多最笨的是给increment加synchronized性能差一点但正确。更优雅的是用 AtomicIntegerAtomicInteger counter new AtomicInteger(0); counter.incrementAndGet();同样是并发自增这段代码没有加锁却在任何时刻都不会丢更新。为什么因为incrementAndGet底层不是“读-改-写”三段操作它把“比较”和“交换”合并成了两个动作核心就是CAS。1.2 CAS的三个操作数V、E、NCAS的全称是 Compare-And-Swap比较并交换。它需要三个操作数V内存中的目标地址也就是我们要更新的那个变量E期望值判断变量是不是“没被别人改过”的依据N新值如果判定通过就把这个值写进去整个过程只有一句话语义只有当内存地址V里的当前值等于期望值E时才把V更新成N如果V的当前值不等于E说明在这段时间里有人动过它那本次操作失败什么都不做。这里要注意CAS返回的是布尔结果但底层指令往往还会返回旧值。Java里的Unsafe类对外提供的compareAndSwapInt返回的是boolean表示是否更新成功。用一个生活化的类比你想把宿舍床位上挂的袋子换成自己带的规则是“先看看我的袋子还在不在在就换不在就说明别人动了我不换”。这个“看袋子还在不在”并不是先看一眼再换而是CPU一个指令同时完成的“检查置换”。关键点就在这里——它不是多个步骤的拼接而是一个原子动作不可被打断。1.3 为什么说CAS是“无锁编程”的核心有了CAS就可以实现一组线程安全操作而不使用锁。这里的“无锁”不是没有并发控制而是没有阻塞和线程挂起。加锁的代价在于竞争锁的线程会进入阻塞状态操作系统要把这个线程挂起之后锁释放再唤醒它。挂起和唤醒涉及内核态切换成本是纳秒级的锁操作、微秒级的上下文切换量级差异非常明显。CAS则完全不同。它让每个线程在CPU寄存器层面直接尝试“比较并交换”失败了立刻重试不会把自己挂起。也就是说线程全程停留在用户态、自旋等待CPU不用切换执行上下文。这个特性就引出了无锁编程一个人对共享数据执行CAS操作如果失败就循环重试直到成功为止。所有并发控制都在一条指令内完成。2. CAS为什么能保证原子性硬件和JVM的配合2.1 Java里怎么调用CASUnsafe的进去看Java标准库没有暴露直接操作内存的API得靠JDK内部一个叫Unsafe的类。这个类在sun.misc包下名字就叫“不安全”因为它的方法大多直接操作内存地址用不好能把JVM干崩。public final class Unsafe { public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); }这里的o是要操作的那个对象offset是目标字段在对象内存布局中的偏移量expected就是CAS期望值x是新值。JVM把对象和字段偏移量传下去由native方法把它翻译成一条CPU指令。AtomicInteger的自增就是这么实现的public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) 1; }getAndAddInt内部就是个do-while循环反复CAS直到成功。Unsafe类普通代码是拿不到的。它的构造器是私有的而且类加载时有安全校验。不过JDK9以后提供了更友好的VarHandle作用类似可以在普通代码里安全使用。2.2 硬件层面CPU的一条指令就把事办了CAS的原子性最终靠的是处理器提供的原子指令x86架构上对应的是CMPXCHG指令编译后就是一行汇编lock cmpxchg [reg], reglock前缀是关键它锁住总线或者锁住缓存行在指令执行期间其他处理器无法访问这段内存。现代处理器缓存一致性协议会做更多优化用锁缓存代替锁总线性能更好。也就是说程序员眼中的“比较判断 条件更新 失败回退”在硬件里只是一条带锁前缀的指令。它在执行过程中不会响应中断也不会被其他CPU核心的写操作打断。这里延伸一个高频面试点为什么synchronized重CAS轻因为synchronized的加锁解锁不只是硬件指令还涉及对象头里的mark word、锁升级、线程阻塞唤醒而CAS本质上就是一条硬件指令没有阻塞的概念。它们不是同一层级的方案CAS是底层构件锁是在它之上更重的策略。2.3 JIT和内存模型CAS和volatile是什么关系CAS操作涉及的内存地址通常要声明为volatile。AtomicInteger内部的value字段就有一个volatile修饰符private volatile int value;原因是CAS要比较的值必须是当前线程能看到的最新值。如果value没有volatile保证可见性一个线程修改后另一个线程的“比较期望值”可能还是旧值CAS肯定会失败逻辑不会错但性能会崩。volatile的语义保证了两点写操作会立刻刷新到主内存读操作会从主内存读取。两者配合起来CAS检查的“当前值E”才是所有线程眼里一致的值。JIT编译时也会针对这种模式做优化把高频自旋代码编译成比较高效的本地指令。所以面试时不要只背“CAS是CPU指令”还要加一句在Java里CAS一般配合volatile使用这样读的就是最新值写的就是同步值。3. CAS在JDK和业务中的经典落地3.1 Atomic系列CAS最直白的使用java.util.concurrent.atomic包整个就是CAS的教学现场。AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference各自封装了对int、long、boolean、对象引用的CAS操作。用AtomicLong举个例子。高并发下累加一个ID或者统计某系统的总请求数直接AtomicLong totalRequests new AtomicLong(0); public void onRequest() { totalRequests.incrementAndGet(); }如果需要先读取再根据旧值做判断还有updateAndGet方法传一个函数进去JVM会循环执行直到CAS成功atomicInteger.updateAndGet(x - Math.max(x, 100));这个函数的写法天然规避了一个经典错误先get再用compareAndSet两步之间数据可能已经变了。updateAndGet把“读-计算-写回”封装成了一个带有重试语义的整体。3.2 AQS背后不经意间就用到CASAQSAbstractQueuedSynchronizer是JUC里ReentrantLock、Semaphore、CountDownLatch的地基。面试必问AQS很多人背了“CLH队列、state变量”却没意识到AQS的关键操作就是CAS。AQS里有一个state字段用来表示同步状态比如锁的重入次数。加锁的核心动作是尝试把state从0改成1代码里就是compareAndSetState(0, 1)。多个线程同时抢锁只有CAS成功的那个线程能拿到锁其他线程进入队列等待。protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }这个CAS就是锁的入口。一旦CAS失败线程才需要付出较重代价进入等待队列、阻塞。所以ReentrantLock本身是“先用CAS快速试一次试不上再走锁机制”这种设计叫快速路径。3.3 乐观锁业务系统里的CAS思维CAS思想不光活在JDK里。数据库的乐观锁完全就是CAS这套逻辑update stock_table set stock #{newStock}, version version 1 where id #{id} and version #{oldVersion};影响行数为0说明version被别的事务改了更新失败业务层重试。这个version比对就是“期望值E”的判断where条件里的version oldVersion是CAS里的比较动作set里的新库存和version1是交换动作。我曾经在一个秒杀项目里给某电商平台做库存扣减最初用悲观锁select for update压测时吞吐上不去。后来改成乐观锁update语句把库存判断直接塞进SQL条件里吞吐翻了几倍。这里面的判断就是CAS思路——系统不对资源加锁而是在最后写入前检查“是否已被修改”。当然乐观锁不是银弹下面详细说坑。4. CAS的三大坑和工程解法4.1 ABA问题改来改去等于没改但中间变了CAS只检查“当前值是不是期望值”它无法感知这个值中间是否被改过几次。最经典的例子线程A读到一个栈的栈顶元素是Node(1)准备用CAS把栈顶替换成Node(2)。线程B先一步把栈顶改成Node(3)紧接着又改成Node(1)。在线程A做CAS的那一刻它发现栈顶还是Node(1)判断没变更新成功。但实际上栈的结构已经变了Node(1)下面的next节点都和A开始时不一样了。用钱来做类比更直观钱包余额是100元A想扣10元。CAS期望值是100。B先转了10元进来变成110又消费10元变回100。A的CAS依然成功看似没扣错但中间发生的那笔收支完全没被感知。解决方案是加版本号。不仅比较值还比较版本。Java里对应AtomicStampedReference它内部维护一个int类型的戳每次更新值的同时戳也要对AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); boolean success ref.compareAndSet(100, 90, stamp, stamp 1);这里的stamp就是版本号值变一次、戳变一次。哪怕金钱回到100戳已经变了CAS就会失败。面试追问点AtomicMarkableReference也能解决ABA它只记录boolean适合关注“是否被动过”而不是“被动了多少次”的场景。能说出这两个类的区别说明你是真懂ABA。4.2 自旋开销失败到成功的路可能很长CAS失败后继续重试这个“继续”是消耗CPU的。如果竞争很激烈大量线程同时自旋在一个共享变量上CPU利用率可能飙升性能反而比锁还差。我用过一次真实教训。某系统做热点账户扣款同一个账户被很多请求并发操作每个请求都用AtomicLong做余额自减结果压测时CPU密集区几乎全在自旋。自旋本身不阻塞线程但它让线程占满时间片其他等待线程也用不上CPU。那里的解法不是去掉CAS而是先分流。把同一个账户的更新请求按ID取模分到多个子账户上减小对同一个变量的竞争。或者改用LongAdder。LongAdder的思路很妙它内部维护一组Cell数组每个线程更新时先通过哈希落到自己的Cell上最后求和再汇总。相当于把CAS竞争的“单热点”打散成了“多分片”对CPU总开销来说自旋的次数大幅下降。如果你的场景是“读多写少”的统计计数器LongAdder比AtomicLong合适得多。这里放一张简单的对比表方便记工具适用场景冲突处理方式性能特点synchronized竞争少或需要等待语义阻塞挂起 唤醒冲突少时开销低冲突多时线程切换成本高AtomicLong CAS单变量更新冲突少自旋重试冲突少性能极高冲突多时CPU空转明显LongAdder频繁累加写多读少分片Cell CAS写冲突大幅降低读时求和略慢ReentrantLock临界区较长可中断可超时CAS快速路径 等待队列冲突多时比锁自旋好避免CPU空转4.3 只能搞定一个变量多字段同改怎么办CAS的V只能是一个内存地址。如果需要同时更新两个变量并且要求两个变量要么都变、要么都不变CAS就不好直接处理了。比较实的解法是把这几个字段封装成一个对象用AtomicReference对对象引用做CAS。比如一个用户对象既改积分又改等级每次都构造一个新的User对象然后用compareAndSet替换class User { final int points; final int level; User(int points, int level) { this.points points; this.level level; } } AtomicReferenceUser userRef new AtomicReference(new User(0, 1)); void upgradePoints(int delta) { while (true) { User current userRef.get(); User next new User(current.points delta, current.level); if (userRef.compareAndSet(current, next)) { break; } } }这叫不可变对象加CAS替换很多并发容器的节点更新都是这么玩的。代价是每次更新都要new一个新对象垃圾回收压力会大一点但换来的是无锁原子性。如果字段特别多且更新频率极高new对象的开销不可忽视就得考虑另一个方案把修改动作包到一个临界区里用锁来做。LMAX Disruptor的高性能队列里也用了大量CAS加不可变对象的设计可见这是一套经过验证的组合。5. 面试实战CAS高分回答与必问场5.1 面试官想听的分析框架很多候选人一上来就背定义“CAS是compare and swap有V、E、N如果V等于E就设成N。”能背下来问题不大但面试官立刻会感觉到你只是背了八股没有自己的理解。我建议的答题框架是这样的第一层讲它是什么一条CPU原子指令通过比较内存值和期望值来决定是否更新Java通过Unsafe调用。第二层讲它解决的问题让单个变量的更新变得线程安全不需要加锁无阻塞、无上下文切换并发性能更好。第三层讲它的边界只能保证一个变量的原子性因为硬件指令本身不维护历史会出现ABA问题竞争激烈时自旋抢占CPU。第四层讲工程应对ABA用AtomicStampedReference加版本自旋用LongAdder、分片或者减少热点变量一个对象多个字段用AtomicReference封装不可变对象。这套顺序从底层到应用层再到坑和方案面试官很难打断你因为你想说的正是他要问的。5.2 高频追问与参考答案我把面试中围绕CAS的典型追问整理成一个速查表每个问题的核心答案控制在两句话以内再补一句可延展的点追问核心答案进阶点CAS真的不会阻塞吗不会自旋是在用户态反复尝试不进入内核态不挂起线程如果长时间CAS不成功会导致CPU占用上升极端场景下类似忙等ABA是什么值从A变成B又变回ACAS认为没变但中间状态被忽略了AtomicStampedReference和AtomicMarkableReference是两种版本策略CAS和volatile有什么关系CAS需要读取最新值volatile保证可见性Atomic类内部字段都是volatileJava内存模型里volatile的写会触发后续读的可见性为什么synchronized没被CAS替代CAS只适合单变量短操作临界区多步操作、复杂状态转换仍然需要锁synchronized还有锁升级机制JVM下性能并不差数据库乐观锁是不是CAS思路一样版本号比较替代了CAS的期望值比较需要关注更新失败后的业务重试机制5.3 手把手写一个CAS自旋计数器面试中如果让你实现一个线程安全的计数器要求不许用锁、不许用Atomic类那就手动模拟一个CAS循环。思路就是拿到旧值算出新值然后CAS。失败就重新读。完整代码可以这样写public class SpinCounter { private volatile int count 0; // 模拟CAS操作实际环境下由Unsafe或VarHandle提供 private boolean compareAndSet(int expected, int newValue) { synchronized (this) { if (count expected) { count newValue; return true; } return false; } } public int increment() { while (true) { int current count; int next current 1; if (compareAndSet(current, next)) { return next; } } } public int get() { return count; } }这段代码里的synchronized只是为了模拟“原子比较更新”这个动作真实场景中换成Unsafe的compareAndSwapInt或者VarHandle.compareAndSet即可。逻辑是一样的先读旧值尝试更新失败重来。很多同学面试时会忽略一点compareAndSet里的期望值必须是循环内读到的current不能是这次循环之前读的。写代码时代码块会产生自己实现的问题有两点一是期望值必须同步更新二是失败后要立刻重读不能spin太久。5.4 业务场景回答账户扣款怎么用CAS思路有一个被反复问到的场景题某支付系统里用户余额是一个整数多个线程可能同时扣款怎么保证不超扣如果数据在数据库里答案乐观锁update语句带version条件影响行数为0则重试。如果数据在进程内存里比如网关限流的令牌桶计数答案AtomicLong配合CASAtomicLong balance new AtomicLong(100); public boolean deduct(int amount) { while (true) { long current balance.get(); if (current amount) { return false; } long next current - amount; if (balance.compareAndSet(current, next)) { return true; } } }注意里层的判断current amount是在CAS之外做的可能会有竞态两个线程同时读到100都发现能扣10都扣了结果余额变成90还是80取决于谁先成功。为什么代码是对的因为失败的那个线程会进入下一次循环重新读余额最终只有一个线程能把余额从100变成90另一个会重试再扣10或者因余额不足返回false。这个细节是面试加分点很多人在第一版代码就漏了。5.5 用一句话说服面试官CAS与无锁的取舍面试快结束时面试官可能会来一句总结式提问“你觉得CAS是万能的吗”要果断说不。CAS在低竞争场景下极其高效因为它没有线程挂起、唤醒、上下文切换。但在高竞争场景下它会退化为CPU自旋性能反而不如锁。无锁之所以叫无锁不是说锁没有价值而是换了一种更轻量的方式协调共享变量。真正的高手会按需选择临界区短、单变量更新、并发冲突小CAS临界区复杂、需要等待队列、多个状态联动还是上锁或者并发工具类。6. 写在最后从面试考点到工程直觉的一点体会见过很多候选人CAS八股背得滚瓜烂熟一到实际项目就只会用AtomicInteger。原因不是知识不够而是缺少“从问题反推方案”的视角。我的个人体会是不要盯着CAS本身要盯两个问题——当前共享数据到底是什么粒度以及并发冲突到底有多严重。粒度是单变量还是多字段冲突是高是低直接决定你是用Atomic系列、LongAdder、AtomicReference还是干脆用锁。CAS不是目的它是“避免阻塞、减少上下文切换”的一种手段。手段适不适合取决于场景而不是因为听起来厉害。如果你下周就要面试建议花一小时做三件事把我认为的四个层次讲一遍给同事听手写一个CAS自旋计数器并想想它和AtomicInteger的差别再想想你项目里有没有解决过ABA问题用自己的例子包装一下。面试官最怕背概念的最喜欢听完一句话就能继续追问的。CAS这个考点背得来是及格说得清边界和取舍才是优秀这也是无锁编程真正的价值所在。
阅读完成 · 觉得有帮助?