Java并发编程:深入解析CAS与自旋锁原理、应用与陷阱

📅 2026/8/15 7:57:31
Java并发编程:深入解析CAS与自旋锁原理、应用与陷阱
1. 并发编程中的“无锁”利器从CAS到自旋锁在并发编程的世界里我们常常需要处理多个线程同时读写同一份数据的场景。传统的做法是使用synchronized关键字或者ReentrantLock这样的显式锁它们能保证线程安全但代价是性能开销——线程的挂起、唤醒、上下文切换这些操作在竞争激烈时相当耗时。有没有一种更轻量级、更高效的同步方式呢答案是肯定的那就是基于硬件原语实现的CASCompare-And-Swap操作以及在其基础上构建的自旋锁。理解它们是深入理解现代高并发框架如java.util.concurrent包的基石。今天我们就来彻底拆解compareAndSet、CAS原理以及自旋锁的实现聊聊它们如何工作优势在哪又有哪些经典的“坑”需要避开。2. CASCompare-And-Swap操作深度解析2.1 什么是CAS硬件层面的原子操作CAS全称Compare-And-Swap中文可译为“比较并交换”。它是一种原子操作意味着这个操作在执行过程中不会被其他线程打断。它的核心思想可以用一个简单的函数来描述boolean compareAndSwap(内存地址V, 期望值A, 新值B) { if (内存地址V中当前存储的值 期望值A) { 将内存地址V的值设置为新值B return true; // 操作成功 } else { return false; // 操作失败 } }这个操作是原子的。想象一下你和朋友约定只有桌上的水杯是满的期望值A你才能把它喝掉并放一个空杯新值B回去。CAS就是确保你在“看杯子”和“换杯子”这两个动作之间没有别人抢先一步把水喝掉。在计算机底层这个原子性是由CPU提供的特殊指令如x86架构的CMPXCHG指令来保证的。在Java中我们通常不直接操作这些指令而是通过sun.misc.Unsafe类不推荐直接使用或者更友好的java.util.concurrent.atomic包下的原子类如AtomicInteger来间接使用CAS。这些原子类的核心方法比如compareAndSet就是对底层CAS操作的一层封装。2.2 Java中的compareAndSet方法实战以最常用的AtomicInteger为例我们来看看compareAndSet是如何工作的。import java.util.concurrent.atomic.AtomicInteger; public class CASDemo { public static void main(String[] args) { AtomicInteger atomicInt new AtomicInteger(5); // 初始值为5 // 线程1尝试将值从5更新为10 boolean success1 atomicInt.compareAndSet(5, 10); System.out.println(第一次CAS操作: success1 , 当前值: atomicInt.get()); // 输出: true, 10 // 线程2尝试将值从5更新为15 (此时值已经是10不再是5) boolean success2 atomicInt.compareAndSet(5, 15); System.out.println(第二次CAS操作: success2 , 当前值: atomicInt.get()); // 输出: false, 10 } }为什么第二次操作会失败因为当线程2执行compareAndSet(5, 15)时它“期望”内存中的值是5但实际值已经被线程1改成了10。期望值与实际值不符所以CAS操作失败值不会被更新为15atomicInt的值保持为10。compareAndSet的核心逻辑获取当前值读取原子变量在内存中的最新值。比较将读取到的当前值与调用方法时传入的“期望值”进行比较。条件交换如果相等说明从读取到比较这段时间内没有其他线程修改过这个变量那么就将“新值”写入。如果不相等说明变量已经被其他线程修改本次操作失败什么都不做。这个过程是**无锁Lock-Free**的。线程不会因为竞争失败而被操作系统挂起它只是得知操作失败然后可以决定重试自旋或者进行其他处理。注意compareAndSet的“当前值”参数通常需要调用者自己通过get()方法获取。一个常见的模式是while(!atomicInt.compareAndSet(oldValue, newValue)) { oldValue atomicInt.get(); }这就是自旋。3. 自旋锁SpinLock基于CAS的锁实现3.1 自旋锁的工作原理理解了CAS自旋锁就很好理解了。自旋锁是一种**忙等待Busy-Waiting**的锁。当一个线程尝试获取锁时如果锁已经被其他线程持有它不会放弃CPU、进入阻塞状态而是会不断地循环自旋检查锁是否被释放直到成功获取为止。我们可以用一个简单的AtomicBoolean或AtomicInteger来实现一个最基础的自旋锁import java.util.concurrent.atomic.AtomicInteger; public class SimpleSpinLock { // 使用0表示锁空闲1表示锁被占用 private final AtomicInteger state new AtomicInteger(0); public void lock() { // 循环尝试将state从0设置为1 while (!state.compareAndSet(0, 1)) { // 自旋等待空循环。在单核CPU或高竞争下这会浪费大量CPU资源。 // Thread.yield() 或 Thread.onSpinWait() (Java 9) 可以适当让出CPU减少资源浪费。 } // 成功将0改为1获取到锁 } public void unlock() { // 将state从1设置回0 state.set(0); // 这里直接用set因为只有持有锁的线程才能解锁不存在竞争。 } }工作流程lock(): 线程A调用lock()通过compareAndSet(0,1)成功将state从0改为1获取锁进入临界区。线程B也调用lock()执行compareAndSet(0,1)。此时state已经是1所以CAS失败。线程B进入while循环不停地重试CAS操作自旋。线程A执行完临界区代码调用unlock()将state设回0。线程B在自旋中下一次执行compareAndSet(0,1)时发现state变回了0于是CAS成功获取锁跳出循环。3.2 自旋锁的适用场景与陷阱自旋锁并非万能它的使用有很强的场景限制。优势极低的开销在锁被持有的时间非常短通常是纳秒或微秒级并且线程数量不多、竞争不激烈的场景下自旋锁避免了线程挂起和唤醒的巨大开销这涉及到用户态到内核态的切换性能远高于阻塞锁。无系统调用整个自旋过程在用户态完成响应非常快。劣势与陷阱CPU资源浪费这是自旋锁最致命的缺点。如果锁被持有的时间较长或者竞争线程很多所有未获取锁的线程都会在空循环中持续消耗CPU时间片导致CPU利用率飙高但有效工作却很少严重时会影响系统整体性能。不公平性在激烈竞争下自旋锁不能保证先到先得可能出现“饥饿”现象某个线程可能长时间无法获取锁。不适合单核CPU在单核CPU上如果持有锁的线程需要等待自旋的线程让出CPU才能执行解锁操作就会导致死锁。因此单核系统上自旋锁通常需要与线程调度配合如Thread.yield()。实操心得在实际开发中我们很少自己手写一个裸的自旋锁。Java的AQSAbstractQueuedSynchronizer框架内部以及ReentrantLock在特定条件下会采用“自适应自旋”等更复杂的策略。例如JVM会根据之前自旋的成功率动态调整自旋次数。对于普通开发者理解其原理更为重要。当你看到java.util.concurrent包中那些高性能的无锁数据结构如ConcurrentLinkedQueue时就知道它们的魔力很大程度上源于对CAS的精妙运用。4. CAS的经典难题ABA问题及其解决方案4.1 ABA问题是如何产生的CAS操作在比较“当前值”和“期望值”时只检查它们是否相等。这带来一个潜在问题如果一个变量的值从A变成了B然后又变回了A那么CAS操作会误以为它从来没有被改变过。这就是著名的ABA问题。我们用一个形象的例子来说明 假设一个原子整型atomicInt的初始值是100。线程T1准备将其修改为200它先读取到当前值100。在T1执行CAS之前线程T2抢先执行将值从100成功修改为50。接着线程T3又将值从50修改回了100。此时线程T1开始执行CAS操作compareAndSet(100, 200)。它发现当前值确实是100期望值于是CAS成功将值更新为200。从结果看T1的CAS操作成功了。但这个过程是有问题的T1的前提假设是“如果值是100说明从我读取到现在没人动过它”。然而现实是值经历了100-50-100的变化这个前提被破坏了。在某些业务场景下这种“中间状态”的忽略可能导致严重的逻辑错误。一个更具体的危险场景考虑一个无锁栈。栈顶节点是A。线程T1想要弹出A它先读取到栈顶为A然后准备用CAS将栈顶设置为A的下一个节点B。 在T1执行CAS前线程T2介入弹出A。弹出B。将A又压入栈中此时栈顶又变回了A但A的next指针可能已经不同或者A对象的状态已被改变。 如果此时T1执行CAS它会成功地将栈顶设置为一个可能已经失效的B导致栈结构损坏或数据丢失。4.2 解决ABA问题的两种主流方案既然问题出在CAS只认值不认版本那么解决方案的核心就是给状态增加版本号。方案一AtomicStampedReference时间戳引用Java在java.util.concurrent.atomic包中提供了AtomicStampedReferenceV类。它不再单纯比较引用值而是同时比较一个int类型的版本戳stamp。import java.util.concurrent.atomic.AtomicStampedReference; public class ABASolution { public static void main(String[] args) { String initialRef 初始值; int initialStamp 0; AtomicStampedReferenceString atomicStampedRef new AtomicStampedReference(initialRef, initialStamp); // 尝试进行带版本号的CAS boolean success atomicStampedRef.compareAndSet( 初始值, // 期望的引用 新值, // 新的引用 0, // 期望的版本戳 1 // 新的版本戳 ); System.out.println(带版本号的CAS是否成功: success); System.out.println(当前引用: atomicStampedRef.getReference()); System.out.println(当前版本戳: atomicStampedRef.getStamp()); } }每次更新引用时必须同时且原子地更新版本戳。这样即使引用值从A变回A版本戳也一定不同例如从1变成了2后续的CAS操作会因为版本戳不匹配而失败从而避免了ABA问题。方案二AtomicMarkableReference标记引用这是AtomicStampedReference的一个简化版它将版本戳简化为一个布尔型的标记mark。适用于那些只需要知道“有没有被修改过”而不关心修改了多少次的场景。选择建议如果你的业务逻辑严格依赖于对象状态的连续变化必须感知到中间状态那么必须使用AtomicStampedReference。如果只是为了防止对象被意外重用例如在无锁数据结构中AtomicMarkableReference可能更轻量。对于简单的数值类型如AtomicInteger如果其值的变化空间足够大比如64位长的版本号也可以采用一种变通方案将值和版本号拼接成一个更大的数据类型如64位的long高32位存版本号低32位存值然后使用AtomicLong的compareAndSet来保证原子更新。这其实就是AtomicStampedReference的思想在原始类型上的应用。5. CAS与自旋锁在Java并发包中的应用与对比5.1java.util.concurrent.atomic包览析Java的原子类是整个JUCJava并发工具包无锁算法的基石。它们内部几乎全部依赖sun.misc.Unsafe类的CAS操作。主要可以分为以下几类类别代表类说明基本类型AtomicInteger,AtomicLong,AtomicBoolean提供对应基本类型的原子操作如getAndIncrementi、getAndAdd等。引用类型AtomicReferenceV提供对象引用的原子更新。数组类型AtomicIntegerArray,AtomicLongArray,AtomicReferenceArrayE提供数组中元素的原子操作。字段更新器AtomicIntegerFieldUpdaterT,AtomicLongFieldUpdaterT,AtomicReferenceFieldUpdaterT,V以反射的方式对指定类的某个volatile字段进行原子更新。适用于已存在的大量对象需要原子更新的场景避免为每个对象创建AtomicReference的开销。ABA问题解决方案AtomicStampedReferenceV,AtomicMarkableReferenceV如上文所述解决CAS的ABA问题。这些类的getAndUpdate、accumulateAndGet等方法内部都是通过自旋CAS的模式实现的。例如AtomicInteger.incrementAndGet()的典型实现public final int incrementAndGet() { for (;;) { // 自旋循环 int current get(); // 获取当前值 int next current 1; // 计算新值 if (compareAndSet(current, next)) // 尝试CAS更新 return next; // 成功则返回新值 // 失败则循环重试 } }5.2 CAS自旋 vs.synchronizedvs.ReentrantLock理解不同同步工具的差异才能做出正确选择。我们用一个简单的计数器累加场景来对比。特性synchronized关键字ReentrantLockCAS自旋 (如AtomicInteger)实现机制JVM内置监视器锁Monitor通过monitorenter/monitorexit字节码实现。基于AQS框架实现的互斥锁功能更丰富。基于CPU硬件CAS指令的无锁循环。锁粒度方法或代码块。代码块需要显式lock()和unlock()。无锁针对单个变量。性能开销较大。涉及用户态/内核态切换线程挂起和唤醒。与synchronized类似但优化后如自旋尝试在低竞争时可能稍好。极小。仅在用户态循环无系统调用。但在高竞争下CPU空转开销大。公平性非公平。可配置为公平或非公平锁。非公平。功能特性自动释放锁可重入不可中断等待早期不支持尝试锁、超时、条件变量等。可重入可中断支持尝试锁tryLock、超时锁、多个条件变量Condition。仅提供变量的原子操作不直接提供锁语义。需在其上构建如自旋锁。适用场景简单的同步控制锁竞争不激烈代码简洁优先。需要高级功能如可中断、超时、公平性、条件队列的复杂同步场景。锁持有时间极短、线程竞争度低的场景。例如计数器、状态标志、无锁队列/栈的节点操作。核心选择逻辑追求极致性能且冲突概率极低首选CAS。例如全局ID生成器、统计点击数。需要复杂的线程协作和调度选择**ReentrantLock**。例如生产者-消费者模型中的精确唤醒。追求代码简洁且性能要求不是极端使用**synchronized**。现代JVM对其优化如锁升级偏向锁-轻量级锁-重量级锁已经非常出色在大多数通用场景下性能并不差。一个常见的误解认为无锁CAS一定比有锁快。这只有在低竞争条件下才成立。在高竞争环境下大量线程自旋会导致CPU风暴性能会急剧下降此时不如让线程阻塞排队使用synchronized或ReentrantLock来得高效。JVM的synchronized锁在升级为重量级锁之前也会先尝试轻量级锁其本质也是一种CAS自旋可以看作是一种自适应的优化策略。6. 高级话题与性能调优考量6.1 伪共享False Sharing与缓存行填充这是一个影响CAS乃至所有多线程编程性能的底层问题。现代CPU为了弥补与内存之间的速度鸿沟引入了多级缓存L1, L2, L3。数据在缓存中不是以单个字节存储而是以**缓存行Cache Line**为单位通常大小为64字节。伪共享问题如果两个毫不相干的变量X和Y恰好位于同一个缓存行上线程A在CPU Core1上频繁修改X线程B在CPU Core2上频繁读取Y。尽管它们操作的是不同变量但由于缓存一致性协议如MESICore1修改X会导致整个缓存行失效Core2的缓存行随之失效需要重新从内存或L3缓存加载。这会导致大量的缓存未命中Cache Miss严重拖慢性能。对于像AtomicLong这样被频繁执行CAS的变量伪共享的影响是灾难性的。解决方案缓存行填充思路是让一个频繁写的变量独自占据一个完整的缓存行避免与其他变量共享。在Java中可以通过在变量前后添加无用的长整型字段来“填充”缓存行。// Java 8 之前的一种实现方式注意实际布局受JVM和字段顺序影响 public class PaddedAtomicLong { public volatile long value 0L; // 实际使用的值 public long p1, p2, p3, p4, p5, p6, p7; // 填充物假设每个long是8字节7个就是56字节 // 加上对象头和一些其他开销努力让value独占一个缓存行 }在Java 8及以后官方提供了更优雅的解决方案sun.misc.Contended注解。被此注解修饰的字段JVM会尝试将其分配在独立的缓存行上。import sun.misc.Contended; public class ContendedCounter { Contended // 此注解会触发缓存行填充 private volatile long value 0; public void increment() { // ... CAS 操作 } }注意Contended默认只在JDK内部的类中生效如Thread类中的threadLocalRandomSeed。要在用户代码中使用需要添加JVM参数-XX:-RestrictContended。此外过度使用填充会浪费内存需权衡利弊。6.2 自旋优化与Thread.onSpinWait()在自旋锁或CAS重试循环中线程只是进行空循环while(!cas(...)) {}这会持续占用CPU资源。为了缓解这个问题JVM提供了一些提示方法Thread.yield()这是一个提示告诉调度器当前线程愿意让出当前使用的CPU。但这只是一个提示调度器可以忽略。它可能会引起上下文切换。Thread.onSpinWait()(Java 9)这是一个更精准的提示。它告诉CPU当前线程正处于自旋等待状态。在现代CPU上这可能会触发一些架构相关的优化比如降低当前核心的功耗或优先执行其他硬件线程从而在减少功耗和总线竞争的同时提高自旋等待的效率。它通常不导致线程上下文切换。在自旋循环中适当加入这些提示是一种良好的实践。public void lock() { while (!state.compareAndSet(0, 1)) { Thread.onSpinWait(); // Java 9 优化自旋 // 或者 Thread.yield(); } }6.3 何时该用何时不该用根据上面的分析我们可以总结出CAS和自旋锁的黄金使用法则坚决使用CAS/自旋的场景操作对象非常简单通常是单个基本类型变量或对象引用。锁持有时间极短操作能在极短时间内纳秒/微秒级完成例如修改一个标志位、递增一个计数器。线程竞争度低同时尝试修改该变量的线程数很少。性能瓶颈确在此处通过性能剖析Profiling工具如Async Profiler, JMC证实此处的锁竞争确实是性能热点。避免使用CAS/自旋的场景涉及复杂操作或多个变量CAS只能保证一个变量的原子性。如果需要同时原子地更新多个变量要么使用锁要么将它们合并到一个对象中用AtomicReference来更新但这可能引发ABA问题需用带版本号的引用。锁持有时间长或竞争激烈这会导致大量CPU时间浪费在空转上性能反而不如阻塞锁。需要复杂的线程间协调如等待/通知机制、可重入、公平性等这些是锁synchronized,ReentrantLock更擅长的领域。单核处理器系统自旋锁在单核上可能导致死锁除非配合主动让出CPU。我个人在实际高并发系统调优中的体会是不要过早优化。首先用synchronized或ReentrantLock写出清晰正确的代码。当性能测试表明同步块成为瓶颈并且满足上述“坚决使用”的条件时再考虑将其重构为基于CAS的无锁算法。同时务必使用AtomicStampedReference等工具处理好ABA问题并使用Contended或填充来避免伪共享。无锁编程能带来性能提升但也显著增加了代码的复杂度和出错风险。