ARTICLE DETAIL

资讯详情

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

Java并发编程核心:CAS原理、应用场景与ABA问题全解析

Java并发编程核心:CAS原理、应用场景与ABA问题全解析 1. 从一次面试说起CAS为什么会是并发编程的必考题做Java开发的人应该都有体会面试官只要聊到并发编程CAS几乎是绕不开的坎。我自己面试别人的时候也喜欢拿CAS当突破口先问synchronized和Lock的区别再问CAS是什么然后顺着往下追问AtomicInteger底层实现、ABA问题怎么解决。几个问题下来基本上就能摸清候选人对并发编程的理解是停留在API调用层面还是真的把底层机制吃透了。反过来作为求职者如果你能把CAS这条线完整串起来从原理讲到应用再讲到缺陷面试官对你的评价通常会高一个档次。这篇内容不是教科书式的原理罗列而是我从面试者和面试官双重角度总结的一份完整笔记。你会看到CAS是怎么从CPU指令一层层走到Java代码里来的它在JDK的哪些地方发光发热它有哪些天生的缺陷以及那个经典的ABA问题到底是怎么回事、业界是怎么解决的。无论你是准备Java面试的求职者还是工作中想深入理解并发底层的开发者这份内容都可以直接参考。先说一个容易被忽视的事实Concurrent包后面的很多高级工具像ReentrantLock、ConcurrentHashMap、ThreadPoolExecutor它们的线程安全机制追根溯源最后都会落到CAS上。所以理解了CAS你在并发这条路上看很多源码都会顺很多。2. CAS核心原理从三条Java指令到一条CPU指令2.1 一个最朴素的多线程问题先看一个最经典的场景两个线程同时对某个变量i执行i操作。很多人第一次学习多线程的时候都被这个例子坑过。i看起来是Java里最简单的一条语句但它在汇编层面其实分三步从内存把i读出来、在寄存器里加1、把结果写回内存。两个线程同时执行就可能出现写覆盖。比如i初始是5线程A读到了5线程B也读到了5两个人都加了1最后写回内存的可能是6而不是7。传统解法是给这个操作加锁保证同一时刻只有一个线程能执行。锁的缺点很明显线程B明明只是等着做一个加法却要被阻塞、唤醒这个挂起和恢复的过程是要系统调用、要内核态和用户态切换的开销很大。在高并发低竞争的场景下这种代价有点不值得。那有没有一种办法不加锁也能保证i的原子性CAS就是干这个事的。2.2 比对再交换CAS的两步走CAS的全称是Compare And Swap直译过来就是“比较并交换”。它的核心逻辑用中文描述非常简单读取变量当前值记为旧值expected计算要更新的新值newValue准备写回时再次检查内存中变量的当前值如果它还等于expected说明这段时间没人改过它就把newValue写进去如果当前值不等于expected说明别人改过了本次更新失败什么都不做。注意这里“再次检查并写回”在Java代码层面看是两步但在CPU层面其实是一个不可分割的原子操作。硬件为了支持这种高并发场景下的原子更新专门提供了对应的指令。x86架构下是CMPXCHG系列指令ARM架构下有对应的LDREX/STREX指令对。你在Java里写的AtomicInteger最终就是靠这些指令完成原子性的。你可以这么理解CAS就像是一个“先比暗号再进门”的机制。你在进门前告诉门卫一个暗号门卫必须确认当前的状态和暗号一致才放你进如果状态已经被改动过暗号就对不上了门就不会开。整个过程是在一瞬间完成的中间不会有人能插队。2.3 CAS不是万能的为什么它不能解决所有原子性问题CAS能保证单个变量的“读-改-写”是原子的但它有一个重要的前提这个变量必须是支持CAS操作的。具体到Java里就是必须是volatile修饰的变量或者说是内存中能够被CAS指令直接操作的地址。为什么因为CAS比对的是当前内存中的值如果这个变量没有volatile的可见性保证线程在工作内存中读到的值和主内存不一致那CAS的比对就没有意义了。还有一个更容易忽略的问题CAS只能保证一个变量的原子性。如果业务逻辑需要同时更新两个变量并且要求这两个变量要么同时成功、要么同时失败那单用CAS就搞不定了。这时候要么退回去用锁要么用AtomicReference把两个变量包成一个对象再CAS要么用Java 8之后提供的AtomicIntegerFieldUpdater之类的工具做字段级原子更新。这些都是应用层面的取舍。3. CAS在JDK中的四大核心应用场景3.1 AtomicInteger最直观的样本先看一段最常用的代码AtomicInteger count new AtomicInteger(0); count.incrementAndGet();这个incrementAndGet底层走的就是CAS的自旋循环。简化后的逻辑是这样的先读取当前值expected然后计算expected1得到next再调用compareAndSet(expected, next)。如果这个方法返回false说明在计算期间有其他线程改了值于是重新读取、重新计算、再尝试直到成功为止。这个自旋的过程通常不是死循环而是配合VarHandle或者内部的Unsafe.compareAndSwapInt实现的。JUC包里大量组件都是这个套路。理解了这个基础后面看LongAdder、AtomicStampedReference的时候就有递进感了。3.2 synchronized的优化锁升级背后的无名英雄很多人以为synchronized就是粗暴的互斥锁其实在JDK 6以后synchronized走的是锁升级路线偏向锁、轻量级锁、重量级锁。偏向锁解决的场景是“同一个线程反复进入同步块”这种情况下几乎没有竞争锁会偏向第一个获取它的线程。一旦出现第二个线程竞争偏向锁撤销后升级为轻量级锁。轻量级锁就是用CAS来抢锁的线程尝试把对象头中的Mark Word通过CAS替换成自己栈帧中的锁记录地址替换成功即获得了锁。只有CAS失败次数多了其他线程确实在竞争才会升级成重量级锁走操作系统层面的互斥量。所以你会发现synchronized性能差这个说法在低竞争场景下已经不成立了。大部分时候根本到不了重量级锁那一层多个线程用CAS自旋一会儿就能拿到锁开销比线程挂起唤醒小一个量级。3.3 AbstractQueuedSynchronizer整个JUC大厦的地基AQSAbstractQueuedSynchronizer是Java并发框架的核心组件ReentrantLock、Semaphore、CountDownLatch、ReadWriteLock全部建立在它之上。AQS内部维护了一个volatile的state变量这个state的含义由子类自己定义。ReentrantLock里它表示锁的重入次数Semaphore里它表示剩余许可数量。获取锁的时候线程会通过CAS尝试把state从0改成1。如果改成功了说明抢到了锁。如果改失败当前线程就会被封装成一个Node节点扔进AQS的CLH队列里等着。锁释放的时候头节点会唤醒后继节点被唤醒的线程再次尝试CAS抢锁。AQS这个设计的妙处在于竞争不激烈的时候走的是无锁CAS只有真正抢不到锁的线程才被挂起。竞争程度和开销成正比不会像老式锁那样不管有没有竞争都先让你阻塞。3.4 ConcurrentHashMap并发容器中的CAS应用细节ConcurrentHashMap在JDK 8开始放弃了分段锁的设计转而使用CAS加synchronized的组合。初始化数组、统计元素个数、扩容时转移元素这些高频操作大量依赖CAS。比如往数组里放元素时如果对应桶位是空的就直接用CAS把新节点写入这个位置成功则返回。如果桶位不为空再针对这个桶的头节点加锁。这样设计的好处是不同的桶位互不干扰多个线程可以同时在不同桶写入锁粒度细化到了单个桶的级别并发能力比JDK 7的分段锁又上了一个台阶。从这些应用场景能看出一个规律CAS擅长处理低冲突度的场景。竞争越激烈CAS自旋重试的次数越多它的优势就越不明显。这个规律在下一节讲优缺点的时候会反复出现。4. CAS的优点与隐性问题不能只知道无锁4.1 先说优点CAS的不加锁特性带来了几个非常实际的收益第一是无阻塞。线程发现CAS失败后不会被挂起而是在用户态继续自旋重试次数完全由自己控制。避免了线程阻塞、唤醒时的内核态用户态切换这种切换的成本在JDK里其实是很高的涉及系统调用、线程调度、上下文切换多个环节。所以低竞争场景下CAS比锁快很多。第二是无死锁风险。死锁的本质是锁的获取顺序环环相扣CAS没有锁的概念自然也就不存在持锁等待、循环等待这类问题。在复杂并发逻辑里这能省去很多排查死锁的功夫。第三是天然具备乐观锁思维。它对数据会被冲突持乐观态度默认冲突不常发生出了问题再重试。这跟很多业务系统的乐观锁设计思路完全一致数据库的版本号更新机制本质上也是CAS思想的应用。4.2 再说三个容易踩的坑坑一自旋会浪费CPU。CAS失败后如果立刻重试在高竞争场景下会非常消耗CPU资源。想象一下100个线程同时抢同一个变量最终只有一个能成功其余99个全在疯狂自旋CPU空转率飙升。实际开发中一般会加入退避策略或者限制重试次数实在不行就退回锁或者分段。坑二只能保证单个变量的原子性。前面已经提到过CAS操作的对象是内存中的一个地址无法同时原子操作多个变量。如果你有两个变量需要一致性更新用CAS需要把这两个变量封装到一个对象里然后对这个对象做CAS。这个封装-拆开的过程本身就会引入额外开销。坑三只能保证可见性不能保证有序性。这里要非常谨慎。CAS本质上只保证了被操作变量的原子性它不像synchronized那样在进入和退出同步块的时候自动建立内存屏障保证同步块内代码的有序性。所以当你看到有人在非volatile变量上使用Unsafe类的CAS方法时要注意对象发布的安全性问题不要以为CAS就自动解决了所有并发问题。5. 优缺点对照速查为了方便面试和日常对比我把CAS和锁的关键差异整理成了一个表对比维度CAS传统互斥锁核心机制CPU指令级原子操作操作系统互斥量/信号量竞争处理自旋重试线程阻塞挂起阻塞特性非阻塞阻塞死锁风险无锁无持锁等待可能产生死锁适用场景低冲突、多读少写高冲突、临界区执行时间长单变量原子性支持不关心靠临界区包住多变量原子性不支持需要封装或加锁支持只要包进临界区CPU占用冲突高时自旋空转阻塞时让出CPU缓存一致性开销频繁缓存同步释放锁时同步一次这个表的重点在于最后两行的对比。CAS在冲突高的时候CPU占用不降反升而锁在冲突高的时候反而让线程让出CPU系统整体还能保持响应。这也是为什么有些场景看似是并发量高要用锁实际性能反而不如直接synchronized的原因。6. ABA问题CAS原理中最出名的坑6.1 什么是ABA问题CAS的逻辑是“如果当前值等于期望值说明没被别人改过就写入新值”。这个逻辑有一个漏洞当前值等于期望值并不代表这个变量从来没被动过。它可能被改成了别的值又被改回了原来的值。假设有一个变量它的初始值是A线程T1读取到了值A准备做CAS操作线程T2抢先把A改成了B然后又改回了A线程T1执行CAS发现当前值还是A与自己读到的expected一致CAS成功。从T1的视角看整个过程风平浪静但变量实际上已经被T2篡改过一次。如果这个变量是一个普通的计数器ABA不会有任何问题反正最终结果一样。但如果这个变量关系到对象状态的一致性比如一个栈的栈顶指针ABA问题就可能导致严重的逻辑错误。6.2 经典案例用CAS实现无锁栈无锁栈Lock-Free Stack是基于CAS实现并发栈的经典方法。栈顶指针top通过CAS来更新。仔细看这个场景假设当前栈顶是节点A栈内依次是A → B → C。线程T1准备执行pop操作它读取到栈顶是A期望值是A。这时T1被切出CPU暂停线程T2执行了两次pop操作先弹出A再弹出B然后又push回A。此时栈的状态和T1临睡前一模一样栈顶还是AT1从暂停中恢复执行CAS发现栈顶确实是A于是它把栈顶更新为B。灾难发生了。B节点早就被T2弹出了它可能已经归属到其他数据结构中甚至被回收了。而T1却认为B还正常在栈里栈的内部状态完全错乱。这就是ABA问题引发事故的真实路径。在这个案例里仅仅靠值相等无法区分“从未变过”和“变了又变回原样”这两种情况。6.3 用版本号消除ABAAtomicStampedReference解决ABA问题的思路非常朴素既然无法从值上区分“没变过”和“变回原样”那就额外引入一个标记让每次修改都改变这个标记。JDK提供的方案是AtomicStampedReference。这个类内部维护了一个Pair对象里面同时保存了引用和版本号。更新操作要求引用和版本号同时匹配才生效版本号每次成功更新都会递增。AtomicStampedReferenceString ref new AtomicStampedReference(A, 0); // 线程T1保存初始版本号 int[] stampHolder new int[1]; Object current ref.get(stampHolder); int stamp stampHolder[0]; // 更新时同时校验引用和版本号 boolean success ref.compareAndSet( A, B, stamp, stamp 1 );注意compareAndSet的参数是四个期望引用、新引用、期望版本号、新版本号。四个参数全部一致才会更新成功。即使引用值回到A版本号已经变成1、2、3了和期望值0对不上CAS必然失败。这就从根本上切断了ABA的来源。6.4 另一个变种AtomicMarkableReferenceAtomicMarkableReference和AtomicStampedReference的区别在于stamp可以每次更新时递增适合记录版本变化的次数markable只用一个布尔值标记“是否被修改过”适合只需要记录“是否动过”的语义比如标记一段数据是有效的还是已删除的。对比维度AtomicStampedReferenceAtomicMarkableReference标记类型int版本号boolean标记变更方式每次更新1在true/false之间切换适用场景需要记录修改次数的业务只需要记录是否被改动更新后恢复原值版本号不会回退可防ABA标记仍保持true可防ABA典型用例无锁栈、无锁链表倒置判断、单次删除标记使用建议是大部分场景选AtomicStampedReference就对了因为版本号能提供更丰富的信息。AtomicMarkableReference更像一个节省内存的变体如果你对“修改了几次”不关心只需要知道“被改过没”用它可以稍微省一点空间。注意这里的省空间是相对于int和boolean的差异说的实际对象内部还有其他的开销不要指望它把小体量类省成几十字节。6.5 我在实际项目中的处理经验真实业务里直接触发ABA问题的高频场景我遇到过两类。第一类是缓存回填。多个线程同时判断缓存里某个key是不是null如果是null就回填数据库数据。有个线程先填了清空后又填了另一份中间过程对其他线程是透明的。这种情况我用AtomicMarkableReference来标记“当前是否有回填任务在进行中”标记为true时其他线程不再重复回填避免了数据被互相覆盖。第二类是状态机流转。订单从“待支付”到“已支付”再到“已退款”这是一个有限状态机。如果只用状态值本身做CAS“已支付”被改成“已退款”再改回“已支付”逻辑上就可能乱掉。我的做法是把状态枚举和版本号都放进AtomicStampedReference版本号递增任何回退操作都会被版本号挡住。说得更直白一点ABA问题的解决不只是找到一个类库方法更重要的是在设计并发数据时主动避免对象复用。你写的节点、实体对象如果可能被重复入栈、入队就需要在结构上多加一道保护哪怕不用版本号也可以用业务上的唯一标识来判断身份而不是只依赖值的相等性。7. 面试官视角CAS相关的连环追问与应对思路7.1 CAS和synchronized你选哪个这几乎是必问题。面试官不是在考验你背书而是在看你会不会按场景做技术选型。我的回答思路是三步。先亮结论低竞争、临界区执行时间短的场景选CAS高竞争、临界区执行时间长的场景选synchronized或Lock。再讲原因CAS的优势在于无阻塞劣势在于自旋空转。临界区短自旋次数少优势明显临界区长自旋等太久不如干脆阻塞等系统调度。最后举一个具体例子批量更新内存中的状态计数器每个操作只有几条CPU指令用AtomicLong和LongAdder都不错但如果临界区要访问数据库或者远程服务考虑用锁把阻塞时的CPU留给其他任务。7.2 CAS一定能保证线程安全吗不能。这个问题的关键不在于CAS本身而在于CAS之后的操作是否仍然是原子的。CAS只保证内存地址上的值被原子更新如果更新之后还要做其他一连串的复合操作那就不安全了。举一个典型例子对一个账户做余额减少操作数据库层可能会先查余额判断余额够不够再更新余额。这三个步骤必须放在一个带锁或者带事务的边界内不能只用CAS。你要是对余额字段做CAS只能保证余额这个字段的写操作是原子的无法保证条件判断和写操作之间的完整性。银行不会允许你在用户余额不足的情况下扣款。所以回答这个问题的完整表述是CAS能保证单个变量的读-改-写是原子的但无法保证多个变量或多步操作的整体一致性。从这个意义上说它像是一个更轻量、更底层的原子操作原语而不是一个能包办所有并发安全问题的框架。7.3 LongAdder为什么在高并发下更快这是CAS缺点的自然延伸。LongAdder把单个热点值拆成一个base变量和一个cell数组每个线程修改时先把自己的副本累加到一个cell里最终取值时再把base和所有cell加起来。这样做的结果是累加操作不再都指向同一个热点内存地址CAS的压力被分散到了不同的cell上冲突率大幅下降。用场景类比就是收费站只有一个窗口的时候所有车挤在一起拆成四个窗口排队每队长度短通行效率高。但要注意LongAdder的get()是各单元格求和并不是瞬间一致性的快照这在严格需要准确count值的时候会有取舍。这一小段回答在面试加分项里经常出现建议随手记一下。7.4 你踩过CAS相关的坑吗这句话的潜台词是检验你有没有真正在项目里用过并发组件。我会诚实分享一个经历之前写一个无锁环形队列用AtomicInteger做生产者和消费者的索引上线后发现生产极快、消费很慢时整个队列会莫名其妙地空转CPU居高不下。排查后发现是消费线程一直CAS失败空转自旋占满了CPU。这个问题的修复不是加退避就行的实际上最终方案是把自旋改成Thread.yield()配合监听的等待机制只有在队列长期空闲时让线程让出CPU。面试时能讲出这种经验的细节比背十遍八股文都管用。8. 实际开发中常见的CAS误区与排查技巧8.1 误区一无锁就一定比有锁快不是的。CAS的优势在低冲突时明显高冲突时反而因为自旋空转导致CPU利用率下降。我在压测里观察过一个现象一个使用AtomicInteger做计数器的服务在线程数从4升到32的过程中吞吐量先上升后下降。原因就是线程多了CAS冲突增多了每个线程都在反复重试白白消耗CPU。判断该不该用无锁可以关注两个指标临界区代码执行耗时和线程并发数量。临界区短、并发量可控的直接上CAS。临界区有IO操作或者并发线程数远超CPU核数的优先考虑锁。8.2 误区二CAS只要做成功就是安全的这个误区前面讲过再补充一个实际例子。一个分布式缓存组件用CAS维护缓存item的引用。有次排查线上数据不一致问题时发现item被另一个线程更新后又被更新回原来的对象结果CAS成功返回但两个线程此后各自维护了完全不同的后续状态。这就是标准的ABA问题。即使更新回来的对象与原来的对象相同equals成立后续逻辑也会因为中间发生过变更而产生不同的结果。8.3 误区三Atomic包的所有类都是无锁的AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference是纯CAS实现。但AtomicLongArray和AtomicLongFieldUpdater在底层实现上会根据平台选择性使用CAS或者锁。Striped64子类LongAdder的祖先内部虽然也是CAS但它采用了分片思想严格说不是单纯的无锁结构。了解这种差异在实际做性能调优时很有用别一概而论。8.4 排查CAS问题的通用套路如果怀疑线上并发组件出现异常我的排查顺序一般是这样第一步确认问题是不是来自系统线程调度或JVM参数。比如偏向锁的延迟开启、C2编译器的优化改动都可能影响并发路径上的行为。第二步检查被CAS的变量是否存在跨线程复用的可能。前面说过的对象复用问题是ABA问题最隐蔽的来源。重点看对象是否被池化、缓存、重试逻辑重复使用。第三步看循环中的CAS是否加了退出条件。少数框架为了追求确定性会写成while(true)的重试如果decrement之类的操作永远失败可能造成线程卡死在重试循环里。第四步如果是数值偏差问题优先检查LongAdder这类工具的快照语义是否被误用不要死盯CAS本身。9. 两种方案的实际落地从代码层面学习版本号控制空谈原理不够我把AtomicStampedReference和AtomicMarkableReference的完整使用方式写出来你可以直接拿来验证。9.1 用AtomicStampedReference实现防ABA的计数器public class SafeCounter { private AtomicStampedReferenceInteger counter new AtomicStampedReference(0, 0); public int incrementAndGet() { int[] stamp new int[1]; int current; int next; do { current counter.get(stamp); next current 1; } while (!counter.compareAndSet( current, next, stamp[0], stamp[0] 1)); return next; } public int get() { return counter.getReference(); } }注意do-while循环里的逻辑先读取当前引用值和版本号计算新值再用compareAndSet尝试更新。一次失败就重新读取当前值和最新的版本号再次尝试。整个自旋过程非常清晰地体现了版本号每成功一次就1的思想。这个模式下即使有人把计数器的值从5改成10再改回5版本号已经变了好几次后面的CAS绝对无法通过。9.2 用AtomicMarkableReference实现一次性标记public class ProcessOnce { private AtomicMarkableReferenceString marker new AtomicMarkableReference(init, false); public boolean tryProcess(String id) { boolean[] mark new boolean[1]; String current marker.get(mark); if (!mark[0] marker.compareAndSet( current, id, false, true)) { // 只有第一次调用能走到这里 return true; } return false; } }这个类的存在意义就是解决“只处理一次”的业务需求比如幂等请求拦截、分布式任务去重。由于标记是布尔值你无法知道它被翻转过几次但“是否处理过”这个语义它表达得足够清楚。9.3 Java 8之后的增强思路从JDK 8开始VarHandle逐渐取代了AtomicReference系列中底层的Unsafe调用它有一系列基于访存屏障的原子操作方法。我自己在工程上已经逐步把新代码迁移到VarHandle它带来的好处除了类型安全还能对内存屏障进行更精细的控制。如果你要面试提一句“新版JDK里原子类底层已经迁移到VarHandle”会显得知识面比大多数候选者新。10. 一个最容易被人忽略的并发一致性细节内存屏障与CAS的配合10.1 CAS和内存屏障是什么关系CAS能保证比较并交换的原子性但它无法保证在CAS之前或之后执行的其他读写操作的有序性。这是理解这方面知识不能绕过的重点。CPU和编译器出于性能优化目的会调整指令的执行顺序。在单线程中这没有影响在多线程下如果顺序被调整就可能导致A线程看到B线程尚未完成的写入。具体到synchronizedJVM在进入和退出同步块时会分别插入acquire barrier和release barrier来保证锁内代码不会被重排到锁外面。ReentrantLock内部也通过AbstractQueuedSynchronizer维护类似的语义并配合VarHandle调用访存屏障。而CAS本身更像一个原子指令它不会自动帮你包住前后代码的有序性。所以在设计无锁数据结构时要自己操心release和acquire的语义。Java中的AtomicReference系列一般自带比较强的一致性保证但如果你直接操作Unsafe或VarHandle的原语就有必要明确自己需要什么级别的屏障。在需要多线程读写共享状态、但没有强同步块的场景里这段理解会决定你是写出一个性能优异的无锁队列还是写出一个偶发性bug。10.2 内存屏障会带来什么代价内存屏障并不是免费的。它会阻止CPU的指令重排和部分缓存优化所以在并发数据结构的临界路径上过多屏障会抵消无锁带来的性能优势。这也是为什么有时你看到JDK内部对同一个场景会提供多种实现比如VarHandle的getVolatile、getAcquire、getOpaque选择不同语义的接口本质上就是在性能与一致性之间取舍。这一部分内容不会出现在大多数面试问题里但在真正的无锁编程实战中它是决定代码能不能扛住压测的隐蔽因素。对大多数Java面试者来说你至少要能说出“volatile提供可见性但CAS还需要配合内存屏障才能实现完整的顺序一致性”这就能和一些只会背结论的人区分开了。11. 面试之外的延伸无锁数据结构的设计思路理解了CAS的边界你就能看懂很多无锁数据结构的共性设计了。它们通常遵循两个原则。原则一每次只做一个原子操作。既然CAS只能单变量那就尽量把一次“状态转移”压缩到对一个变量、一个引用、或一个版本号的更新上。栈顶指针、队列头尾索引、链表节点引用是常见的操作对象。原则二允许短暂的不一致最终收敛。无锁算法通常会容忍并发过程中的中间态只要最终状态正确即可。比如Michael-Scott队列的tail节点在出队操作进行到一半时tail可能还没指向最新的末尾但最终会通过CAS收敛。这两个原则和数据库里的“乐观锁、最终一致性”思想是一脉相承的。能理解到这一层之后再看到Java里的ConcurrentLinkedQueue、Exchanger、ForkJoinPool里的无锁队列读源码的体感会完全不同。12. 一把梭之外的现实残酷面何时该弃用无锁我在开头说过CAS擅长低冲突的场景这里把话说得更直白一点。如果临界区本来就只是几条CPU指令无锁方案在性能上确实是无敌的。一旦临界区开始做IO、数据库操作、远程调用甚至只是比较重的对象创建无锁方案就丧失了意义。因为CAS失败后马上重试重试的时候你又重新读数据、重新算逻辑可能还要重新创建对象这些成本叠加起来可能比锁的阻塞唤醒成本还高。所以我的建议是无锁不是银弹它是一把更细的螺丝刀能用在对的地方。遇到高冲突长临界区的场景老老实实选synchronized或者ReentrantLock然后用并发压测验证选择不要凭感觉定方案。13. 最后聊一点个人体会写并发代码这几年我最大的感悟是面试里的八股只是起点。你以为理解了CAS就能写好无锁代码但真正到了高并发压测环境你才会发现缓存行、伪共享、内存屏障、偏向锁延迟这些比CAS本身更难缠的问题。它们环环相扣任何一环理解不到位线上就会出现那种“跑十分钟崩溃一次”的幽灵bug。所以如果你在准备Java面试别只盯着“CAS是什么”“ABA是什么”这种口号式问答多花点时间弄清楚“为什么CPU需要CMPXCHG指令”“为什么无锁队列的tail会有中间状态”“为什么版本号能挡住ABA”这些更深一层的原因。把这些逻辑链打通面试官问起CAS相关的任何变种问题你都不会慌。我自己的一个心得是阅读LongAdder和ConcurrentLinkedQueue的源码比背一百道面试题都有用。你会发现原来高并发设计并不是魔法它就是你理解的这些原语按照一定的约束组合起来。无锁思想就是这一整套组合的底层语法而CAS是语法里最基础的一个单词。祝你面试顺利也祝你写出的并发模块稳定二字能多扛几年。
返回列表