第043篇 CAS 与原子类:无锁编程的第一课

简介: CAS是Compare-And-Swap的缩写,一种硬件级原子指令,实现“比较并交换”的乐观并发控制。它通过volatile保证可见性、CAS保证原子性,但存在ABA问题(用AtomicStampedReference加版本号解决)、高竞争下自旋耗CPU(LongAdder分段累加优化)及仅支持单变量更新等局限。

CAS 这题的分水岭在于:背过"比较并交换"的人很多,能把 ABA 问题讲清楚、知道 AtomicStampedReference 怎么解决它、能说准 LongAdder 相比 AtomicLong 凭什么在写密集场景更快的人很少。这三层恰好是面试官往下挖的方向,答到哪一层基本就定了档位。

先把结论放在前面:CAS 的全称是 Compare-And-Swap,一条原子指令,语义是"如果内存位置 V 的值仍然等于期望值 A,就把它改成 B,并返回成功;否则只返回失败、不做任何修改"。它是一条自旋重试的原子操作——失败不是阻塞,而是重新读、重新算、重新比。 原子类正是基于它实现:AtomicInteger 内部就是一个 volatile int value 加一组 compareAndSet 方法,靠 volatile 保证可见性、靠 CAS 保证原子性。

机制拆解

讲清为什么不能直接用锁。锁的思路是"互斥访问",代价是线程阻塞与上下文切换;CAS 的思路是"乐观并发"——先假设没人改,干完再检查有没有被改,有就重算。两者各有适用面:临界区短、竞争低时 CAS 明显更快;临界区长、竞争激烈时自旋会烧 CPU 反而更慢,此时锁更合适。 另外 CAS 天然只适用于单变量更新——i++ 可以拆成"读—改—写"再对写操作做 CAS,但 i 与 j 要一起改时,中间状态无法被一次 CAS 覆盖,这就是复合操作的困境。

这些坑的正确绕法

最常见的坑是 CAS 自旋在高竞争下大量空转,CPU 烧满吞吐反降。 CAS 失败后立刻重试,如果锁竞争激烈,线程会陷入"读—比—失败—再读"的循环,CPU 全耗在内存读写上。识别特征是 CPU 使用率异常高但吞吐量上不去,trace 里线程状态为 RUNNABLE 却无实际业务进展。 解决方向有两个:降低竞争(把共享变量分片,比如用 LongAdder 替代 AtomicLong),或改用锁(在临界区较长时)。

其次是拿 AtomicReference 做多字段一致性更新,忘了 ABA 或拆分更新导致状态撕裂。 两个问题要分开看。ABA 是指值经历了"A→B→A"的往返,CAS 只看值不看历史,于是误认为没变;对基本类型影响有限(值确实一样),但对引用类型会出错(对象内部可能已改变)。解法是加版本戳,即 AtomicStampedReference 维护一个引用加一个整型版本。 多字段更新则是另一回事:把对象整体放进 AtomicReference 并替换整个对象(不可变对象 + 整体替换)才是正确做法,逐字段 set 无法保证一致性。

还有一个更隐蔽的坑:把原子类当成"所有并发问题"的解法。 原子类只保证单变量的单次读写原子,AtomicInteger 里的 incrementAndGet 是原子的,但"取出值 → 判断 → 写回"这段仍然是复合操作,多线程下照样出错。判断标准很简单:如果逻辑里有"检查后动作",就需要锁;如果只是"读出来加一写回去",原子类就够。

给 CAS 与原子类建一份边界清单,注释里写清楚,单测覆盖边界与异常路径。 落地成几条:单个计数用原子类,多字段一致用不可变对象整体替换或加锁;写密集场景优先 LongAdder,读密集场景 AtomicLong 更省内存;重试型 CAS 循环要有退出条件,不做无界自旋。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// CAS 与原子类
AtomicInteger ai = new AtomicInteger();       // volatile int + CAS
ai.incrementAndGet();                          // 自旋 CAS 实现的原子自增

// 自旋重试模板:只能用于单变量的读-改-写
int prev, next;
do {
   
    prev = ai.get();
    next = prev + 1;
} while (!ai.compareAndSet(prev, next));       // 失败则重算重试

// 写密集用 LongAdder(空间换时间,Cell 分散热点)
LongAdder adder = new LongAdder();
adder.increment();
long sum = adder.sum();                        // 读时汇总

