ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入解析Java CAS机制:从硬件原理到无锁编程实战

2026/8/13 12:32:34 拓冰建站 浏览量
深入解析Java CAS机制:从硬件原理到无锁编程实战 1. 项目概述从乐观锁到CAS在Java多线程编程里处理共享数据最让人头疼的就是“竞态条件”。你肯定写过这样的代码多个线程同时去读写一个计数器最后发现结果总是不对。传统的解决方案是synchronized关键字它简单粗暴直接给代码块或方法加锁保证同一时间只有一个线程能执行。这就像只有一个卫生间的办公室谁先抢到谁用其他人只能在外面干等。synchronized是悲观锁它默认每次操作都会发生冲突所以必须先独占资源。但很多时候冲突并没有那么频繁。为了这点小概率事件就让所有线程排队性能开销太大了。这时候一种更“乐观”的思路就出现了CASCompare-And-Swap比较并交换。它假设操作大多数时候不会冲突所以允许多个线程同时尝试更新。更新前它会先看看要修改的变量当前值是不是和它之前读到的“期望值”一样。如果一样说明这段时间没被别的线程改过那就放心地改成新值如果不一样说明被“捷足先登”了那这次操作就失败通常的选择是重试。CAS是Java并发包java.util.concurrent简称JUC的基石。像AtomicInteger、ConcurrentHashMap这些高性能并发工具底层都依赖CAS。理解CAS不仅是理解一个原子操作更是打开了无锁编程和高效并发设计的大门。这篇文章我会带你从硬件原理到Java实现再到实战中的坑彻底搞懂CAS机制。2. CAS机制的核心原理与硬件支持2.1 什么是CAS一个生活化的比喻让我们抛开术语想象一个场景你和朋友合租共用一个冰箱里面有一瓶可乐。你们约定谁想喝这瓶可乐需要完成一个“CAS操作”查看Compare你先走过去看到冰箱里可乐的当前状态是“满的未开封”。你记下这个状态作为“期望值”。计划Plan你打算把它变成“空的已喝光”这个新状态。执行与检查Swap你打开冰箱门伸手去拿可乐的瞬间你会再次快速确认可乐的状态是否还是“满的未开封”。如果是说明在你“查看”到“伸手”这个极短的时间里没人动过可乐。你成功拿走并喝掉把空瓶放回去状态更新为“空的”。如果不是比如你发现可乐已经变成了“空的”说明在你查看之后、伸手之前你朋友已经喝掉了。那你本次“喝可乐”的操作就失败了。这个过程就是CAS的精髓“我认为现在是A如果是我就把它改成B如果不是A说明有人动过了那我就不改了。”整个“查看-确认-修改”的过程必须是原子的不可分割。你不能在确认状态还是“满的”之后手还没拿到就被别人抢走。在计算机中这个“可乐”就是内存中的一个变量比如int i“状态”就是它的值。CAS操作就是一条CPU指令它保证了对一个内存位置的“读-比较-写”操作在执行时不会被其他线程打断。2.2 硬件基石CPU的CAS指令CAS不是Java语言层面的魔法它需要CPU硬件的直接支持。现代处理器如x86架构的CMPXCHG指令都提供了这条原子指令。当Java程序调用sun.misc.Unsafe类中的compareAndSwapInt、compareAndSwapLong等方法时最终会通过JVMJava虚拟机映射到这条CPU指令上。这条指令的执行逻辑可以用以下伪代码来理解// 这是一个概念性的伪代码并非真实实现 function CAS(memory_address, expected_value, new_value) { // 原子性地执行以下操作 current_value *memory_address; // 读取内存当前值 if (current_value expected_value) { *memory_address new_value; // 如果相等则更新 return true; // 成功 } else { return false; // 失败 } }关键在于读取-比较-写入这三个步骤在CPU层面是一条指令完成的中间不会有上下文切换从而保证了原子性。2.3 Java中的CAS APIUnsafe类在Java中我们一般不直接操作CPU指令。标准库通过一个“后门”类——sun.misc.Unsafe注意这个类名就暗示了它的不安全性来提供CAS等底层操作。Unsafe类提供了直接操作内存、线程挂起/恢复、CAS等一系列“不安全”但功能强大的本地方法。我们日常编程中更常用的是java.util.concurrent.atomic包下的原子类例如AtomicInteger。我们来看看它的核心实现public class AtomicInteger { private static final Unsafe unsafe Unsafe.getUnsafe(); private static final long valueOffset; // value字段的内存偏移地址 static { try { // 获取value字段在AtomicInteger对象内存布局中的偏移量 valueOffset unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField(value)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; // 实际存储值的变量用volatile保证可见性 public final boolean compareAndSet(int expect, int update) { // 调用Unsafe的CAS方法 return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } public final int incrementAndGet() { // 典型的CAS循环失败就重试 return unsafe.getAndAddInt(this, valueOffset, 1) 1; // getAndAddInt内部就是一个do-while循环不断尝试CAS直到成功 } }可以看到AtomicInteger.incrementAndGet()这种看似简单的自增内部就是一个基于CAS的无锁循环。Unsafe.compareAndSwapInt方法接收四个参数操作的对象、对象中字段的偏移量、期望值、新值。它通过偏移量精准定位到内存中具体的位置进行原子操作。注意Unsafe类在正规的Java API中是不鼓励直接使用的因为它绕过了JVM的内存管理和安全机制容易导致程序崩溃或安全漏洞。我们应始终优先使用java.util.concurrent.atomic包中封装好的原子类。3. CAS的典型应用与源码解析理解了原理我们看看CAS在Java并发包里的经典应用。这能让你明白为什么CAS是高性能并发的关键。3.1 原子类Atomic Classesjava.util.concurrent.atomic包提供了一系列原子类如AtomicInteger、AtomicLong、AtomicReference等。它们提供了原子性的更新操作是替代synchronized进行简单计数器、状态标志更新的首选。以AtomicInteger.getAndIncrement()为例我们深入其实现Java 8的典型实现// Unsafe类中的方法 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v this.getIntVolatile(o, offset); // volatile读获取当前最新值 } while (!this.compareAndSwapInt(o, offset, v, v delta)); // CAS失败则循环重试 return v; }这就是经典的“CAS循环”或“乐观锁重试”模式读取变量的当前值v。基于v计算新值v delta。尝试用CAS将变量从v更新为v delta。如果步骤3成功退出循环返回旧值v。如果步骤3失败说明v已经不是最新值回到步骤1重新读取最新的当前值再次尝试。这个过程完全是无锁的线程不会被挂起。在低竞争环境下大部分线程一次CAS就能成功效率极高。3.2 实现无锁数据结构CAS是构建复杂无锁Lock-Free或非阻塞Non-Blocking数据结构的核心。以ConcurrentLinkedQueue一个无锁并发队列为例它的入队操作核心思想如下找到尾节点tail。将新节点的next指针设置为null。通过CAS尝试将尾节点的next指针从null指向新节点。如果CAS成功再尝试通过CAS将队列的tail指针指向新节点这一步允许失败因为其他线程可能已经帮忙完成了。这种设计允许多个线程同时尝试入队即使它们的CAS操作有失败也会通过循环重试而不会导致队列状态错误。整个过程中没有使用任何锁线程不会因为等待锁而被阻塞极大地提升了高并发下的吞吐量。3.3 实现轻量级同步器著名的AQSAbstractQueuedSynchronizer它是ReentrantLock、CountDownLatch、Semaphore等同步工具的基础。AQS内部维护了一个同步状态state对这个状态的修改大量使用了CAS操作。例如一个线程尝试获取锁本质上就是尝试通过CAS将state从0改为1。如果成功则获取锁如果失败state已经是1则可能进入队列等待。这个过程避免了使用重量级锁带来的内核态切换开销。4. CAS的三大经典问题与应对策略CAS虽好但也不是银弹。在实际使用中必须清醒地认识到它的局限性。4.1 ABA问题这是CAS最著名的问题。我们回顾一下CAS的逻辑它只检查“当前值是否等于期望值”。如果一个变量原来是A被一个线程改成了B然后又被改回了A。那么对于另一个只关心“是不是A”的线程来说它的CAS操作会成功因为它发现当前值A和期望值A相等。这有什么问题对于简单的计数器值从1变成2再变回1可能没问题。但对于某些场景这个“A”已经不再是原来的“A”了。一个经典的比喻是“垃圾袋”你出门前把客厅的垃圾袋A放在门口打算回来时扔掉。结果你老婆在你出门期间把垃圾倒了又把一个新袋子B套在了垃圾桶上。你回来一看门口有个空垃圾袋外观是A你以为还是原来那袋垃圾直接扔了。实际上你已经失去了“倒掉旧垃圾”这个状态变化的感知。解决方案版本号/时间戳给要CAS的数据加上一个版本号Stamp。每次数据被修改版本号都递增。进行CAS时同时比较“值”和“版本号”。即使值相同版本号不同也会导致CAS失败。在Java中AtomicStampedReference就是为解决ABA问题而生的。它维护了一个Pair对象包含引用和版本戳stamp。AtomicStampedReferenceInteger asr new AtomicStampedReference(100, 0); int[] stampHolder new int[1]; int oldRef asr.get(stampHolder); // 同时获取引用和版本戳 int oldStamp stampHolder[0]; // 尝试更新必须同时满足引用和版本戳的期望 boolean success asr.compareAndSet(oldRef, 200, oldStamp, oldStamp 1);4.2 循环时间长带来的开销在高竞争的环境下多个线程反复执行CAS操作很容易发生大量失败和重试。线程会长时间占用CPU进行空转Busy Spin消耗大量的计算资源但实际进展缓慢。这就像一群人围着一个柜台抢着办业务每个人都要反复询问“现在轮到我了没”场面很热闹但效率低下。解决方案自适应策略或退避自适应自旋JVM内部的锁优化如synchronized升级为重量级锁之前会采用自适应自旋。如果线程最近自旋成功过那么下次就允许它自旋更久如果很少成功则可能直接放弃自旋进行线程挂起。手动退避Backoff在CAS失败后不要立即重试而是让线程“休息”一下如Thread.yield()让出CPU或短暂睡眠Thread.sleep(1)给持有资源的线程执行完成的机会降低竞争激烈度。LongAdder在高竞争下的优秀表现部分就源于它采用了类似“分散热点”的思路减少了CAS冲突。4.3 只能保证一个共享变量的原子操作CAS指令本身是针对一个内存地址的。如果你需要同时原子性地更新多个独立的变量单个CAS指令就无能为力了。例如你想把变量a从1改成2同时把变量b从3改成4并且要求这两个改动要么都成功要么都不成功。解决方案封装对象或使用AtomicReference将多个需要同时更新的变量封装到一个不可变Immutable对象中。然后使用AtomicReference对这个对象引用进行CAS操作。class Point { final int x; // 使用final确保对象状态不可变 final int y; public Point(int x, int y) { this.x x; this.y y; } } AtomicReferencePoint ar new AtomicReference(new Point(1, 3)); Point oldP ar.get(); Point newP new Point(2, 4); // 创建新的不可变对象 while (!ar.compareAndSet(oldP, newP)) { oldP ar.get(); // 失败后获取最新的Point newP new Point(oldP.x 1, oldP.y 1); // 基于最新值重新计算 }通过这种方式我们用一次引用变量的CAS实现了对多个逻辑变量的原子更新。这里使用不可变对象至关重要因为一旦对象被创建其状态就不会改变所有修改都通过创建新对象来完成避免了线程间看到不一致的中间状态。5. 实战用CAS实现一个简单的无锁栈理论说再多不如动手写一个。我们来实现一个线程安全的无锁栈Treiber Stack这是展示CAS威力的经典示例。import java.util.concurrent.atomic.AtomicReference; public class ConcurrentStackE { // 栈顶节点使用AtomicReference保证其更新的原子性 private AtomicReferenceNodeE top new AtomicReference(); // 内部节点类 private static class NodeE { final E item; NodeE next; Node(E item) { this.item item; } } /** * 入栈操作 */ public void push(E item) { NodeE newNode new Node(item); NodeE oldTop; do { oldTop top.get(); // 1. 读取当前栈顶 newNode.next oldTop; // 2. 新节点指向原栈顶 } while (!top.compareAndSet(oldTop, newNode)); // 3. CAS尝试更新栈顶 // 如果CAS失败说明oldTop已不是最新栈顶循环重试 } /** * 出栈操作 */ public E pop() { NodeE oldTop; NodeE newTop; do { oldTop top.get(); // 1. 读取当前栈顶 if (oldTop null) { return null; // 栈为空 } newTop oldTop.next; // 2. 新的栈顶应该是原栈顶的下一个节点 } while (!top.compareAndSet(oldTop, newTop)); // 3. CAS尝试更新栈顶 // 如果CAS失败说明oldTop已不是最新栈顶循环重试 return oldTop.item; } public boolean isEmpty() { return top.get() null; } }代码解析与实操要点核心思想栈的状态完全由top引用决定。push和pop操作的本质就是通过CAS原子地改变top的指向。push操作创建新节点newNode。进入循环读取当前topoldTop让newNode.next指向oldTop。这样新节点在逻辑上就被放在了栈顶。关键CAS尝试用newNode替换top。条件是top当前的值必须还是我们刚才读到的oldTop。如果是替换成功操作完成如果不是说明有其他线程在我们之前成功修改了top完成了入栈或出栈我们的oldTop已经过时需要循环重试。pop操作进入循环读取当前topoldTop。如果为null栈空。确定新的栈顶newTop应为oldTop.next。关键CAS尝试用newTop替换top。条件同样是top当前的值必须还是oldTop。这确保了在我们读取oldTop之后没有其他线程修改过栈顶。如果成功返回oldTop.item如果失败重试。内存可见性AtomicReference内部通过volatile和Unsafe的CAS保证了top引用的可见性。一个线程成功执行CAS更新top后新值会立即对其他线程可见。这是“无锁”但不是“等待-自由”这个栈是无锁Lock-Free的因为至少有一个线程执行成功CAS的那个能在有限步内取得进展。但它不是等待-自由Wait-Free因为个别线程可能因为持续CAS失败而“饥饿”理论上可能一直重试。不过在实际中这种简单结构的竞争窗口很小性能通常很好。实操心得编写无锁数据结构时画出并发修改的时序图至关重要。在纸上模拟两个线程交错执行push和pop能帮你验证CAS条件是否正确以及是否存在ABA问题在这个栈实现中ABA问题不影响正确性因为节点引用不同但如果你复用节点对象就可能出问题。6. 性能对比与选型建议CAS和锁如synchronized该如何选择没有绝对的好坏只有适合的场景。我设计了一个简单的基准测试模拟100个线程每个线程对一个共享计数器进行10000次自增。分别使用synchronized、AtomicInteger和LongAdder来实现。// 省略详细的JMH基准测试代码框架展示核心逻辑 // 1. 使用synchronized private int counterSync 0; public synchronized void incrementSync() { counterSync; } // 2. 使用AtomicInteger private AtomicInteger counterAtomic new AtomicInteger(0); public void incrementAtomic() { counterAtomic.incrementAndGet(); } // 3. 使用LongAdder private LongAdder counterAdder new LongAdder(); public void incrementAdder() { counterAdder.increment(); }测试结果分析基于典型环境具体数值因机器而异实现方式低竞争线程数少高竞争线程数多特点synchronized性能尚可但存在锁升级开销性能下降明显线程阻塞严重编程简单适用场景广但在高竞争下线程挂起/唤醒开销大。AtomicInteger性能最优直接CAS一次成功性能急剧下降大量CPU空转无锁低竞争下无敌。高竞争时CAS失败重试导致CPU空转吞吐量下降。LongAdder性能略低于AtomicInteger有创建Cell开销性能最优吞吐量稳定内部采用“分段”思想分散竞争热点。高并发下每个线程操作自己的Cell最后汇总避免了单一变量的激烈CAS竞争。选型建议简单的计数器、状态标志且竞争不激烈优先使用AtomicInteger或AtomicLong。代码简洁性能高。高并发下的统计、计数场景如QPS统计毫不犹豫地选择LongAdder。它在高竞争下的性能优势是压倒性的。ConcurrentHashMap的size()方法在Java 8中就改用类似LongAdder的思路来维护计数。需要明确的互斥语义或临界区操作复杂使用synchronized或ReentrantLock。例如你需要执行“检查账户余额-扣款-记录日志”这一系列操作必须作为一个整体原子执行CAS很难优雅地实现这种复杂的复合操作。构建无锁数据结构当你需要实现队列、栈、链表等并且追求极限性能时深入理解并使用CAS进行设计。注意事项不要陷入“无锁一定比有锁快”的误区。在低竞争或临界区代码执行时间较长的场景下锁的代价可能远小于无数线程CAS空转的代价。性能优化的黄金法则是先测量再优化。使用JMH等可靠的基准测试工具来获取数据而不是凭感觉猜测。7. 常见问题排查与进阶思考7.1 我的CAS操作一直在循环程序卡住了吗不一定。这通常是高竞争的表现。线程在不断地读取-计算-尝试CAS但每次尝试都发现值已被其他线程改变。从外部看程序似乎没有进展比如计数器增长很慢但CPU使用率会很高。排查与解决使用工具监控用jstack查看线程栈如果看到很多线程停留在原子类的getAndAddInt方法内部的循环中就是典型的CAS重试。降低竞争考虑是否能用LongAdder替代。或者重新设计数据结构和算法减少对单一热点变量的争用例如使用线程本地存储暂存结果定期合并。引入退避在CAS失败后增加一个短暂的、随机的延迟如Thread.yield()或LockSupport.parkNanos(1)这能显著降低竞争激烈度提升整体吞吐量。7.2AtomicInteger和volatile关键字有什么区别这是一个核心概念问题。volatile解决的是可见性和有序性问题。它保证对一个变量的写操作能立即被其他线程看到并且禁止指令重排序。但它不保证原子性。i这种“读-改-写”操作即使i是volatile的在多线程下依然会出错。AtomicInteger利用volatile其内部value是volatile的保证可见性同时利用CAS来保证incrementAndGet()这类复合操作的原子性。它是volatile CAS的组合拳。简单说volatile告诉你变量最新的值是什么但不管你怎么改它AtomicInteger不仅告诉你最新值还能帮你安全地修改它。7.3 既然有LongAdderAtomicLong还有用吗当然有用。LongAdder和AtomicLong的API语义有细微差别。AtomicLong提供的get()、set()、compareAndSet()等操作是精确的、强一致的。你任何时候调用get()都能立刻拿到当前系统内精确的计数值。LongAdder为了性能牺牲了实时一致性。它的sum()方法需要累加所有Cell的值在累加过程中可能有其他线程在修改Cell所以sum()返回的结果是一个没有并发修改瞬间的近似值不适合用于需要精确同步控制的场景如序列号生成器。选择依据如果需要高精度、实时可见的计数器如控制全局唯一的ID生成用AtomicLong。如果只是做统计如统计请求次数允许最终一致性并且追求高并发吞吐用LongAdder。7.4 如何调试复杂的无锁程序无锁程序的Bug往往是非确定性的难以复现。调试时强化不变式检查在代码的关键位置插入断言assert验证数据结构的不变式Invariant是否始终成立。例如在无锁栈中可以断言“从top开始遍历不会出现环”。压力测试使用大量线程长时间运行测试比小规模测试更容易暴露并发问题。使用专业工具JCStressJava Concurrency Stress Test是专门测试并发正确性的工具。ThreadSanitizer等也可以帮助检测数据竞争。形式化验证对于极其核心的无锁算法可以考虑使用模型检查工具进行验证但这通常属于高级研究范畴。我个人在实现无锁结构时会先写一个单线程版本确保逻辑正确然后将其替换为AtomicReference和CAS循环最后用JCStress进行高强度、多样化的并发场景测试。一次成功的压力测试能给你带来巨大的信心。