ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

彻底搞懂CAS:从AtomicInteger底层到ABA问题与并发性能优化

彻底搞懂CAS:从AtomicInteger底层到ABA问题与并发性能优化 1. 从一次面试和一次线上事故说起前阵子团队招人我面了一个简历上写着“熟悉Java并发编程”的候选人问了他一个很基础的问题AtomicInteger的原子性是怎么保证的他脱口而出“volatile”再追问一句“volatile能保证原子性吗”他就开始支支吾吾了。这个问题其实非常典型因为大多数人对并发编程的认知停留在synchronized和volatile的层面真正能把CAS讲清楚的人十个里未必有三个。另一方面去年我们线上有个库存扣减接口在大促峰值时段频繁出现库存超卖。一开始大家首先想到的是加锁synchronized或者Redis分布式锁但压测发现吞吐量上不去。后来我们把热点的库存扣减从“锁 数据库更新”改成了“CAS 重试”的自旋方案QPS直接翻了将近一倍超卖也彻底解决了。这两个场景一个在面试间里一个在线上生产环境本质都指向同一个东西——CASCompare-And-Swap比较并交换。这篇文章我打算把它彻底聊透。从CPU硬件指令到底层实现从AtomicInteger源码到ABA问题的完整解决方案再到面试官最爱追问的几个变形题。不管你是刚接触并发编程的初学者还是准备跳槽想系统梳理并发知识的进阶开发者这篇都值得收藏下来反复看。2. CAS的底层原理一次比较、一次交换凭什么就线程安全了2.1 先从Unsafe类和那段经典的native方法说起在JDK里CAS最核心的入口其实是sun.misc.Unsafe类JDK 8及以前或者jdk.internal.misc.UnsafeJDK 9。虽然我们业务代码几乎不会直接用它但理解了它就理解了CAS的一半。我先把AtomicInteger的核心方法摆出来// JDK 8中的实现基于Unsafe public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 this.getIntVolatile(var1, var2); } while (!this.compareAndSwapInt(var1, var2, var5, var5 var4)); return var5; }这个方法做了两件事第一通过getIntVolatile读取对象某个内存偏移量处的当前值第二调用compareAndSwapInt只有当内存中的当前值还等于我们读到的var5时才把值更新成var5 var4。如果失败就重新读、重新比、重新试直到成功为止。这个do-while循环就是典型的自旋。compareAndSwapInt是个native方法实际调用的是CPU底层的cmpxchg指令。这一条指令完成“比较”和“交换”两个动作而且是原子的——要么比较失败什么都不做要么比较成功并且完成赋值中间不会被线程调度打断。这里我补充一个很多文章容易忽略的细节cmpxchg在单核CPU上本身天然具备原子性因为单核环境下一条指令在执行过程中不会被其他指令插入。但在多核CPU上情况就不一样了。Intel在cmpxchg指令前加了一个lock前缀lock cmpxchg会锁住总线或者锁住缓存行保证多核环境下的原子性。这也解释了为什么CAS不是“免费”的它底层是有硬件协调成本的。注意Java的CAS在x86架构上对应的是lock cmpxchg在ARM架构上对应的是LDREX/STREX指令对。所以跨平台讲解CAS的时候不要只盯着x86的实现JDK的JIT会根据CPU架构生成不同的机器码。2.2 为什么CAS能做到无锁缓存一致性协议是关键要真正理解CAS为什么“无锁”需要往CPU缓存体系再深挖一层。现代CPU都有三级缓存每个核心有自己私有的L1和L2缓存多核共享L3缓存。当多个线程由操作系统调度到不同CPU核心上执行同时操作同一个变量时这个变量会以副本的形式存在于多个核心的私有缓存里。每个核心改的是自己的副本那怎么保证大家看到的值一致靠的就是缓存一致性协议最典型的就是MESI协议。MESI协议把缓存行标记为四种状态Modified已修改、Exclusive独占、Shared共享、Invalid失效。当一个核心要修改某个缓存行时它会先向总线发出消息通知其他核心把对应的缓存行置为Invalid状态。只有拿到了缓存行的独占权才能安全修改。CAS走的就是这个机制。lock cmpxchg指令会让当前核心锁定缓存行完成比较和交换操作然后让其他核心的副本失效下次读取时被迫从主内存重新拉取最新值。这样看起来好像只是“比较 赋值”两个动作但实际上底层涉及了一整套缓存同步协议的工作。我打个比方CAS就像一群人围着一张桌子桌上放了一个便签上面写着一个数字。你要改这个数字先看一眼便签上的值然后告诉所有人“我看到的数是N我现在要把它改成M”。如果在你喊出这句话的时候别人还没动过这个便签还是N那你就能写上去如果别人已经改成别的值了那你这一轮操作作废重新看、重新喊。整个过程中没有人排队领号也没有人拿钥匙开锁靠的是“先比较、后更新”这条规则的约束力。2.3 原子性、可见性、有序性CAS到底保证了什么这个问题面试官特别喜欢追问你得能分层回答原子性比较和交换是一条CPU指令完成的天然不可分割。这是CAS最核心的保障。可见性CAS通过缓存一致性协议让其他核心缓存失效读取时强制走主内存所以修改后的值对其他线程可见。加上Java层面通常会配合volatile声明变量保证读操作总能拿到最新值。有序性CAS本身不提供完整的内存屏障语义但硬件层面lock前缀指令带全屏障的效果JIT在翻译时也会保证相关指令的有序性。这一点在日常业务代码中感知不明显但在实现无锁数据结构时会比较重要。不过CAS不是万能的。它只能保证一个变量的原子更新如果业务需要多个变量作为一个整体来原子更新CAS就使不上力了。这一点我后面讲缺点的时候会展开。3. CAS的优与劣性能神器背后的三个坑3.1 优点无锁竞争、无阻塞、高并发下的性能红利CAS最吸引人的地方就是“无锁”。这里说的无锁指的是没有操作系统层面的锁机制比如synchronized在重量级锁阶段依赖操作系统的mutex线程竞争不到锁会进入阻塞状态涉及用户态与内核态的切换开销非常大。而CAS不需要这种切换。线程更新失败后马上进入下一轮循环重试整个过程都不离开用户态。在锁竞争不那么激烈、操作又很轻量的场景下CAS的吞吐量要明显优于synchronized。这点我用实际测试验证过在一个简单的计数器累加场景下8个线程并发执行10万次自增AtomicInteger的耗时大约是synchronized锁保护下的int变量的三分之一当然这个数据在不同机器上会有差异但性能优势是实打实的。CAS在高并发读多写少的场景下尤其友好。因为读操作不需要CAS只有真正的写操作才需要走“比较-交换”的流程竞争的烈度被大幅降低。3.2 缺点一长时间自旋导致CPU空转这一点我要放到最前面说因为它是最容易被忽视的“隐形杀手”。CAS失败后会不断重试如果两个线程互相竞争非常激烈一个线程可能在循环里转上成千上万次。每一轮循环都要执行一次完整的读-比较-交换流程CPU占用率会被拉得很高。我上线过一个用CAS实现的自旋锁测试的时候没发现问题一上生产有个热点数据被20个线程反复抢结果某个CPU核心的使用率直接飙升到接近100%。当时的解决方法是在自旋超过一定次数后让线程主动让出CPUThread.yield或者sleep 1毫秒避免死循环式自旋。经验分享CAS追求的是“短平快”的操作。如果原子操作的逻辑很重比如更新前要计算复杂的哈希、更新后要联动更新多个字段那就不适合用CAS老老实实上锁反而更稳。3.3 缺点二只能保证单个变量的原子性java.util.concurrent.atomic包下提供了AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference这些类但它们的共同点是一次CAS操作只能针对一个变量。假设我要做一个转账操作需要同时扣减转出账户余额、增加转入账户余额这两个变量必须作为一个整体原子更新用两个独立的CAS根本做不到。经典的解决方案有两种。第一种是封装一个大对象把多个字段都放进一个自定义类中然后用AtomicReference引用这个对象每次修改时整个对象进行CAS替换。这种方案的缺点是每次修改都要重新创建一个对象如果有大量线程在修改对象创建开销会很大。第二种是用AtomicReferenceFieldUpdater或者AtomicIntegerFieldUpdater它们可以针对已有对象中的某个字段进行操作而不需要封装整个对象。在框架源码里经常能看到这种用法目的是省掉一个引用对象的创建和内存占用。不过Updater要求字段必须是volatile的而且不能是static和final的这一点要注意。3.4 缺点三ABA问题CAS最经典的副作用ABA问题的场景我单独用一章来讲因为它是面试中必问的也是实际写无锁代码时最容易被坑的地方。4. ABA问题数值相同不代表状态没有变化4.1 ABA问题是怎么发生的先描述一下经典场景。假设有一个共享变量初始值是A。线程1读取到A准备做CAS操作。在线程1尚未执行CAS之前线程2抢先执行把A改成了B随后又把B改回了A。这时候线程1执行CAS它发现当前值还是A与它之前读到的一致于是CAS成功了。从结果来看线程1的更新操作确实执行了变量从A变成了CC是线程1要更新的新值。但问题在于这个过程中的B-A变化线程1完全感知不到。在某些业务场景下这种“看不到的中间状态变化”可能带来严重问题。我举个关于栈的例子。用CAS实现一个无锁栈栈顶元素初始为A。线程1要弹出栈顶元素先读取栈顶为A然后执行CAS期望值是A新值是A的下一个节点B。线程1还没执行CAS时线程2把栈顶A弹出了又压入了新元素A可以是内容相同的另一个节点对象。此时线程1的CAS依然成功栈顶被设置成了B。但B其实是线程2压入A之前就已经不在栈里的旧节点这个操作把整个栈的结构破坏了。这就是ABA问题最典型的危害表面上看地址或者值没变但对象本身已经被“用过一次”了。栈变成了一个“野指针”式的数据结构后续出栈操作很可能访问到一个已经不再属于栈的节点。4.2 从ABA到“状态丢失”的抽象理解我换一个更贴近业务的理解方式。假设你用一个变量记录“订单状态”0代表未支付1代表已支付2代表已取消。线程A读到状态0准备执行“标记为正在处理”的操作。线程B提前把状态从0改成1已支付很快因为退款原因又把状态改回0。线程A执行CAS时发现还是0于是成功地把状态改成了“处理中”。但从业务视角看这个订单在两次状态0之间已经经历了“支付”和“退款”两个流程当前时刻它实际上处于“已退款”状态。线程A却把它当成最原始的“未支付”状态来处理后续逻辑很可能继续引导用户重新支付引发重复支付或资金损失。所以ABA问题的本质是比较的是“值”但我们要保证的是“状态没有被中途改变”。数值碰巧相同不等于状态连贯。4.3 AtomicStampedReference用版本号戳穿“重演”解决ABA最正统的方式是给每个变量配一个版本号每次修改不仅仅是更新值还要更新版本号。下一次CAS的时候不仅比对值还要比对版本号。如果版本号不一致说明中间发生过修改CAS就会失败。JDK里对应的是AtomicStampedReference它的核心思想就是把一个引用和一个int版本号打包成一个Pair对象每次CAS操作都要求“引用相同”且“stamp版本号相同”。import java.util.concurrent.atomic.AtomicStampedReference; public class AtomicStampedReferenceDemo { private static AtomicStampedReferenceInteger atomic new AtomicStampedReference(0, 0); // 初始值0版本号0 public static void main(String[] args) { int[] stampHolder new int[1]; Integer value atomic.get(stampHolder); int currentStamp stampHolder[0]; // 模拟一次修改值从0 - 1版本号 1 atomic.compareAndSet(value, 1, currentStamp, currentStamp 1); // 再次读取注意版本号已经变成1了 Integer secondValue atomic.get(stampHolder); int secondStamp stampHolder[0]; // 这时候如果还用旧的value和旧的stamp去CAS就会失败 boolean result atomic.compareAndSet(0, 2, currentStamp, currentStamp 1); System.out.println(CAS result result); // 输出 false } }这里有个使用上的小技巧get()方法需要传入一个int[1]数组用来接收版本号。原因是Java没有多返回值通过数组参数来“带出”额外信息。每次想修改前必须重新读取最新的值和版本号不要复用旧的快照否则CAS永远成功不了或者永远都不该成功却成功了。4.4 AtomicMarkableReference不关心改了多少次只关心有没有被改过AtomicMarkableReference是ABA问题的第二种解法。它内部不是一个int版本号而是一个boolean标记位。它不关心对象被改了多少次只关心“有没有被修改过”。引用相同且标记相同CAS才会成功。什么场景适合用它比如“只需要一次性初始化”的变量。线程A发现状态是“未设置”准备CAS设为“已设置”。如果在线程A执行之前线程B设置了一次然后又重置为“未设置”线程A可能重复设置。但实际上我们关心的不是“设置了多少次”而是“到底有没有设置成功过”。这时候用AtomicMarkableReference比AtomicStampedReference更合适因为它只用一个布尔位就表达清楚了省内存也更简洁。import java.util.concurrent.atomic.AtomicMarkableReference; public class AtomicMarkableReferenceDemo { // 初始引用为 nullmarked 为 false private static AtomicMarkableReferenceString ref new AtomicMarkableReference(null, false); public static void main(String[] args) { String current ref.getReference(); boolean marked ref.isMarked(); // 第一个线程尝试设置 boolean success ref.compareAndSet(null, config-v1, false, true); System.out.println(first CAS result success); String second ref.getReference(); boolean secondMarked ref.isMarked(); // 第二个线程尝试重新设置但此时 marked 已经是 true必然失败 boolean secondAttempt ref.compareAndSet(second, config-v2, secondMarked, true); System.out.println(second CAS result secondAttempt); } }4.5 什么时候可以忽略ABA问题什么时候必须处理在实际开发里并非所有ABA问题都需要解决。如果变量的语义是“只要最终值一致就行过程无所谓”那么ABA就是无害的。比如一个统计访问次数的计数器中间从100变成101又变回100最终结果还是100业务上没有任何影响。但如果是无锁数据结构如无锁栈、无锁队列、多版本状态流转如订单状态机、资源分配与释放如数据库连接池里某个连接被复用就必须认真处理ABA。这种情况我一般直接上AtomicStampedReference它在绝大多数场景下都是够用的。避坑提示不要为了“防ABA”而盲目引入版本号。版本号会增加CAS的内存开销和代码复杂度如果业务本身对中间状态不敏感用普通的AtomicReference就够了。过度设计是并发编程里最常见的错误之一。5. CAS在JDK中的经典应用从AtomicInteger到LongAdder5.1 AtomicInteger源码精读从getAndIncrement到updateAndGet看到一个AtomicInteger的源码感觉简单但里面有几个设计细节值得我们学。上面的getAndAddInt已经展示了核心的自旋逻辑。再看incrementAndGetpublic final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) 1; }它调用了getAndAddInt然后加1返回。之所以要加1是因为getAndAddInt返回的是“修改前的值”而不是“修改后的值”。这个细节在面试中经常被拿来考察incrementAndGet和getAndIncrement的区别不只是“先加再取”和“先取再加”这么简单底层实现都依赖同一个方法只是返回值加不加1而已。还有一个点是valueOffset。它是value字段在AtomicInteger对象内存布局中的偏移量在静态初始化块中通过Unsafe.objectFieldOffset获取。有了这个偏移量Unsafe就能直接操作对象的内存绕过了Java访问修饰符的限制直接读/写字段。这也是反射做不到的——反射在性能上比Unsafe操作差不少。JDK 8之后AtomicInteger又引入了updateAndGet和accumulateAndGet方法它们接受一个IntUnaryOperator或IntBinaryOperator函数式接口可以把“更新逻辑”和“CAS重试逻辑”组合起来。AtomicInteger count new AtomicInteger(10); count.updateAndGet(value - Math.max(value, 20)); // 如果当前值小于20就更新为20这个API的写法比较接近函数式编程但在并发场景下要特别小心传入的lambda可能会被多次调用因为CAS失败后会反复执行lambda。所以lambda里不能有副作用比如不能在里面打印日志、不能修改外部状态否则可能产生你意想不到的重复执行效果。这一点我在实际项目里踩过坑当时在lambda里调用了一个RPC接口CAS失败几次就重复调用了几次RPC直接把下游服务打挂了。5.2 LongAdderCAS的另一种打开方式AtomicInteger在高并发下如果竞争激烈自旋会非常严重性能急剧下降。JDK 8引入了LongAdder它的思路是把一个热点变量拆成多个“单元”每个线程往自己对应的单元里累加最后需要获取总量的时候再把所有单元的值加起来。这个思路本质上就是“分段CAS 最终汇总”。它降低了单点竞争比AtomicInteger在超高并发下的表现好很多。我用一个粗糙的基准测试验证过8个线程各累加10万次LongAdder的耗时大约只有AtomicInteger的一半。但注意LongAdder的sum()方法返回的是一个近似值因为它在求和的过程中其他线程可能还在往单元里写值。因此LongAdder适合“读写分离”的场景适合统计埋点、指标上报这类不要求强一致性的场景不适合作为分布式锁或者唯一ID生成器这类强一致性的基础设施。5.3 ConcurrentHashMap对CAS的运用ConcurrentHashMap在JDK 8的实现中大量使用了CAS。比如往table数组里放元素的casTabAt方法以及初始化表时保证只有一个线程负责创建数组节点的逻辑。它和synchronized混用的方式也很巧妙头节点用CAS设置避免锁竞争一旦CAS失败说明其他线程正在操作这个槽位当前线程再通过synchronized锁住头节点做后续的扩容或者树化操作。这种“CAS synchronized”的组合策略是JDK并发包里的经典设计。核心思路是在低竞争路径上尽量用CAS快速完成在高竞争路径上退化为锁避免无休止的自旋消耗CPU。这也是我在设计自己的并发组件时特别推崇的一种模式。6. 手写一个CAS自旋锁彻底弄懂无锁与锁的边界6.1 基于AtomicReference实现一个可重入自旋锁面试里经常会让你手写一个自旋锁。我用AtomicReference实现一个简单版本通过记录持锁线程来实现可重入。import java.util.concurrent.atomic.AtomicReference; public class SpinLock { private final AtomicReferenceThread owner new AtomicReference(); public void lock() { Thread current Thread.currentThread(); // 可重入如果当前线程已经持锁直接重入计数1 if (current.equals(owner.get())) { reentrantCount; return; } // 自旋获取锁不断尝试将owner从null更新为当前线程 for (;;) { if (owner.compareAndSet(null, current)) { return; } // 可选的让出措施避免空转太猛烈 Thread.yield(); } } public void unlock() { Thread current Thread.currentThread(); if (!current.equals(owner.get())) { throw new IllegalMonitorStateException(不是持锁线程不能释放); } if (reentrantCount 0) { reentrantCount--; return; } // 释放锁将owner从当前线程更新为null owner.compareAndSet(current, null); } private int reentrantCount 0; }这个实现有个关键点为什么用AtomicReference而不是一个普通的volatile boolean因为“获取锁”的本质操作是“判断当前无人持有 → 将持有标记设为自己”这两个动作必须原子完成。一个普通的布尔变量赋值做不到这一点。只有CAS可以保证“compare swap”一起成功。这也是无锁编程的核心思维把多个逻辑步骤压缩成一个原子的比较与交换。我把这个自旋锁放进一个压测环境发现一个很有意思的现象当只有两个线程竞争时性能非常棒几乎没什么损耗。但线程数上升到十几个后CPU核数不变自旋竞争加剧性能不升反降。后来我在自旋循环里加了一个Thread.yield()让CPU在自旋失败时主动让出执行权情况立刻好了很多。不过也要注意yield()不是强制的JVM完全可以选择忽略它所以它只能作为缓解手段不能作为正确性的保证。6.2 从锁到CAS的演进路径为什么JDK把synchronized优化成“自适应自旋”很多人误以为synchronized是“重锁”性能一定差。其实JDK 6之后synchronized做了大量优化其中有一个就是“自旋”。在没有实际的线程竞争之前synchronized先是偏向锁接着是轻量级锁轻量级锁获取失败时才升级为重量级锁重量级锁获取失败时线程进入阻塞状态。轻量级锁的获取本身就是用CAS实现的。JVM在为对象创建锁记录时会尝试用CAS把Mark Word替换成指向锁记录的指针。如果成功锁就获取成功了如果失败说明有其他线程持有锁JVM会进入自旋等待。JDK 6之后引入的自适应自旋会根据以往自旋等待的成功率来动态调整自旋次数如果最近总是成功就多自旋一会儿如果经常失败就少自旋甚至不自旋直接阻塞。所以synchronized和CAS不是非此即彼的关系。它们是同一套并发原语在不同层面的两种表现背后都离不开cmpxchg指令。理解这一点你在面试时回答“synchronized和CAS的区别”会更有深度而不是只把表面的优缺点背一遍。6.3 什么时候用synchronized什么时候用CAS我说说自己的经验法则临界区代码短、逻辑简单、无IO优先用CAS。比如计数器自增、状态位切换、引用替换。临界区代码长、逻辑复杂、包含IO操作果断用synchronized或ReentrantLock。CAS在这种场景下只会疯狂空转白费CPU。读多写少的共享变量适合用volatile CAS结合。比如读操作直接读volatile变量写操作通过CAS更新。需要阻塞唤醒机制比如等待某个条件成立后再执行时用LockSupport或者synchronized配合wait/notifyCAS做不了这件事。这个法则在我做项目时基本没出过问题。7. 并发编程里的CAS实战细节与测试验证7.1 从Lightning Trade System场景看CAS如何落地我在团队里维护过一个内部的轻量级交易计数系统每个订单要经历“创建、待支付、已支付、已取消”四个状态状态流转频率很高而且多个服务会同时修改状态。最初用数据库乐观锁也就是update ... where status ?但数据库行锁在高并发下成了瓶颈。后来我们改成内存中用AtomicStampedReference维护订单状态对象状态流转只有毫秒级延迟因为所有状态更新走的是CAS不依赖数据库锁。数据库里的状态只做最终一致性的落库不再参与实时控制。这套方案有一个必须注意的点CAS必须在“最短路径”上保证操作成功一旦长时间失败必须有兜底。我们当时的兜底是CAS重试超过3次后直接把订单状态通过队列异步交给单线程处理器由它来串行推进状态机。这样既保证了高并发下的低延迟又不会因为极端竞争导致任务积压。7.2 实测AtomicInteger在高竞争下为什么慢但可控我写了一个极简测试来展示竞争的代价用四个线程对同一个AtomicInteger连续自增一百万次观察耗时和CPU占用。测试结果如下数据仅供参考不同机器结果会有差异实现方式4线程耗时(ms)TCP线程数说明synchronized int约5504夹带了锁升降级和阻塞唤醒AtomicInteger约1204自旋竞争但整体可控LongAdder约654分段单元竞争分散再加大压力到16个线程AtomicInteger的耗时开始明显上升因为CPU核心数不足以让所有线程“真并行”大量线程在自旋等待。LongAdder的上升幅度则平缓很多这是分段设计的红利。从这个测试里能得到一个实操结论当你不知道未来并发量到底有多大时优先选LongAdder而不是AtomicInteger代价只是多占几十个字节的内存换来的是更平稳的性能曲线。7.3 数据库层面的乐观锁和Java CAS的异同很多同学会把“数据库乐观锁”和“Java CAS”混为一谈因为它们的思想一致先比较、再更新。但数据库乐观锁通常是用版本号字段实现的UPDATE account SET balance balance - #{amount}, version version 1 WHERE id #{id} AND version #{oldVersion};如果更新影响行数为0说明版本号变了需要重试。这和CAS的核心逻辑一模一样。区别在于数据库乐观锁依赖数据库自身的行锁和事务机制比较和更新是在数据库服务端完成的Java CAS则是在JVM进程内完成。前者适合分布式场景后者适合单机进程内场景。如果你们系统已经引入了Redis也可以把Redis的WATCH命令看成一种分布式CAS的雏形WATCH监控keyMULTI/EXEC事务执行提交时如果key被其他客户端改过事务就失败。不过Redis的事务不支持部分成功回滚使用时要把每个命令都设计成幂等的否则失败后可能出现脏数据。8. 面试高频追问与我的参考答案8.1 面试官问“CAS的缺点有哪些”时重点说什么这个问题考察的是深度。我建议的回答框架是这样第一CPU开销。长时间自旋占用CPU特别是多线程竞争激烈时会拖垮系统性能。第二只能保证单个变量的原子性涉及多个变量的复合操作无法直接用CAS完成。第三ABA问题。这是CAS本身的设计缺陷因为它只比较当前值无法感知“值经历过多少次变化”。第四在JVM层面还有一些边界情况比如带位移的字段CAS、对象头中的mark word操作等处理不好容易引入难以定位的并发bug。这样回答既展示了你知道CAS的优点也证明你踩过它的坑面试官通常会从这个问题继续往深处追问ABA问题的解决方案准备充分的话整个面试节奏就会很顺。8.2 “如何用CAS实现一个阻塞队列”的标准回答路径这也是一个扩展题。标准回答套路可以用一个AtomicReference数组来做环形队列维护head和tail索引。入队时用CAS更新tail指针出队时用CAS更新head指针。但要注意当队列为空或满时CAS会反复失败不能无限自旋需要让线程阻塞。阻塞可以用LockSupport.park和unpark或者直接在元素上挂一个锁。更简单的做法是先采用synchronized wait/notify写一个基础版阻塞队列然后再展示如何用CAS替换其中“更新索引”的部分把锁竞争缩小到仅剩阻塞机制本身。这种“先粗后细”的演进思路在面试中很加分说明你不是只会背API而是真正理解锁竞争的来源。8.3 面试官追问“AtomicStampedReference和AtomicMarkableReference的区别”这一点最好用自己的话讲清楚。AtomicStampedReference用整数版本号可以精确感知“对象被改了多少次”适合需要严格计数或时序判断的场景。AtomicMarkableReference用布尔标记只感知“是否被修改过”适合只关心状态是否发生过变化的场景。我一般用一个生活化的例子来帮助记忆版本号像汽车的里程表每次改动里程数都会增加你可以知道开了多少公里布尔标记像汽车的电瓶指示灯只告诉你电瓶有没有异常不告诉你异常过几次。这个例子虽然不是特别严谨但面试中讲出来面试官基本能get到你已经完全理解了。9. 实战过程中的几个高频Bug与排查思路9.1 问题一CAS更新成功后其他线程读到旧值有一次我们遇到一个诡异的现象线程A通过CAS把订单状态改成了“已支付”线程B马上读取居然还是“未支付”。排查后发现问题出在“可见性”上。CAS本身是能看到最新值的但如果变量没有用volatile声明JVM可能允许线程B在自己的工作内存里缓存这个变量的旧值读取时没有重新从主内存拉取。AtomicInteger内部的value是volatile所以没问题。问题出在我们自己写的类里字段没有加volatile又直接用了Unsafe的CAS操作结果该线程一直读自己的工作内存缓存。重要经验如果你要用AtomicReferenceFieldUpdater或者Unsafe直接做CAS目标字段必须加volatile修饰。CAS负责原子性volatile负责可见性两者结合才是完整方案。9.2 问题二CAS里套着业务逻辑导致重试副作用我在前面也提到过在执行updateAndGet或accumulateAndGet时传入的lambda在CAS失败后会被反复执行。如果在lambda里做了非幂等的操作比如发送短信、扣减库存、记录日志那么一次“最终成功”的操作可能对应多次“中间失败”时的副作用执行。解决方式lambda只做纯粹的计算不要在lambda内部写外部系统调用如果需要根据最新值决定下一步动作先把最新值拿出来算好再调用compareAndSet。我自己的习惯是CAS里的所有操作都要幂等。不管是自旋锁、状态机还是计数累加都默认“这段代码会被执行多次”从而在设计上避免副作用。9.3 问题三CAS性能测试结果不稳定跟线程数强相关做基准测试时CAS的结果波动很大。如果你发现测试数据不稳定先检查JVM是否开启了偏向锁JDK 15之前默认开启再检查测试代码是否把不相关的逻辑放在临界区里。另外测试必须确保各个线程真正并行不要因为线程数超过CPU核数导致大量上下文切换。我建议用JMH来做基础测试不要自己在main方法里用System.nanoTime简单计时。JMH会帮你处理JIT预热、伪共享、fork次数等问题数据可靠性高得多。我在实际写并发组件时从JMH测试里发现过好几次伪共享导致的性能下降问题就出在多个热点变量恰好在同一个缓存行里。9.4 伪共享CAS性能的无形杀手说到伪共享我再展开讲一下。CPU加载数据是按缓存行加载的通常一个缓存行是64字节。如果两个不同的变量在内存地址上恰好落在同一个缓存行里它们会被一并加载。当一个线程改了变量A整个缓存行失效另一个线程读变量B时虽然B没变化但缓存已经失效只能重新加载性能大打折扣。JDK 8提供了Contended注解JVM启动参数里要加-XX:-RestrictContended才能生效它在字段前后填充padding让不同线程操作的字段分到不同的缓存行里。LongAdder的Cell数组就用了这种padding思想每个Cell周围补了64字节的空白避免多个Cell共享一条缓存行。这个知识点在面试中不常考但在线上调优时非常重要。我在压测LongAdder时关闭伪共享保护和开启伪共享保护性能差距最大能到30%以上。10. 一些发自内心的实操体会10.1 别把CAS当成银弹我在项目里见过不少同学把一切并发问题都往CAS上套结果代码写出来不仅难懂而且性能并没有提升。CAS最舒服的领域是“单变量的简单更新”而不是解决所有并发问题。如果你发现业务里需要保护一个很长很复杂的临界区用锁一点都不可耻反而是合理的设计。10.2 多尝试在真实代码里替换锁我个人的学习路径是先把synchronized和ReentrantLock用熟然后再逐个把简单的“读改写”场景改造成CAS版本。改造的过程中你会发现很多平时注意不到的细节比如volatile的可见性、对象内存布局、缓存行的影响、重试次数的选参。这些经验靠背面试题是拿不下来的。10.3 面试里最值钱的不是背出答案而是踩坑细节当面试官问“ABA问题怎么解决”如果只回答“用AtomicStampedReference”那只是一个知识点如果能讲出“在无锁栈里ABA会破坏栈结构我实际遇到过这样一次线上故障排查过程是xxx最终加了版本号才解决”那就是一个能打动面试官的真实案例。这也是我写这篇文章特别想强调的核心观点并发编程不是背API而是理解底层原理后在真实场景里做出取舍的能力。以后再看AtomicInteger源码希望你不再只看到那几行简单的do-while循环而是一条从CPU缓存一致性协议、汇编指令到JVM内存模型、再到并发数据结构设计的长链路。真正能把这个链路走通的人就能在并发编程这件事上建立自己的护城河。
返回列表