这段代码值得盯两处:第一处,自旋重试模板只适用于单变量,遇到"要同时看两个变量"的逻辑就必须改用锁;第二处,LongAdder 的思路是分散热点——每个线程往自己的 Cell 里加,读时再汇总,牺牲读的即时性换取写的吞吐。面试讲到这一层,基本就稳了。

这题在面试里怎么问、怎么答

"请简单介绍一下 CAS 与原子类,它在 Android 开发中起什么作用?"一句话定位:CAS 是硬件级的原子指令,原子类基于它实现无锁并发更新。再补一个真实场景——统计点击次数、给消息加序号、维护轻量的运行期开关状态。落一个细节:AtomicInteger 与 synchronized 的取舍看临界区长度与竞争强度。

"CAS 与原子类的底层原理是什么?能不能详细说一下?"从 CAS 语义讲起:一条读-比较-写指令,中途不会被其他线程打断。再讲三个经典问题:ABA(用 AtomicStampedReference 加版本号解决)、自旋开销(高竞争下改用锁或分片)、只适用单变量(复合操作必须用锁)。最后补 LongAdder 的 Cell 机制,说明它是"用空间换时间、把单点热点打散"的典型。 讲完这四块,这题基本可以给高分。

"在使用 CAS 与原子类时遇到过什么问题?"按现场、排查、根因、预防四段讲。一个典型案例:某个全局计数器在多线程写入时 CPU 使用率异常升高,吞吐却没有提升;定位到是高竞争下 CAS 自旋空转;修复为改成 LongAdder 并减少无谓的读-改-写;验证是 CPU 明显下降、吞吐反而上升。

"和相关的替代方案相比,有什么优劣?"对比 synchronized、ReentrantLock、无锁结构三条线。结论落在场景:单变量低竞争用原子类(无阻塞、无上下文切换);临界区长或竞争激烈用锁;读多写少用 AtomicLong,写密集用 LongAdder。选型时把"自旋空转烧 CPU"这类代价摆到台面上,再决定是否引入。

再补一个工程上值得讲清的点:为什么 LongAdder 要在读的时候做汇总,这正是它的取舍。AtomicLong 的每一次读都要读同一个热点变量,读写都压在同一个缓存行上,高并发下会触发缓存行的争抢。 LongAdder 的做法是维护一个 Cell 数组,每个线程(或 CPU 核)写的时候只动自己那个 Cell,读的时候把所有 Cell 加起来——写被分散了,读的成本上升但读通常不频繁,整体吞吐因此改善。 这个设计还有两个细节:Cell 数组在竞争激烈时会自动扩容(按线程探针哈希映射到不同槽),而 sum() 不是快照,中途可能有线程在写,所以它只适合统计类场景、不适合做精确的同步判断。面试里能讲出"分散热点 + 读时汇总 + 非快照读"这三点,说明不是只背过 API。

再补一个容易被问到的高频陷阱:AtomicInteger 的 getAndIncrement 与 incrementAndGet 差别在哪。前者返回自增前的旧值,后者返回自增后的新值。业务含义完全不同——做"先取值再累加剩余额度"要用前者,做"发放一张并拿到新余额"要用后者。写错一个方法名,在并发下就可能超发或少发,而这种错误在低并发测试里往往复现不了。 面试里被追问"这两个方法有什么区别"时,能答清返回值语义并给出业务例子,说明是写过的而不是背的。

给正在准备面试的你

把 CAS 画成一张流程图:读当前值 → 与期望值比对 → 相等则写入并返回成功 → 不等则回到第一步重来,并在旁边标出 ABA 与自旋两个问题的位置。面试中关于 CAS 的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。

再补工程案例与踩坑——应用落点是把项目里所有 AtomicLong 换成 LongAdder 做对照压测,分别在低并发、高并发、读多写少三种场景下记录吞吐与延迟差异。

复习时别孤立刷题:volatile——原子类靠 volatile 保证可见性、靠 CAS 保证原子性,两者分工互补。

划两句重点:CAS 失败是自旋重试而非阻塞,高竞争下会烧 CPU;ABA 用版本戳解决,多字段一致用不可变对象整体替换,"检查后动作"必须用锁。

下一篇聊 ThreadLocal 原理:主线程到底能不能用——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:CountDownLatch-与-CyclicBarrier:等待与协作

下一篇预告:ThreadLocal-原理:主线程到底能不能用

有任何问题欢迎在评论区留言交流。

