ARTICLE DETAIL

建站实战干货

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

深入解析CAS与自旋锁:从硬件指令到高并发编程核心

2026/8/15 13:40:08 拓冰建站 浏览量
深入解析CAS与自旋锁:从硬件指令到高并发编程核心 1. 从硬件指令到并发基石CAS与自旋锁的深度解构在并发编程的世界里我们常常面临一个根本性的挑战如何让多个线程安全、高效地修改同一块共享数据你可能会立刻想到synchronized关键字或者ReentrantLock它们通过加锁来保证互斥访问确实安全但锁的获取与释放、线程的挂起与唤醒都伴随着不小的性能开销。有没有一种更轻量级、更接近硬件原语的方式呢答案是肯定的这就是Compare-And-Swap也就是我们常说的CAS。而围绕CAS构建的自旋锁则是实现无锁Lock-Free或乐观锁Optimistic Locking并发控制的核心思想。今天我们就来彻底拆解compareAndSet、CAS以及自旋锁理解它们如何从一条CPU指令演变为高并发场景下的利器并深入探讨其背后的原理、应用以及那些你必须知道的“坑”。简单来说CAS操作包含三个核心参数一个内存位置V、一个期望的原值A、一个新值B。它的语义是“我认为内存位置V的值应该是A如果是那我就把它更新为B如果不是说明在我读取之后、准备更新之前已经有其他线程修改了它那么我什么也不做并告知操作失败。” 这个过程是原子性的即不可被中断。AtomicInteger等原子类中的compareAndSet方法就是对底层CAS指令的Java层封装。自旋锁则是利用CAS来实现的一种锁当一个线程尝试获取锁时它会在一个循环里不断尝试执行CAS操作比如尝试将锁标志从0改为1如果失败就继续循环“自旋”直到成功为止。这避免了线程上下文切换的开销但在竞争激烈时会导致CPU空转。2. 核心原理一条CPU指令如何撑起高并发要真正理解CAS我们必须深入到硬件层面。现代多核处理器普遍提供了一条名为Compare-And-Swap的原子指令。在x86架构下对应的指令是CMPXCHG。这条指令的执行在CPU内部是不可分割的这保证了其原子性。2.1 CAS操作的原子性本质为什么需要硬件支持考虑一个简单的i操作它包含三个步骤1. 读取i的值到寄存器2. 将寄存器中的值加13. 将新值写回内存。在多线程环境下这三个步骤之间可能被其他线程打断导致更新丢失。CAS指令将“比较”和“交换”这两个动作捆绑成一个原子操作。当CPU执行这条指令时它会锁定相关的高速缓存行Cache Line确保在操作完成前其他核心无法访问该内存区域从而实现了原子性。在Java中sun.misc.Unsafe类提供了访问这些底层硬件原语的方法如compareAndSwapInt,compareAndSwapLong。而java.util.concurrent.atomic包下的原子类如AtomicInteger其compareAndSet、incrementAndGet等方法内部最终都调用了Unsafe类的CAS操作。// AtomicInteger.incrementAndGet() 的简化版内部实现逻辑 public final int incrementAndGet() { for (;;) { // 自旋循环 int current get(); // 获取当前值 int next current 1; // 计算新值 if (compareAndSet(current, next)) // 尝试CAS更新 return next; // 成功则返回新值 // 失败则循环重试 } }2.2 自旋锁的实现模式自旋锁是CAS最直接的应用之一。它的核心逻辑就是一个“循环尝试”的过程。public class SimpleSpinLock { private final AtomicInteger state new AtomicInteger(0); // 0-未锁定1-已锁定 public void lock() { // 自旋期望值是0未锁想更新为1加锁 while (!state.compareAndSet(0, 1)) { // 可选在此处加入线程让步Thread.yield或短暂休眠以减少CPU消耗 // Thread.onSpinWait(); // JDK9 提供的提示优化自旋 } // 成功获取锁 } public void unlock() { state.set(0); // 释放锁无需CAS因为只有持有锁的线程才能释放 } }这里的关键点在于lock()方法中的循环。它没有让线程进入阻塞BLOCKED状态而是保持运行RUNNABLE并持续尝试。这在锁持有时间非常短纳秒或微秒级的场景下效率远高于系统调用导致的线程挂起和唤醒。注意自旋锁并非银弹。它的适用场景有严格限制1.临界区执行时间极短2.多核处理器单核自旋无意义因为持有锁的线程无法运行3.线程竞争不激烈。在不符合这些条件的场景下使用会导致严重的CPU资源浪费和性能下降也就是常说的“锁饥饿”或“总线风暴”。3. Java中的实践Atomic类与自旋锁应用解析理解了原理我们来看看在Java中如何具体运用。java.util.concurrent.atomic包为我们提供了一整套原子工具。3.1 AtomicInteger 的典型用法与源码窥探AtomicInteger可能是最常用的原子类。除了compareAndSet它还提供了许多基于CAS的复合操作如getAndIncrementi、getAndAdd等。AtomicInteger counter new AtomicInteger(0); // 线程安全的递增 int newValue counter.incrementAndGet(); // 类似 i int oldValue counter.getAndIncrement(); // 类似 i // 复杂的更新如果当前值是100则设置为200 boolean updated counter.compareAndSet(100, 200); // 更灵活的更新传入一个函数 int updatedValue counter.updateAndGet(x - x * 2); // 将值翻倍我们深入看一下incrementAndGet的HotSpot实现简化概念。它内部就是一个自旋循环不断读取当前值计算新值然后尝试CAS直到成功为止。这种模式被称为“乐观锁”因为它假设冲突不常发生先进行计算提交时再检测冲突。3.2 超越AtomicInteger其他原子类与字段更新器AtomicLong/AtomicBoolean 与AtomicInteger类似用于长整型和布尔型。AtomicReference 用于原子更新对象引用。这是实现无锁栈Treiber Stack、无锁队列的基础。AtomicStampedReference 这是解决ABA问题的关键类我们后面会详细讲。它在引用之外额外维护了一个int类型的版本号Stamp。AtomicIntegerFieldUpdater 允许你以原子方式更新某个类的volatile int字段。当你需要原子性但又不想将整个类包装成原子类时例如已有的POJO它非常有用能节省内存。class MyClass { private volatile int count; private static final AtomicIntegerFieldUpdaterMyClass UPDATER AtomicIntegerFieldUpdater.newUpdater(MyClass.class, count); public int increment() { return UPDATER.incrementAndGet(this); } }3.3 自旋锁在JDK中的应用实例JDK内部的许多并发工具都使用了自旋优化。AQSAbstractQueuedSynchronizerReentrantLock、CountDownLatch等同步器的基石。在AQS中线程在尝试获取锁时会先进行一段短暂的自旋具体次数与策略因版本和JVM而异如果自旋失败才会被放入CLH队列中挂起。这是一种“自适应自旋”结合了自旋和阻塞的优点。Synchronized的锁升级 在HotSpot JVM中偏向锁和轻量级锁的竞争过程也包含了自旋操作。当线程竞争轻量级锁失败时并不会立即膨胀为重量级锁涉及操作系统互斥量而是会进行一段时间的自旋默认次数尝试再次获取锁如果成功则避免了一次昂贵的系统调用。实操心得 在业务代码中除非你正在构建底层并发框架否则不建议手动实现自旋锁。优先使用ReentrantLock并合理设置其tryLock的自旋时间或者直接使用synchronizedJVM会帮你做优化。手动实现的自旋锁很难处理好所有边界条件如公平性、可重入性、以及我们在下一节要讲的ABA问题。4. 深入陷阱ABA问题、自旋消耗与内存顺序CAS和自旋锁虽然强大但也伴随着几个经典的陷阱理解它们才能安全使用。4.1 ABA问题你以为没变其实早已沧海桑田这是CAS操作最著名的问题。假设一个共享变量的值是A。线程1读取到值A。在线程1执行CAS之前线程2将值从A改为B。接着线程3或线程2又将值从B改回了A。此时线程1执行CAS操作它期望的值是A当前内存值也是A于是CAS成功。对于线程1来说它“感觉”这个值没变过。但在某些场景下这可能导致逻辑错误。例如在一个无锁链表中你检查头节点是A想把它换成B。但在你检查后另一个线程移除了A又加入了一个新的节点地址恰好也是A或内容与A相同你的CAS成功将头节点换成了B却可能破坏链表结构。解决方案使用版本号Stamp 这就是AtomicStampedReference的作用。每次修改不仅更新引用还让一个版本号原子递增。CAS时同时比较引用和版本号。AtomicStampedReferenceNode ref new AtomicStampedReference(nodeA, 0); int[] stampHolder new int[1]; Node current ref.get(stampHolder); // 同时获取引用和版本戳 // ... 准备新节点 newNode boolean success ref.compareAndSet(current, newNode, stampHolder[0], stampHolder[0] 1);使用布尔标记或计数器 原理类似增加一个维度来标识状态是否被更改过。4.2 自旋的CPU消耗与适应性自旋锁在竞争激烈时会导致大量线程空转白白消耗CPU周期这种现象在物理机或容器资源紧张时尤为致命。自适应自旋Adaptive Spinning是现代JVM和锁库采用的优化策略根据上次自旋成功获取锁的频率动态调整下次自旋的次数。如果最近很少自旋成功就减少自旋次数甚至直接挂起。在编写高性能并发代码时一个重要的经验是测量而不是猜测。使用jstack、JMCJava Mission Control或async-profiler等工具观察线程在自旋状态通常显示为RUNNABLE且停留在某个循环的比例如果过高就需要考虑降低竞争如缩小锁粒度、使用并发数据结构或改用会阻塞的锁。4.3 内存可见性与顺序一致性CAS操作本身具有volatile读和写的内存语义。成功执行CAS的线程其CAS操作之前的写操作对随后成功读到该CAS更新值的线程是可见的。这比普通的volatile变量更强。但需要注意的是自旋锁只保证了互斥不保证公平性。后请求锁的线程可能比先请求的线程更早获得锁这可能导致某些线程“饿死”。如果需要公平性需要使用队列如AQS中的CLH队列来管理等待线程。一个常见的误区认为无锁编程一定比有锁编程快。这只有在冲突率极低的情况下才成立。当冲突率上升时不断重试的CAS开销会迅速超过一次性的锁获取开销。因此选择CAS还是锁需要基于实际场景的竞争程度来做权衡。5. 高级模式与性能调优考量掌握了基础我们可以看看更高级的应用模式和调优思路。5.1 无锁数据结构Lock-Free Data Structures基于CAS可以构建出完全无锁的并发数据结构如无锁栈、无锁队列Michael-Scott队列是最著名的实现。这些结构的核心思想是所有操作都通过CAS来完成确保数据结构总是处于一致状态。即使某个线程在中途挂起其他线程依然可以继续推进。以无锁栈为例Treiber Stackpublic class ConcurrentStackE { private AtomicReferenceNodeE top new AtomicReference(); public void push(E item) { NodeE newHead new Node(item); NodeE oldHead; do { oldHead top.get(); newHead.next oldHead; } while (!top.compareAndSet(oldHead, newHead)); // CAS更新栈顶 } public E pop() { NodeE oldHead; NodeE newHead; do { oldHead top.get(); if (oldHead null) return null; newHead oldHead.next; } while (!top.compareAndSet(oldHead, newHead)); // CAS更新栈顶 return oldHead.item; } private static class NodeE { final E item; NodeE next; Node(E item) { this.item item; } } }这种结构的吞吐量在高并发读写下可能优于基于锁的版本但实现复杂度高且对ABA问题敏感上述简易实现就有ABA风险生产环境需用AtomicStampedReference。5.2 缓存行伪共享False Sharing与Contended这是一个极其隐蔽的性能杀手。现代CPU以缓存行通常64字节为单位从内存加载数据。如果两个频繁写的变量比如两个AtomicLong的value字段位于同一个缓存行那么一个CPU核心修改其中一个变量时会导致其他核心中整个缓存行失效即使它们修改的是该行内的不同变量。这会导致缓存一致性协议如MESI产生大量不必要的流量严重降低性能。对于高度竞争的原子变量可以使用JDK 8引入的sun.misc.Contended注解JDK9在jdk.internal.vm.annotation包下来避免伪共享。这个注解会在字段前后自动添加填充Padding确保它独占一个缓存行。public class StripedCounter { jdk.internal.vm.annotation.Contended // 防止伪共享 private final AtomicLong cell1 new AtomicLong(); jdk.internal.vm.annotation.Contended private final AtomicLong cell2 new AtomicLong(); // ... 分别操作cell1和cell2最后汇总 }LongAdder和ConcurrentHashMap中的CounterCell就采用了类似的思想通过分散热点来提升并发累加的性能。5.3 何时选择CAS/自旋何时选择传统锁这是一个架构选型问题。我的经验法则是选择CAS/自旋锁当操作非常简单如计数器增减、标志位切换。临界区执行时间极短纳秒/微秒级。线程竞争程度低到中等。你正在实现一个无锁数据结构并且对其正确性有充分把握和测试。选择synchronized或ReentrantLock当临界区逻辑复杂执行时间较长。需要可重入、公平锁、条件变量等高级特性。竞争激烈自旋会导致CPU浪费。你追求代码的简单性和可维护性。在大多数业务场景下JVM优化后的synchronized性能已经足够好且代码更清晰。最后的建议 在应用层优先使用java.util.concurrent包提供的高级工具如ConcurrentHashMap、LongAdder、AtomicInteger它们已经集成了最优的并发策略。只有在你确信成为性能瓶颈并且有足够能力进行正确性证明和测试时才考虑自己基于CAS构建更复杂的无锁算法。毕竟并发Bug是最难调试和复现的。多花时间在架构设计上减少共享状态往往比在底层锁优化上绞尽脑汁更有效。