自旋锁原理与实战:从CAS忙等到JVM锁升级的并发陷阱
先讲一个我实际遇到的场景。某个线上服务平时很稳某天晚上CPU突然飙到接近100%线程dump打下来一看大量线程都卡在同一行代码附近一个非常简单的AtomicInteger.compareAndSet循环。单看逻辑这个循环最多三五次就能退出凭什么把CPU烧满后来定位到问题不在这个循环本身而在于它背后参与的正是锁的等待机制——自旋spin-waiting。那段时间我把JVM底层的锁升级、synchronized、AQS、并发工具类翻了个遍也自己手写过几种自旋锁做压测踩过可见性的坑、AB的坑也见过CPU被空转烧到红的场景。这篇东西不是教科书式的概念复述而是我复盘整个理解过程和实战经验后留下的干货自旋的核心原理、JDK里真实存在的自旋点、手写自旋锁的完整思路以及到底什么时候该用自旋、什么时候该老老实实阻塞。1. 一次线上抖动逼着我把自旋彻底看明白1.1 线程卡在同一个循环CPU却烧满了当时的现象很典型服务没有流量激增但CPU占用率长时间拉满接口耗时大幅度上涨。线程dump里能看到几十个线程状态都是RUNNABLE并且栈顶都停在一个原子类的compareAndSet方法附近。RUNNABLE不是BLOCKED不是WAITING说明这些线程并没有被挂起而是在某个循环里反复执行操作。让我疑惑的是那段代码本身是轻量级的一个并发计数器的自减操作临界区充其量就是几条内存操作。按常理线程要么立刻抢到锁要么等一会儿也该轮到了。可实际情况是线程们全都在“原地打转”谁也进不去CPU被这些空转逻辑吃干净了。这和我之前对锁的理解不一样我以为多线程竞争锁时抢不到的线程就该阻塞挂起、让出CPU为什么这里全都是活蹦乱跳的自旋后来才搞清楚那是JVM对轻量级锁的一种优化策略在锁膨胀成重量级锁之前线程会先自旋等待一小段时间期望持锁线程很快释放锁避免直接进入阻塞、等待唤醒的昂贵路径。而那一次线上服务的问题本质上就是锁竞争激烈到让自旋次数失控大量线程同时空转CPU被打满。从那时起我就意识到自旋不是Java里可有可无的小技巧而是很多并发机制的地基。1.2 自旋到底是什么忙等不只是转圈先给自旋下一个足够朴素的定义线程在等待某个条件满足时不主动挂起而是持续循环检查这个条件直到条件满足或达到某个上限。这就像你去餐厅等位有人选择叫号时再去门口有人选择一直站在门口盯着叫号屏——后者就是自旋前者就是阻塞。用Java代码描述最简单的自旋就是一个while循环while (!condition) { // 空转等待 }条件不满足就继续循环满足就跳出。看起来很简单但正是这个“继续循环”让很多开发者栽了跟头如果condition没有正确的内存可见性或者循环内部没有合适的退出机制轻则让一个CPU核心空转重则整个进程都卡死。自旋的核心特征就是不放弃CPU用CPU时间换取低延迟代价是CPU资源被持续消耗。1.3 这篇文章想带你看懂的东西清楚了自旋是“忙等”之后真正需要弄明白的是几件事为什么自旋在某些场景下比阻塞快那么多Java里自旋的原子性底座是什么JDK内部哪些地方在用自旋手写一个可用的自旋锁需要避开哪些坑以及实际开发里自旋锁、synchronized、乐观原子类之间的选型边界在哪里。这些内容我会从原理讲到代码从JDK源码讲到压测数据。你可以把它当成一份“自旋主题的排查手册”遇到锁竞争导致的CPU飙升、无锁算法性能瓶颈、或者面试被问到self-spin / spin-waiting时都能回来翻一翻。2. 从CPU指令到Java代码自旋的底层原理2.1 为什么自旋在许多场景下快过阻塞要理解自旋为什么快先要看清阻塞的代价。线程进入阻塞状态时操作系统要把它从CPU核心上换下来保存现场唤醒时再换上去恢复现场。这个过程叫上下文切换context switch一次可能消耗几十微秒甚至上百微秒和CPU主频相比这是极其昂贵的时间成本。比较一下一次CAS原子操作通常只需要几十纳秒一个简单的自旋循环一轮可能就几纳秒到几十纳秒。如果一个临界区只需要几百纳秒就能执行完那么持锁线程根本来不及被换下去其他线程自旋等待的代价就远低于阻塞再唤醒的代价。自旋的本质是用“当前线程空转CPU”来换取“避免一次线程切换的开销”。但这里有个前提条件临界区必须足够短。如果临界区动不动几毫秒那自旋的线程就是在空烧CPU烧的时间远超一次上下文切换这时候让线程挂起、等锁释放了再唤醒反而更划算。这也是为什么我在文章后面会反复强调临界区长度的问题——它是自旋选型的唯一分水岭。2.2 CAS让“检查交换”变成一条原子操作自旋锁的“检查条件”不能只是一个普通的读操作因为“检查”之后还要紧跟一个“修改”这两个动作必须是一个整体原子操作否则多个线程同时检查通过、同时修改锁就被打破了。Java里承担这个角色的是CASCompare-And-Swap比较并交换。CAS的语义是给定内存位置V、预期值A、新值B只有当V上的当前值等于A时才把V更新为B并返回更新是否成功。这个“比较写入”的动作在硬件层面被实现为一条原子指令最典型的就是x86的cmpxchg。所以CAS天然具备原子性不需要再加锁。用代码模拟一下AtomicInteger.incrementAndGet()的底层逻辑// 伪代码仅用于说明CAS语义 public static boolean compareAndSet(int currentValue, int expect, int newValue) { if (currentValue expect) { currentValue newValue; return true; } return false; }当然真正的CAS操作是直接在内存层面完成的不会出现“先读到值、再判断、再写入”这种分离动作。也正因为CAS是硬件指令JVM才能把它作为无锁并发的基础。几乎所有Java并发工具底层都能追溯到若干条CAS指令。2.3 Java里使用CAS的三个入口Atomic、Unsafe和VarHandle开发者能接触到CAS的途径主要有三个层级。第一层是java.util.concurrent.atomic包下的原子类如AtomicInteger、AtomicLong、AtomicReference这些类封装了CAS对外暴露compareAndSet、incrementAndGet之类的方法日常业务开发用这一层就够了。第二层是sun.misc.Unsafe它提供了compareAndSwapInt、compareAndSwapObject等native方法是JDK内部大量并发实现的地基。但Unsafe是内部API官方并不推荐普通开发者直接使用而且未来的JDK版本越来越限制它的访问没必要硬上。第三层是java.lang.invoke.VarHandleJDK 9开始提供的替代方案可以理解为一个更安全的、面向开发者的CAS入口。它能对对象字段、数组元素做原子操作写库和写框架时很有用。从可靠性和维护成本来看我自己的原则是业务代码用原子类需要定制对象字段级别的原子操作时用VarHandle不到万不得已不碰Unsafe。CAS本身没有“锁”的阻塞语义它只是给自旋循环提供了一次又一次原子尝试的能力。2.4 CAS自旋与无锁数据结构的关系明白了CAS就能理解很多无锁数据结构为什么长成那副模样——全都是在for (;;)循环里不断CAS失败就重试。比如ConcurrentLinkedQueue的入队操作就是典型的“CAS自旋推进”拿到当前尾节点尝试把新节点接到它后面接失败了就刷新尾节点再试一次循环继续直到成功。同样地ConcurrentHashMap在扩容辅助、LongAdder在热点分离、ThreadPoolExecutor在维护线程池状态时都大量采用这种“循环CAS”的写法。这里的自旋不涉及锁但却和自旋锁在底层一脉相承都用空闲CPU去换取一个确定性的成功。可以说只要你看到Java并发代码里出现for (;;)和compareAndSet的组合那就是自旋的最典型形态。3. 手写自旋锁把原理变成能跑的代码3.1 第一版用一个volatile标志位理解了原理之后最容易踩的第一个坑就是以为自旋锁只要一个volatile标志位就够了。我当初也是这么写的public class BrokenSpinLock { private volatile boolean locked false; public void lock() { while (locked) { // 空转等待 } locked true; // 检查与置位不是原子操作 } public void unlock() { locked false; } }这段代码能通过编译但完全不可用。问题在于while (locked)这个检查和后面的locked true是两条独立指令。两个线程可以同时读到locked false然后一起进入循环体一起把locked设成true结果两个线程都认为自己拿到了锁。这就是线程安全的经典竞态条件检查与执行之间必须有原子性。3.2 第二版用CAS让抢锁动作原子化正确的做法是让“判断锁是否空闲 把锁置为占用”作为一个原子操作。用AtomicBoolean的compareAndSet(false, true)就能做到public class SpinLock { private final AtomicBoolean locked new AtomicBoolean(false); public void lock() { // 自旋等待直到CAS成功把false改成true while (!locked.compareAndSet(false, true)) { // 空转继续尝试 } } public void unlock() { // 直接写回false释放锁 locked.set(false); } }这样多个线程同时抢锁时CAS只允许一个线程把false改成true其他线程的CAS都会失败于是继续在循环里等待。持锁线程执行完临界区后调用unlock()把状态写回false其他线程才有机会再次抢锁。这才是自旋锁的最小可用实现。3.3 进阶改动可重入、公平性与TicketLock上面这个自旋锁足够入门但距离生产可用还有距离。最大的问题是不可重入如果同一个线程在持锁期间再次调用lock()会立刻死锁等待自己。改成可重入锁也不复杂记录当前持锁线程和重入次数即可public class ReentrantSpinLock { private final AtomicBoolean locked new AtomicBoolean(false); private Thread owner; private int depth; public void lock() { Thread current Thread.currentThread(); if (owner current) { depth; return; } while (!locked.compareAndSet(false, true)) { // 自旋等待 } owner current; depth 1; } public void unlock() { if (owner ! Thread.currentThread()) { throw new IllegalStateException(不是锁的持有者不能释放); } depth--; if (depth 0) { owner null; locked.set(false); } } }还有一个公平性问题。上面非公平版自旋锁在竞争激烈时可能出现多个线程同时等待、但每次都是同一个线程“幸运”抢到的情况理论上会造成饥饿。要保证公平性可以用TicketLock先来的线程先拿号叫号到自己才进入临界区。public class TicketLock { private final AtomicInteger ticket new AtomicInteger(); private final AtomicInteger serving new AtomicInteger(); public int lock() { int myTicket ticket.getAndIncrement(); while (serving.get() ! myTicket) { // 等待叫号 } return myTicket; } public void unlock(int myTicket) { // 服务号后移 serving.compareAndSet(myTicket, myTicket 1); } }TicketLock能保证完全公平但代价是每个线程都要读serving字段。多个线程同时自旋读同一个volatile变量在高竞争下会遇到缓存一致性压力的放大性能不一定比非公平版好。生产环境下很少直接用这种极端公平的自旋锁但它对理解公平性设计很有帮助。3.4 手写时容易被忽略的三个细节第一unlock()里的写入最好也用CAS或者至少写volatile字段。上面用locked.set(false)没问题因为AtomicBoolean本身就是volatile int包装。第二自旋锁一般不支持超时等待也没有响应中断的机制如果你需要长时间不可控的等待请直接上ReentrantLock。第三unlock()必须由持锁线程自己调用否则可能释放掉别人的锁这是一个规范问题而不是技术问题。自旋锁写到这里已经能清楚看到它的能力边界它是为短临界区设计的工具而不是用来替代阻塞锁的通用方案。4. JVM和JDK底层自旋其实无处不在4.1 synchronized锁升级路线中的自旋Java的synchronized常常被当成“重量级锁”但现代JVM里它的默认路径是轻快的。锁状态从无锁到偏向锁、轻量级锁、重量级锁是一步步升级的。当多个线程同时竞争同一个锁JVM进入轻量级锁阶段后抢不到锁的线程并不会立刻阻塞而是先在栈帧中创建一个锁记录并尝试用CAS把对象头的Mark Word替换成指向锁记录的指针。失败之后JVM并不会马上把线程挂起而是让它进入自旋等待。这里的原始设计目标是既然持锁线程正在运行可能马上释放锁自旋一小段时间比阻塞唤醒更划算。HotSpot曾经允许通过-XX:PreBlockSpin指定自旋次数但后续版本大多采用自适应自旋也就是JVM根据历史竞争情况动态决定自旋多久。值得提醒的是较新版本的JDK已经将偏向锁逐步移除但轻量级锁阶段“先自旋尝试再膨胀阻塞”的思路至今仍在。4.2 AQS的acquireQueued先自旋再阻塞的混合设计ReentrantLock、Semaphore、CountDownLatch这些工具背后是AbstractQueuedSynchronizerAQS。AQS的等待逻辑是典型的“自旋阻塞”混合策略线程在acquireQueued方法里从一个循环中不断尝试获取锁抢不到时就检查前驱节点的状态如果前驱在等待就把当前线程挂起否则继续自旋尝试。这意味着如果锁很快被释放很多线程在自旋那一两轮里就能成功拿到锁根本不需要进入park/unpark的阻塞唤醒路径。lock()接口看上去和while循环无关但底层确实藏着“抢不到就试着转一转转不成就睡一会儿”的机制。理解了这一点再看ReentrantLock在高竞争下的性能表现就不容易误判了。4.3 ThreadPoolExecutor里的workerCount自旋线程池ThreadPoolExecutor里保持着一个ctl状态变量它是一个原子整数高3位记录线程池运行状态低29位记录工作线程数。addWorker()增加线程数时不会粗暴地写一个整数而是循环CASfor (;;) { int c ctl.get(); // 检查状态和workerCount是否允许增加 if (compareAndIncrementWorkerCount(c)) { // CAS成功继续创建真实线程 break; } // CAS失败说明其他线程同时改了workerCount重试 }每次CAS失败后重新读最新状态继续尝试这个“循环读-判断-重试”的模式本质就是自旋。虽然只有几百行代码能看出这种痕迹但线程池的稳定性恰恰建立在无数个这样的小自旋上。4.4 自适应自旋JVM替我们做的动态取舍我一直觉得JVM的自适应自旋是“自旋思想”的最高级体现。它会在运行时统计每个锁的自旋成功率如果最近几次自旋之后成功抢到了锁说明当前竞争强度下自旋有效下次可以多自旋一会儿如果最近几次自旋全失败说明持锁线程短时间内根本不会释放锁于是减少甚至停止自旋直接走阻塞挂起流程。这个机制意味着开发者试图用-XX:PreBlockSpin一类的参数固定自旋次数往往是徒劳的因为JVM的动态策略优先级更高。与其折腾调参不如理解它背后的逻辑JVM会根据临界区实际耗时和竞争强度替你把短等待优化掉把长等待的线程按下去。真正需要手动写自旋锁的业务场景反而比想象中更少。5. 自旋的坑看起来简单用起来全是细节5.1 可见性标志位不volatile会怎样自旋循环的退出条件依赖其他线程修改共享状态那么这个条件变量必须正确处理内存可见性。如果只是声明一个普通boolean线程可能永远看不到其他线程的修改。原因在于JIT编译器和CPU都可能做优化把变量缓存到寄存器或高速缓存中导致读取操作长期命中旧值。处理方式无非三种加volatile修饰、使用AtomicBoolean/AtomicInteger、或者通过显式的内存屏障。回到第3.2节的代码AtomicBoolean内部就是一个volatile int包装了CAS操作所以可见性和原子性同时被满足。这里记住一句话自旋变量如果不具备正确的可见性轻则性能劣化重则死循环直接挂死。5.2 ABACAS的经典陷阱CAS有一个与生俱来的问题只检查“当前值是否等于预期值”不检查“这个值是否被修改过”。如果一个值从A变成B然后又变回A那么执行CAS的线程会认为条件一直没变成功地完成交换。大多数场景下这没有影响但如果在无锁数据结构中回收节点AB就可能让线程误操作已经不属于当前状态的节点。解决AB的经典工具是AtomicStampedReference它把“引用”和“版本号”绑定在一起每次修改都必须同时更新版本号。CAS时不仅比较引用还比较版本号因此能识别出“从A变成B又变回A”的完整过程。如果在栈、队列、链表这类需要复用对象的并发实现中做CAS强烈建议带上版本号否则AB问题迟早找上门。5.3 CPU飙升与伪共享自旋本质是空转CPU占用天然比阻塞高。但有时候你看到的现象会进一步恶化锁竞争不高、临界区也不长自旋却能拖垮整个服务。这背后的常见元凶之一是伪共享。多个线程操作的是不同变量但这些变量恰好落在同一个CPU缓存行Cache Line里修改其中一个变量会导致其他线程的缓存行失效于是每次自旋读取都触发缓存同步拖拽性能断崖式下跌。处理手段包括把热点变量填充到独立的缓存行中拆开结构体或者使用Contended注解需要JVM参数-XX:-RestrictContended开启。在实际业务中伪共享不一定是自旋锁独有但它对自旋的影响最明显因为自旋会高频读取同一个共享区。排查手段还是看火焰图如果自旋点本身不长但周围缓存同步相关的指令开销极高就往伪共享方向怀疑。5.4 退避策略让自旋从野蛮变成优雅裸自旋的问题是“死磕到底”只要没成功就疯狂循环CPU烧到冒烟。更合理的做法是引入退避策略在每一次CAS失败之后不要立刻重试而是稍微停下来一会儿。最简单的退避可以是Thread.yield()把当前CPU时间片让出去还可以是LockSupport.parkNanos(n)休眠几纳秒或几微秒再回来。退避时间一般用指数退避第一次失败等1微秒第二次等2微秒第三次等4微秒上限控制在几十微秒。这样做的核心逻辑是如果连续多次CAS都失败说明竞争正在加剧此时继续高频尝试收益极低不如稍微缓一缓给持锁线程更多执行时间。退避不是无限加大延迟因为退避过久会让锁释放后其他线程来不及抢到锁整体延迟反而上升。5.5 临界区长度自旋的唯一分水岭我踩过最大的坑是在一个临界区里放了一段不太稳定的外部调用然后自定义自旋锁。临界区正常情况下几百纳秒但偶尔会飙升到几毫秒。结果就是正常时自旋锁性能惊艳异常时所有线程都在空转CPU被打满服务雪崩。教训总结成一句话自旋只适合临界区长度稳定且极短的场景。那“极短”到底多短经验上如果临界区操作大约在几十纳秒到几微秒的量级自旋是有效的如果临界区可能执行毫秒级操作请果断放弃自旋改用ReentrantLock、synchronized或异步化方案。还有一个前置条件必须是多核环境。单核或者CPU被限制得很死的环境里一个线程自旋会挤占持锁线程的CPU时间自旋只会越等越慢。6. 实测自旋锁、synchronized与原子类怎么选6.1 实验设计把临界区控制到“极短”为了验证不同锁方案的适用边界我做了一组对比压测。场景是一个非常短的临界区一个计数器累加循环若干次后写入数组。分别用四种方案手写非公平自旋锁、synchronized、ReentrantLock、AtomicLong乐观累加。线程数从1到8递增持续运行数秒统计吞吐量。需要说明的是这里的绝对数值会受CPU型号、JDK版本、是否开启偏向锁等因素影响不同机器跑出来的结果会有差异但相对趋势是稳定的。看重的不是具体数字而是竞争强度变化时四种方案吞吐量的走向。6.2 低竞争场景自旋锁赢在没有上下文切换低竞争1~2线程时临界区极短锁很快被释放。自旋锁和AtomicLong表现都很好因为大部分线程在自旋一两次后就抢到了锁几乎没有任何上下文切换synchronized在偏向锁或轻量级锁阶段表现也不错ReentrantLock因为AQS里包含队列节点维护逻辑吞吐量略微低一些。竞争强度手写自旋锁synchronizedReentrantLockAtomicLong低竞争1~2线程高高中高高中竞争4线程中高中高中高高竞争8线程以上低、CPU高中、CPU稳中、CPU稳中高、CPU偏高低竞争场景下手写自旋锁的优势在于省掉了锁升级过程中的Mark Word操作和队列节点维护但这只是理论上的最优态。实际开发里这个量级的性能差距往往不值得付出手写锁的维护成本用原子类甚至AtomicLong就够了。6.3 高竞争场景自旋锁把CPU烧爆当线程数升到8以上情况彻底反转。手写自旋锁的吞吐量快速下滑因为大量线程同时空转缓存一致性流量暴涨CPU时间都花在无效的CAS重试上持锁线程反而得不到足够的CPU时间执行临界区。synchronized和ReentrantLock则切换到阻塞挂起模式吞吐量下降但CPU占用稳得住。这个结果揭示了一个反直觉的事实在临界区极短、但竞争极其激烈的情况下自旋锁可能比阻塞锁更差。因为自旋的CPU开销是线程数级别的线程越多空转消耗越大而阻塞锁把等待线程挂起CPU资源被留给真正能干活的任务。高竞争下的最优方案往往是乐观原子类或更细分的数据结构比如LongAdder这类通过热点分离降低CAS冲突的实现。6.4 我最后留下的选型决策口诀经过这一轮测试和线上排查我给自己总结了一套判断顺序先问临界区有多短毫秒级直接排除自旋微秒级看竞争强度竞争低可以用自旋竞争高优先原子类纳秒级才值得考虑手写自旋锁。然后问业务能不能容忍CPU占用飙升不能容忍就老老实实让别人阻塞。最后问代码维护成本能不能接受自旋锁的正确性完全依赖开发者对内存模型的理解交给团队里每个同事评审的成本往往比性能提升本身更高。如果确实需要调整自旋行为优先考虑JVM层面的锁策略参数比如旧版本JDK里的-XX:BiasedLockingStartupDelay0来提前启用偏向锁。但要记住这些都是具体JDK版本高度相关的配置升级JDK后很可能失效或被移除不能写进上线依赖项。代码设计选型永远比调参数更可靠。最后再分享一点我的心法遇到锁竞争问题不要第一反应就换自旋锁。先用jstack和CPU采样确认热点再用一个朴素的AtomicLong或ConcurrentLinkedQueue验证并发模型是否合理。自旋更像一把锋利的小刀在临界区短到极致时快得惊人但刀柄上没有安全锁拿不稳就容易割伤手。用好它之前先学会在什么时候应该把刀放下。