相关文章
|
13小时前
|
缓存 算法 Java
第047篇 垃圾回收基础:可达性分析与 GC Roots
本文深入剖析JVM垃圾回收核心——可达性分析机制,厘清GC Roots四类(栈变量、静态字段、JNI引用、活跃线程)作为泄漏排查入口;对比引用计数缺陷,详解强/软/弱/虚四级引用的本质差异在于“回收时机”;直击Android开发中静态缓存、Context泄漏等高频坑点,并给出支配树定位、显式限容、弱引用替代等工程化解决方案。
13 0
|
16小时前
|
Java 编译器 API
第008篇 面向对象三大特性:封装、继承、多态怎么讲才透
本文深度解析面向对象三大特性(封装、继承、多态)的面试核心:不止于定义,重在讲清“为何设计、有何代价、如何用对”。结合Java机制、代码实操与典型误区,助你构建扎实认知网,从容应对层层追问。
15 0
|
14小时前
|
缓存 Java API
第037篇 动态代理:AOP 与框架的基石
动态代理本质是运行期生成代理类,统一转发调用至InvocationHandler。JDK代理限于接口,CGLIB通过子类代理非final类。核心难点在于:代理对象与目标实例不同、方法体动态生成、this自调用绕过代理——这三点正是区分“读文档”与“写框架”的关键试金石。
17 0
|
13小时前
|
安全 Java 编译器
第068篇 sealed class:状态建模的利器
`sealed class` 是 Kotlin 状态建模的核心工具,本质是“用类型系统表达封闭状态机”。它强制子类同文件/模块定义,使编译器可穷举所有分支,配合 `when` 实现**无 `else` 的完备匹配与类型安全**,彻底杜绝非法状态(如 `loading && data != null`),远超枚举与多字段布尔组合的表达力。
19 0
|
13小时前
|
监控 算法 Java
第048篇 常见垃圾回收器:从 CMS 到 G1 的演进思路
本文深入剖析JVM垃圾回收器的演进本质——并非技术堆砌,而是“吞吐↔停顿”的持续取舍:Serial(小堆低开销)、Parallel(高吞吐/长停顿)、CMS(并发降停顿但碎片/弃用)、G1(Region收益优先/可控停顿)、ZGC/Shenandoah(亚毫秒停顿/吞吐略损)。强调选型需匹配堆规模与业务指标,核心在理解分配速率、存活率与停顿目标的联动关系。
23 0
|
14小时前
|
缓存 安全 Java
第031篇 ConcurrentHashMap:从分段锁到 CAS 加 synchronized
ConcurrentHashMap(JDK8)通过CAS初始化空桶、桶头节点加synchronized锁实现细粒度并发写,get无锁依赖volatile可见性;size采用baseCount+CounterCell分片计数;禁止null键值,复合操作须用compute/merge等原子方法——兼顾高性能与线程安全。
16 0
|
13小时前
|
安全 JavaScript Java
第063篇 类型推断与基本类型:Kotlin 没有隐式拓宽
Kotlin 用“可空性”统一基本/包装类型:`Int`编译为JVM基本类型,`Int?`才装箱为`Integer`;`==`恒为值比较,算术不隐式提升,需显式`toLong()`等转换;泛型容器仍会装箱。核心是分清语言抽象与JVM现实。
21 0
|
14小时前
|
安全 Java API
第027篇 ArrayList 源码与扩容机制:1.5 倍增长的细节
ArrayList面试高频题:JDK8中无参构造不分配数组,首次add才扩容至10;后续按1.5倍增长,不足时直取所需容量,上限为Integer.MAX_VALUE-8。扩容本质是Arrays.copyOf整段拷贝。关键要懂原理、避坑(如预估容量、非线程安全、subList陷阱)并结合工程实践。
19 0
|
13小时前
|
缓存 Java 数据库
第078篇 Sequence 与惰性求值:大数据量集合优化
`Sequence` 的核心是**惰性求值**(操作延迟至终端才执行)与**冷流语义**(每次遍历都重新计算,不缓存)。它省内存(无中间集合),但不支持多次遍历、随机访问;适用于大集合多级过滤或无限序列截断(如 `take`)。慎用于副作用操作、未截断的无限流及需重复消费场景——应物化(`toList()`)或内联使用。
28 0
|
15小时前
|
安全 Java 编译器
第024篇 泛型基础:类型擦除到底擦了什么
本文深入剖析Java泛型本质:以“问题—擦除—运行时残留—错误表现”四步公式讲清原理;透彻解析类型擦除机制、桥接方法及常见坑(raw type、new T[]、instanceof泛型);结合可运行代码与Android实战场景,助你面试答出“为什么”,而非仅“怎么写”。
19 0