[面试题] CAS的执行原理是什么?它有什么问题?什么情况下适合使用?
📖 版块 A:知识讲解版 (温故知新)
-
CAS (Compare And Swap) 定义:CAS 是乐观锁思想在计算机科学中的一种具体实现机制,全称是“比较并交换”。它包含三个核心操作数:内存地址 V、预期原值 E 和拟写入的新值 N。它的核心逻辑是:如果内存地址 V 当前的值等于预期原值 E,就将 V 的值更新为 N,否则什么都不做。
-
执行原理(底层揭秘):
- 硬件级支持:CAS 操作之所以能够保证“比较和更新”这两个步骤不被其他线程打断,是因为它在底层是由 CPU 硬件直接提供的一条原子指令来保证的。比如在 x86 架构下,对应的是
lock cmpxchg指令。 - Java 层的实现:Java 代码主要通过
sun.misc.Unsafe类提供的compareAndSwapInt等 native 方法,向下调用 C++ 写的虚拟机底层代码,最终映射为 CPU 上的原子指令。
- 硬件级支持:CAS 操作之所以能够保证“比较和更新”这两个步骤不被其他线程打断,是因为它在底层是由 CPU 硬件直接提供的一条原子指令来保证的。比如在 x86 架构下,对应的是
-
CAS 面临的三大问题:
- ABA 问题:假如一个变量的值从 A 变成了 B,然后又变回了 A,此时另一个做 CAS 操作的线程去检查时会发现它的值还是 A,从而误以为它没有被修改过,CAS 操作就会成功执行。
- 循环时间长、CPU 开销大:CAS 一般配合死循环(自旋)来使用。如果并发竞争非常激烈,CAS 会频繁失败并不断重试,这会让 CPU 一直在空转,极大地拉高 CPU 的负载。
- 只能保证一个共享变量的原子操作:CAS 只能针对一个内存地址进行原子替换,无法直接保证跨多个变量(或多个步骤)的复合操作的原子性。
-
适用场景:
- 读多写少、写冲突较少:低竞争环境下,CAS 不需要将线程挂起和唤醒,避免了用户态与内核态的切换,性能远高于
synchronized。 - 原子类操作与非阻塞数据结构:例如
AtomicInteger进行计数,或者ConcurrentLinkedQueue进行无锁并发入队/出队。
- 读多写少、写冲突较少:低竞争环境下,CAS 不需要将线程挂起和唤醒,避免了用户态与内核态的切换,性能远高于
🎤 版块 B:面试实战版 (高手逐字稿)
Q:CAS 的执行原理是什么?它有什么问题?什么情况下适合使用?
💬 高手逐字稿:
CAS,也就是 Compare And Swap,它是 Java 无锁并发编程的核心,本质上是乐观锁思想的一种落地实现。
关于它的执行原理,可以从 Java 层和系统层两个维度来看。在 Java 层,我们是通过 Unsafe 类的 native 方法发起的调用;而到了最底层,其实是依赖了 CPU 硬件提供的一条原子指令,比如 x86 架构下的 cmpxchg 指令。在多核环境下,这条指令执行期间会锁住总线或者通过缓存一致性协议,来确保“比较并替换”这整个动作的绝对原子性,中间绝不会被其他线程的上下文切换给打断。
虽然 CAS 避免了传统重量级锁带来的线程挂起和内核态切换开销,但在实际应用中它也存在三个典型的痛点:
第一是著名的 ABA 问题。如果一个值从 A 改成了 B,紧接着又改回了 A,那么 CAS 在比对时就会误认为这个值从未被修改过。对于某些像栈弹出压入这种对修改过程敏感的业务,这会导致致命的逻辑错误。我们的解决思路也很成熟,就是引入一个递增的版本号或者时间戳,在 Java 里可以直接使用 AtomicStampedReference 来解决。
第二是自旋导致的 CPU 开销问题。我们用 CAS 通常会包裹在一个 while 死循环里。如果并发冲突非常严重,线程就会不断地自旋重试,这会极大地白白消耗 CPU 资源。针对这个瓶颈,我们在高并发计数场景下,会选择使用 JDK 8 引入的 LongAdder。它的思想是通过内部维护一个 Cell 数组进行分段累加,将单个热点变量的 CAS 竞争分散到多个变量上,典型的“空间换时间”的优化思路。
第三点是它只能保证单一变量的原子性。如果业务要求同时更新两个以上的变量,普通的 CAS 就无能为力了。这种时候,我们通常会把多个变量封装进一个实体对象中,然后利用 AtomicReference 对整个对象进行 CAS 替换。
最后,我认为在技术选型上,CAS 最适合用在竞争不算太激烈,并且业务逻辑极短、操作粒度极小的场景中(比如做个计数器,或者改变一个状态标志位)。在这种轻量级场景下,它的性能是吊打悲观锁的。但如果业务代码又长又复杂,或者并发写极其惨烈,强行用 CAS 反而会引发 CPU 飙升的灾难,老老实实加锁反而是更稳妥的选择。