1. 从锁到无锁:为什么我们需要CAS
在Java多线程编程里,一提到线程安全,很多人的第一反应就是synchronized关键字或者ReentrantLock。没错,这些锁机制是构建并发安全大厦的基石,它们通过“互斥”来保证同一时刻只有一个线程能访问共享资源。但用久了你会发现,锁这东西,好用是好用,但“重”也是真的重。每次线程进入同步块,都涉及到操作系统的内核态切换、线程的挂起与唤醒,这个开销在低竞争场景下显得尤为奢侈。更头疼的是,一旦设计不当,锁还容易带来死锁、活锁、饥饿等一系列问题。
于是,追求性能极致的开发者们开始思考:有没有一种更轻量级的线程安全方案?能不能在不加锁的情况下,安全地更新一个共享变量?这个问题的答案,就是CAS。CAS,全称Compare-And-Swap,翻译过来就是“比较并交换”。它不是Java独有的概念,而是一种被现代CPU广泛支持的原子指令。它的核心思想极其朴素:我认为变量V现在的值应该是A,如果是,那我就把它改成B;如果不是,说明在我读取之后、准备修改之前,已经有其他线程改动了它,那我就放弃本次修改,通常选择重试。
你可以把它想象成一个乐观的版本控制。synchronized是悲观的,它默认每次都会有人来抢,所以先上锁把门关死。而CAS是乐观的,它默认这次不会有人跟我冲突,我先去改,改的时候看一眼版本号(也就是旧值)对不对,对就提交,不对就拉取最新代码再试一次。这种“无锁”的思路,在高并发、低冲突的场景下,性能优势是碾压性的。Java从1.5版本开始引入的java.util.concurrent.atomic包,其核心正是建立在CAS机制之上,为我们提供了AtomicInteger、AtomicReference等强大的原子类工具。理解CAS,不仅是理解这些原子类的基石,更是深入理解ConcurrentHashMap、AQS等高级并发框架的必经之路。
2. CAS机制的核心原理与硬件支持
2.1 CAS操作的三元组:V,A,B
要理解CAS,必须吃透它的三个核心操作数:
- V(内存位置):需要读写的共享变量的内存地址。比如一个
AtomicInteger对象内部存储value的地址。 - A(预期原值):执行操作前,线程认为的变量V当前应该具有的值。这个值通常是线程之前从V中读取出来的。
- B(新值):线程希望将变量V更新成的值。
CAS指令的执行逻辑是一个不可分割的原子操作:“如果内存位置V的值等于预期原值A,那么处理器会自动将该位置的值更新为新值B,否则不做任何操作。无论是否更新,处理器都会返回该内存位置的旧值。”
整个比较和交换的过程,在CPU层面是一条指令完成的,中间不会被其他线程打断。这是CAS能够实现无锁线程安全的最根本保障。在Java中,这个能力通过Unsafe类提供的本地方法(Native Method)暴露出来,例如compareAndSwapInt、compareAndSwapLong等。
2.2 硬件基石:CPU如何实现原子CAS
你可能会有疑问,为什么一条指令就能保证原子性?这依赖于现代CPU提供的特殊硬件指令。最常见的是CMPXCHG指令(Compare and Exchange),在x86架构中,这条指令在执行前会锁住前端总线(或者缓存行),从而确保在执行期间,其他处理器无法访问该内存地址,实现了原子性。
这里有一个关键点:CAS的原子性是由CPU硬件指令保证的,而不是软件逻辑。软件层面的“先读后比再写”三步操作,如果没有硬件支持,无论如何都无法保证原子性。硬件让这三步变成了一步,这是所有无锁算法得以成立的前提。
2.3 Java层面的抽象:Unsafe类与原子类
Java开发者并不直接操作CPU指令,而是通过sun.misc.Unsafe这个后门类。这个类提供了类似指针操作、内存分配、CAS等底层能力。由于它过于强大和危险(如其名Unsafe),普通应用代码无法直接获取其实例。
JDK的并发大师们利用Unsafe,封装出了我们熟悉的原子类。我们以AtomicInteger的incrementAndGet()方法为例,看看其内部简化实现:
public final int incrementAndGet() { for (;;) { // 典型的自旋重试循环 int current = get(); // 获取当前值,作为预期原值A int next = current + 1; // 计算新值B if (compareAndSet(current, next)) // 调用CAS return next; // CAS成功,返回新值 // CAS失败,循环重试 } }这里的compareAndSet方法,内部就是调用了Unsafe.compareAndSwapInt方法。这个for(;;)循环结构,是CAS编程的经典模式,被称为自旋。线程不会被挂起,而是在CPU上空转循环,不断尝试,直到成功为止。这在冲突不频繁时效率很高,因为避免了上下文切换的开销;但在高冲突时,会白白浪费CPU资源。
注意:
Unsafe类在JDK内部也在被逐步规范化,例如在JDK 9及以上版本,许多功能被迁移到了VarHandleAPI中,提供了更安全、更可控的内存操作方式。但核心的CAS思想不变。
3. CAS的经典应用场景与代码实战
理解了原理,我们来看看CAS在Java世界里大放异彩的几个地方。这能让你更直观地感受它的威力。
3.1 场景一:原子计数器与累加器
这是最直接的场景。假设我们有一个Web服务器,需要统计请求总数。用synchronized当然可以,但每个请求都抢一把锁,性能瓶颈显而易见。用AtomicLong则优雅得多:
public class RequestCounter { private final AtomicLong requestCount = new AtomicLong(0); public void handleRequest() { // 处理业务逻辑... requestCount.incrementAndGet(); // 原子递增 } public long getCount() { return requestCount.get(); } }incrementAndGet()内部就是我们刚才分析的自旋CAS。在每秒数万次请求的场景下,这种无锁方式的吞吐量远超锁方案。JDK 8还引入了LongAdder和DoubleAdder,它们在超高并发下,通过分段累加(Cell数组)进一步分散热点,性能比AtomicLong更优,原理也是基于CAS。
3.2 场景二:实现无锁栈(Treiber Stack)
这是一个展示如何用CAS构建复杂数据结构的经典例子。无锁栈的核心是维护一个栈顶节点引用,入栈和出栈操作都通过CAS来更新这个引用。
import java.util.concurrent.atomic.AtomicReference; public class ConcurrentStack<E> { private static class Node<E> { final E item; Node<E> next; Node(E item) { this.item = item; } } private final AtomicReference<Node<E>> top = new AtomicReference<>(); public void push(E item) { Node<E> newNode = new Node<>(item); Node<E> oldTop; do { oldTop = top.get(); // 获取当前栈顶作为预期原值A newNode.next = oldTop; // 新节点指向原栈顶 } while (!top.compareAndSet(oldTop, newNode)); // CAS尝试将栈顶更新为新节点B // 如果CAS失败,说明oldTop已不是最新栈顶,循环重试 } public E pop() { Node<E> oldTop; Node<E> newTop; do { oldTop = top.get(); // A if (oldTop == null) return null; // 空栈 newTop = oldTop.next; // B: 新栈顶是原栈顶的下一个节点 } while (!top.compareAndSet(oldTop, newTop)); // CAS return oldTop.item; } }这个栈是完全无锁且线程安全的。push和pop操作都可能失败重试,但在低冲突下,性能非常好。ConcurrentLinkedQueue等无锁队列的实现也采用了类似的思路。
3.3 场景三:乐观锁与数据库版本号控制
CAS的思想不仅用于内存,也广泛应用于数据库的乐观锁实现中。例如,更新一条用户记录时,为了避免更新丢失,通常会使用一个version字段。
-- 1. 查询时获取数据和当前版本号 SELECT balance, version FROM account WHERE id = 1; -- 假设得到 balance=100, version=1 -- 2. 更新时,将版本号作为CAS条件 UPDATE account SET balance = 150, version = version + 1 WHERE id = 1 AND version = 1; -- 这里的 version=1 就是预期原值A这条SQL语句就是一个“数据库层面的CAS”。如果执行后影响行数为0,说明在查询和更新之间,version字段已被其他事务修改(不再是1),本次更新失败,业务层需要回滚或重试。这完美复现了CAS“比较并交换”的核心逻辑。
4. CAS的“阿喀琉斯之踵”:你必须了解的三大问题
CAS并非银弹,它带来了性能提升的同时,也引入了三个经典难题。能否处理好这些问题,是区分普通开发者和并发高手的关键。
4.1 ABA问题:最隐蔽的陷阱
这是CAS最著名的问题。假设一个共享变量的值原来是A,线程1准备将其CAS为B。在它读取A之后、执行CAS之前,线程2将值从A改成了C,然后又从C改回了A。此时,线程1执行CAS检查:发现当前值确实是预期原值A,于是CAS成功,将值更新为B。
问题出在哪?从A到A,值没变,但中间状态(C)丢失了!这个变量的“历史”发生了变化。对于简单的数值,这可能无关紧要。但如果这个V是一个引用,指向一个对象,而对象的内容可能已经改变(例如,一个链表的头节点被移走又添加回来,但链表内容已变),就会导致严重的数据不一致。
解决方案:版本号(Stamp)ABA问题的根源在于状态缺少“版本”标识。解决思路是给状态加上一个单调递增的版本号。Java中提供了AtomicStampedReference<V>类。它不再单纯比较引用值,而是同时比较一个int类型的版本戳(stamp)。
AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(100, 0); int[] stampHolder = new int[1]; int oldRef = atomicStampedRef.get(stampHolder); // 同时获取引用和版本戳 int oldStamp = stampHolder[0]; // 尝试更新,必须同时匹配旧引用和旧版本戳 boolean success = atomicStampedRef.compareAndSet(oldRef, 200, oldStamp, oldStamp + 1);这样,即使引用值从100变回100,版本戳也从0变成了1,CAS就会失败。AtomicMarkableReference是另一个变种,它使用一个布尔值(mark)作为标记,适用于某些状态只有两种变化的场景。
4.2 循环时间长带来的开销:自旋的代价
如果CAS操作长时间不成功(比如线程冲突非常激烈),for(;;)循环会持续占用CPU,这被称为“忙等待”或“自旋”。大量线程自旋会消耗巨大的CPU资源,反而可能让系统性能下降。
解决方案:自适应自旋与升级策略
- 自适应自旋:JVM内部的锁优化(如
synchronized的轻量级锁)和某些并发容器会采用自适应策略。例如,如果最近自旋很少成功,那么下次就可能直接减少自旋次数或放弃自旋,改为更传统的阻塞策略。 - 退让策略:在自旋循环中,可以加入
Thread.yield()提示调度器让出CPU,或者短暂睡眠(LockSupport.parkNanos(1L)),给其他线程,特别是持有锁的线程执行的机会,这有助于打破僵局。 - 问题分解:这是最根本的方法。像
LongAdder那样,将一个热点变量拆分成多个Cell,让线程竞争分散到多个内存地址上,从而极大降低单个CAS位置的冲突概率。
4.3 只能保证一个共享变量的原子操作
CAS指令本身一次只能作用于一个内存地址(变量)。如果你需要同时原子地更新两个、三个独立的变量,单纯的CAS就无能为力了。
解决方案:封装与合并
- 封装成对象:将多个需要原子更新的变量封装到一个不可变对象中,然后使用
AtomicReference来更新这个对象引用。这要求每次更新都创建一个新的对象。class Point { final int x, y; } // 不可变类 AtomicReference<Point> atomicPoint = new AtomicReference<>(new Point(0, 0)); Point oldP, newP; do { oldP = atomicPoint.get(); newP = new Point(oldP.x + 1, oldP.y + 2); } while (!atomicPoint.compareAndSet(oldP, newP)); - 使用锁:当逻辑过于复杂,或者需要更新的变量确实太多时,无锁编程的复杂度会急剧上升。此时,使用一个精确范围的锁(如
synchronized代码块),可能是更清晰、更易维护的选择。不要为了无锁而无锁,代码的正确性和可维护性永远排在第一位。
5. 超越基础:CAS在JUC中的高级应用探秘
CAS是构建Java并发包(JUC)的砖石。了解它在高级组件中的应用,能让你对并发编程有更深的理解。
5.1 AQS(AbstractQueuedSynchronizer)的基石
ReentrantLock、CountDownLatch、Semaphore等同步器的核心都是AQS。AQS内部维护了一个state变量(int类型)和一个CLH队列。所有对state的修改,都是通过CAS完成的。比如tryAcquire尝试获取锁,本质上就是尝试用CAS将state从0改为1。
AQS的排队机制也巧妙地运用了CAS。当线程获取锁失败时,需要将自己加入等待队列的尾部。这个“入队”操作,在并发环境下,必须保证线程安全。AQS使用的是CAS来更新尾节点(tail)的引用,确保同一时刻只有一个线程能成功将自己设为新的尾节点,其他失败的线程则循环重试。这种“无锁化”的队列管理,是AQS高效的关键。
5.2 ConcurrentHashMap的size()方法演进
在JDK 7的ConcurrentHashMap中,为了获取一个近似准确的size(),它采用了分段计数再求和的方式,但依然不精确。在JDK 8的重构中,ConcurrentHashMap的baseCount和CounterCell[]完全依赖于CAS和LongAdder的思想。
当你调用putVal方法插入数据时,最后一步是调用addCount()来增加计数。它首先尝试用CAS更新baseCount。如果失败(说明有竞争),则检查是否要创建或使用CounterCell数组,将计数累加到某个Cell上。最终size()方法就是将这些分散的值求和。整个过程几乎没有全局锁争用,性能极高,这正是CAS思想在分布式计数上的完美体现。
5.3 内存屏障与happens-before
CAS操作不仅仅是一个原子指令,它还附带强大的内存语义。在Java中,一个成功的CAS操作具有volatile读和写的内存效果。这意味着:
- 写屏障:在CAS更新之前的所有写操作(在程序顺序上),对随后成功读取该CAS位置的线程是可见的。
- 读屏障:成功CAS之后,该线程能读到之前其他线程通过CAS写入的最新值。
这保证了即使在弱内存模型(如某些CPU架构)下,基于CAS构建的无锁数据结构也能正确地在线程间传递数据变更,建立起可靠的happens-before关系。这是单纯volatile变量难以实现的复杂同步的基础。
6. 实战避坑指南与性能调优心得
纸上得来终觉浅,绝知此事要躬行。下面这些是我在实际项目中使用CAS时总结的血泪经验。
6.1 如何诊断高CPU消耗:自旋的火焰图
当你发现应用在某个高并发场景下CPU使用率异常高(比如接近100%),且线程状态多为RUNNABLE而非WAITING时,就要警惕可能是CAS自旋导致的。
排查工具:
jstack:抓取线程栈,查看大量线程是否卡在某个原子类的compareAndSet方法调用栈上。- Arthas的
thread -n命令:可以快速查看最忙的线程在做什么。 - Async Profiler或火焰图:这是最直观的。通过性能剖析生成火焰图,你会看到CPU时间大量消耗在
sun.misc.Unsafe.compareAndSwapInt或类似的自旋循环方法上,形成一个很宽的“平顶山”。
优化方向:一旦确认,就要思考:这个热点变量能否被拆分?业务逻辑能否降低竞争频率?能否用LongAdder替代AtomicLong?或者,在这个场景下,是否真的需要如此极致的无锁优化?换用一个简单的ReentrantLock会不会更简单、整体性能更好?
6.2 伪共享(False Sharing):看不见的性能杀手
这是一个极其隐蔽的性能问题。现代CPU缓存以缓存行(通常64字节)为单位。假设两个频繁写的原子变量AtomicLong a和AtomicLong b在内存中恰好位于同一个缓存行。线程1在CPU核心1上疯狂CAS更新a,线程2在CPU核心2上疯狂CAS更新b。虽然它们操作的是不同变量,但因为位于同一缓存行,每次CAS成功都会导致整个缓存行失效,迫使另一个核心的缓存行失效并重新从内存加载。这造成了大量的缓存一致性流量(Cache Coherency Traffic),严重拖慢性能。
解决方案:缓存行填充在Java早期,可以通过在变量前后添加一些无用的长整型字段来“填充”,确保一个变量独占一个缓存行。
public class PaddedAtomicLong extends AtomicLong { // 假设缓存行64字节,一个long是8字节。 // 前后填充7个long,加上对象头,大概能保证value独占一行。 public volatile long p1, p2, p3, p4, p5, p6, p7 = 0L; // 真正的value继承自父类 public volatile long p8, p9, p10, p11, p12, p13, p14 = 0L; }注意:在JDK 8中,
@sun.misc.Contended注解被引入来专门解决伪共享。JVM会在标注了此注解的字段前后自动插入填充。LongAdder内部的Cell类就使用了这个注解。在自定义高性能数据结构时,这个注解非常有用,但需要注意,默认只在JDK内部类生效,自定义类使用需要添加JVM参数-XX:-RestrictContended。
6.3 设计模式:CAS与状态机的结合
对于复杂的状态流转,CAS是实现无锁状态机的利器。例如,一个连接的状态可能是IDLE、CONNECTING、CONNECTED、CLOSING、CLOSED。用synchronized控制状态转换代码会很长。用CAS则可以非常清晰:
private final AtomicReference<State> state = new AtomicReference<>(State.IDLE); public boolean connect() { State current; do { current = state.get(); if (current != State.IDLE) { return false; // 当前状态不允许连接 } } while (!state.compareAndSet(State.IDLE, State.CONNECTING)); // CAS成功,状态转为CONNECTING,执行实际连接操作... // 连接成功后,再将状态设为CONNECTED state.set(State.CONNECTED); return true; }这种模式将状态判断和状态转换原子地绑定在一起,避免了在判断之后、设置之前状态被其他线程修改的竞态条件,代码既安全又简洁。
6.4 最后的忠告:不要滥用CAS
CAS是高性能的利器,但也是一把双刃剑。
- 复杂度:无锁算法的正确性证明非常复杂,稍有不慎就会引入极难复现的并发Bug。
- 可调试性差:基于自旋的代码,在出现问题时会让你在日志和断点中迷失。
- 并非永远最快:在冲突极高的场景,自旋会浪费大量CPU,不如阻塞等待。
我的经验法则是:优先使用java.util.concurrent包中现成的高质量组件(如ConcurrentHashMap、LongAdder)。只有当性能 profiling 证明此处确实是瓶颈,且现有组件无法满足需求时,才考虑自己动手,基于CAS或AtomicReference等构建自定义的无锁结构。并且,一定要辅以严格的压力测试和正确性验证。记住,正确的、可维护的代码,远比聪明的、但脆弱的代码更有价值。