
刚开始做 Java 并发编程的时候对锁的理解基本停留在“用 synchronized 保证线程安全”这一步。直到有一次在压测环境里跑一个高频交易模拟程序发现线程一多吞吐量反而往下掉CPU 也飙到满负荷才意识到 synchronized 在锁竞争激烈时会有大量线程被挂起、再唤醒。频繁的上下文切换吞掉了大部分性能那个阶段才开始真正关注“自旋”这个概念也理解了为什么 JUC 里大量组件都在用 CAS 自旋这套组合拳。后来面试、写框架代码、排查线上问题都和自旋打过不少交道。这篇把我在实践中对 Java 自旋锁的理解、踩过的坑和一些可以“抄作业”的思路完整写下来。1. 自旋锁解决的到底是什么问题1.1 一次状态切换的成本到底有多高先做个简单的估算。当一个线程拿不到锁时如果走阻塞挂起的方式操作系统需要把这个线程从运行态切换到阻塞态这意味着要保存当前线程的上下文、寄存器状态、程序计数器还要让出 CPU。等锁释放了线程又要从阻塞态切回运行态恢复上下文重新被调度器选中。线程由操作系统调度这个过程本身就非常昂贵线上排查的经验告诉我一次典型的线程阻塞唤醒操作通常需要消耗几微秒到十几微秒。如果是几十个线程同时竞争一把锁这个成本会被放大得很可观。更隐蔽的坑在于挂起的线程可能要在操作系统的等待队列里排队顺序还不一定是公平的甚至会出现“被唤醒的线程再次拿不到锁又重新睡过去”的情况。这种线程颠簸的浪费在短临界区场景下尤其难受——本来临界区里的代码执行只要几百纳秒结果为了一个纳秒级别的操作要付出微秒级别的挂起唤醒代价明显不划算。自旋锁换了一种思路拿不到锁的时候不让出 CPU而是原地打转、反复尝试获取。锁释放之后自旋的线程可以立刻抢到执行机会省掉了操作系统层面的线程切换开销这正是“用 CPU 时间换响应时间”的典型思路。1.2 自旋不是万能的别把场景选错了自旋的前提必须是临界区极短短到线程自旋一小会儿就能等到锁释放。如果临界区代码逻辑很重比如里面有数据库查询、IO 操作线程就会在空转中消耗大量 CPU 周期。我见过有人把一段执行几十毫秒的业务代码包进自旋锁保护范围内结果就是多核 CPU 全部打满业务响应越来越慢。所以理解自旋锁的正确姿势是先认清它适用的边界锁持有时间短、线程数量相对有限、等待锁的线程不需要做复杂计算。一旦偏离这个边界自旋就变成了灾难。注意不要以为看到“自旋”就代表高性能。Java 自带 synchronized 在 JDK 1.6 之后也做了锁升级优化轻量级锁和偏向锁底层就在使用自旋逻辑但针对的同样是极短临界区。2. 从 CAS 到自旋锁的底层逻辑2.1 CAS 是如何做到原子比较并交换的自旋锁的核心依赖是 CASCompare And Swap比较并交换。要说清楚自旋锁绕不开这个底层原子操作。CAS 做的事情很简单拿到内存地址、预期值、新值只有当内存中的当前值等于预期值的时候才把新值写入内存整个过程是原子的否则就返回失败由调用方决定重试。Java 里对应的入口是sun.misc.Unsafe以及java.util.concurrent.atomic包下面的 AtomicInteger、AtomicReference 等组件。看一段最基本的用法AtomicInteger state new AtomicInteger(0); // 尝试从 0 变成 1成功返回 true失败返回 false boolean success state.compareAndSet(0, 1);这个compareAndSet看起来平平无奇但它实际上是所有 JUC 锁、并发工具高效运转的基础。底层的 CPU 指令是 x86 体系下的cmpxchg配合lock前缀保证多核处理器下的原子性。也就是说自旋锁的“判断、修改”不再是两步操作而是一条硬件层面的原子指令这才让循环自旋成为可能。2.2 从 CAS 到“循环尝试”的自旋模型CAS 本身只做一次尝试自旋锁做的是“CAS 失败后再次尝试”的循环。用一个最简单的不公平自旋锁模型来说明public class SpinLock { private final AtomicReferenceThread owner new AtomicReference(); public void lock() { Thread current Thread.currentThread(); // 一直循环直到抢到锁 while (!owner.compareAndSet(null, current)) { // 空转等待 } } public void unlock() { Thread current Thread.currentThread(); // 只有持有锁的线程才能释放 owner.compareAndSet(current, null); } }这个模型里有一个关键设计用AtomicReferenceThread记录当前持有锁的线程而不是用一个简单的 boolean。好处是可以在解锁时判断当前线程是否为持有者避免没有锁的线程误释放。unlock里的compareAndSet(current, null)保证了只有当前持有者才能成功释放锁。不过这个模型有个隐藏问题线程 T1 持锁期间T2、T3 都在自旋一旦 T1 释放锁T2 和 T3 会同时竞争。CAS 只能让其中一个成功另一个颗粒无收、继续自旋这就会引发“惊群”效应。虽然性能最差但它的代码最简单适合理解自旋锁本身的运作机制。2.3 volatile 与内存可见性的细节实现自旋锁时AtomicReference内部用volatile修饰了 value 字段。这个细节特别容易被忽略——volatile 保证了变量在多线程之间的可见性T1 把 owner 改成 null 后T2 在自旋循环里才能立刻感知到这个变化否则 T2 可能一直在自己的 CPU 缓存里读到旧值永远发现不了锁已经被释放。这就是 JMMJava 内存模型的价值volatile 底层使用内存屏障阻止了指令重排序同时让写操作强制刷新到主内存让读操作从主内存获取最新值。自旋循环里的条件判断每走一次都会消费这个最新可见的值如果这里不用 volatile整个自旋逻辑就是无效的。以后你自己设计并发组件时凡是“多线程共享状态 循环读取判断”的模式都要先确认状态字段的可见性是否可靠这是自旋实现的第一道关卡。3. synchronized 内部的锁升级与自旋机制3.1 偏向锁、轻量级锁、重量级锁是怎么一回事JDK 1.6 之后synchronized 做了一轮非常关键的性能优化核心就是引入锁升级路径。锁对象不再是一上来就线程互斥而是根据实际竞争情况动态升级无锁状态——偏向锁——轻量级锁——重量级锁。偏向锁的逻辑是如果一把锁从头到尾只有一个线程访问那就让这个线程在锁对象头上记录自己的线程 ID后续进出不再需要重复竞争。一旦出现第二个线程竞争偏向锁撤销升级到轻量级锁。轻量级锁的加锁过程就是典型的自旋加锁——线程在自己的栈帧中创建锁记录用 CAS 把锁对象头部的 Mark Word 替换为指向锁记录的指针。CAS 失败说明锁被占用线程就进入自旋等待。只有自旋等待超过一定阈值或者自旋期间又有新线程加入竞争锁才会升级到重量级锁此时线程真正进入操作系统的阻塞队列。相比它的表现重量级锁要更可靠也更“重”适合临界区代码耗时较长或竞争异常激烈的场景。3.2 JVM 中自旋参数如何调整默认值是多少JVM 对轻量级锁的自旋有一个默认的等待次数上限。早期的 HotSpot 有-XX:PreBlockSpin10参数表示默认自旋 10 次后如果还没拿到锁就挂起线程。后来 JVM 引入了自适应自旋不再固定次数而是根据上一次同一把锁的自旋时间、竞争者数量等因素动态调整。实际调优时可以用以下参数-XX:UseSpinning -XX:PreBlockSpin10不过在较新的 JDK 版本里自旋策略管理得越来越智能手动调整的收益已经不大。在 JDK 11、JDK 17 的默认配置下自适应自旋通常是开启且稳定运行的。与其纠结调参数不如把目光放到锁的粒度控制上缩短持锁时间、减少不必要的锁竞争往往对性能的提升更直接、更可控。3.3 锁消除和锁粗化JIT 对自旋的额外影响JIT即时编译器在做编译优化时有时候会直接消除掉一些不会产生竞争的锁。比如一个方法内部创建的对象只在该方法内被使用没有逃逸到其他线程那么这个对象上的 synchronized 就是多余的JIT 会让它消失这就是锁消除。同理连续加锁同一对象多次时JIT 可能会把锁粗化到整个循环外面减少重复加锁解锁的开销。这两项优化反过来也说明了一个原则不要写“无脑 synchronized”但也不必为了极致的短临界区去 hack 细微的锁逻辑。JVM 的 JIT 已经在运行时帮我们做掉了大量简单优化。理解这些机制的意义在于遇到线上性能问题时不至于把时间浪费在“不该是瓶颈的锁”上面。4. AQS 与 JUC 组件里的自旋艺术4.1 AQS 是如何用自旋 CAS 封装锁状态机的真正把自旋艺术发挥到极致的是AbstractQueuedSynchronizer简称 AQS。ReentrantLock、Semaphore、CountDownLatch 这些常用并发工具内部都基于 AQS 实现。AQS 维护了一个 int 类型的state变量以及一个双向链表的等待队列CLH 队列的变种。获取锁的过程是线程通过 CAS 尝试把 state 从 0 改成 1如果成功直接作为持有者继续执行如果失败AQS 并不会立刻把线程挂起而是把线程包装成等待节点插入队列尾部然后进行一次检查——如果前驱节点是 head说明自己即将排到队首再次通过 CAS 尝试获取锁。这个过程会伴随有限次数的自旋然后才调用LockSupport.park挂起线程。源码里能看到一个典型的逻辑final boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }注意这个for (;;)循环在成功或挂起前会多次尝试快速获取锁而不是立刻让线程睡眠。这正是性能和公平性的折中线程有大概率立刻抢到锁又不会因为无限自旋浪费太多 CPU。4.2 CLH 队列锁排队自旋还是队列自旋AQS 的等待队列简化自 CLH 锁。CLH 锁的核心思想是每个等待线程不是抢同一个共享变量而是各自在前驱节点的状态字段上自旋通过前驱节点的状态变化感知自己是否被唤醒。这样一来自旋的对象被“分散”到了每个线程自己的前驱节点上减少了同一个缓存行的竞争。和最初那个多人抢一个 owner 的简单自旋锁相比CLH 队列把“一窝蜂抢同一个变量”改成了“排好队只看前一个人”缓存一致性压力显著降低。这个例子很好地说明了一件事自旋锁的工程实现需要关心 CPU 缓存层次和内存一致性协议不能只停留在理论上。4.3 LongAdder 里的“自旋扩容”思想如果说 AQS 里自旋体现在“排队获取锁”那 LongAdder 就是另一种自旋玩法分散竞争。它内部维护一个 base 值和一组 Cell 数组多线程更新同一个计数时先尝试 CAS 更新 base如果竞争激烈就会把操作分散到不同 Cell 上。在线程哈希到某个 Cell 时如果 CAS 失败还会探测相邻 Cell甚至扩容数组。这类组件的设计哲学是“减少共享变量的竞争”自旋只是实现手段。具体的多线程计数场景LongAdder 在高并发下的性能比 AtomicLong 好很多原子变量的基础就是自旋 CAS只是它在自旋的细节和冲突规避上做了更细的处理。5. 从实战角度看自旋锁的取舍与坑5.1 为什么生产环境要慎用自旋锁理论很丰满但实战中我很少直接自己写一个自旋锁放到业务代码里。原因是自旋锁对使用环境的假设太苛刻锁持有时间必须极短线程数不能太多而且最好是多核 CPU 环境。一旦环境不满足自旋就是性能毒药。看一个典型的反例在临界区里做集合遍历、数据统计、甚至文件读写然后外层用自旋锁保护。一次锁的持有时间是毫秒级别其他线程只能在多个 CPU 核上空转等待。如果正好赶上机器 CPU 核数不多空转线程会抢走持有锁线程的 CPU 资源导致临界区代码执行变慢进一步延长等待时间形成恶性循环。所以我的经验是业务代码里优先用 synchronized、ReentrantLock 这些经过大量生产环境验证的锁。自旋锁主要用于框架底层、工具类开发以及那些真正能确认“临界区只有几条指令”的场景。5.2 可重入性、公平性与饥饿问题前面贴的自旋锁代码是不可重入的。线程 T1 持有锁后如果代码里又进入了同一个锁保护的临界区第二次 lock 调用会发现自己还是拿不到锁owner 不为 null直接死锁。这在递归函数和嵌套调用里特别容易踩雷。如果要实现可重入需要记录持有者线程和重入次数public class ReentrantSpinLock { private final AtomicReferenceThread owner new AtomicReference(); private int count 0; public void lock() { Thread current Thread.currentThread(); if (owner.get() current) { count; return; } while (!owner.compareAndSet(null, current)) { } count 1; } public void unlock() { Thread current Thread.currentThread(); if (owner.get() current) { count--; if (count 0) { owner.compareAndSet(current, null); } } } }这个版本依旧是不公平的T2、T3 谁抢到算谁的可能出现线程饿死。真要保证公平性就得维护一个 FIFO 等待队列或者直接用ReentrantLock(true)这种现成方案。注意如果只是在面试题里描述自旋锁建议把可重入、公平性、ABA 问题都串起来回答。这些点很大概率会被追问到 CAS 的底层。5.3 ABA 问题与版本号机制CAS 操作还有一个经典问题线程 T1 读到值 A准备改成 B 时线程 T2 已经把 A 改成了 C又改回 A。在 T1 看来当前值仍然等于预期值 ACAS 成功。但实际上这个值已经经历过其他线程的修改。解决方案是加版本号或时间戳。比如AtomicStampedReference它维护一个对象引用和整数版本号每次修改都会增加版本号。这样即使数据变回原来的值版本号也对不上CAS 就会失败。如果自旋锁里用AtomicReferenceThread存储线程对象ABA 问题其实很难触发——因为线程对象的引用不会凭空产生但在更通用的并发容器设计中ABA 是不容忽视的。5.4 伪共享对自旋性能的致命影响搞 Java 并发绕不开伪共享False Sharing。简单说CPU 的缓存是以缓存行通常 64 字节为单位加载的。如果两个不同的变量恰好落在同一个缓存行里其中一个变量被修改会导致整个缓存行失效另一个变量所在的 CPU 核也会被迫重新加载即使那个变量根本没被修改。自旋锁里如果锁状态变量和别的热点变量挨在一起所有线程自旋读取时就会出现严重的缓存行颠簸。解决办法是填充padding或者使用Contended注解需要配置 JVM 参数-XX:-RestrictContended。LongAdder 内部 Cell 数组就是用了 Contended 来隔离缓存行避免多个 Cell 互相影响。6. 线上排查自旋相关性能问题的思路6.1 CPU 飙升时如何判断是不是自旋导致的自旋锁最典型的症状就是 CPU 使用率居高不下同时线程堆栈里大量线程处于 RUNNABLE 状态。用jstack或者 Arthas 抓线程栈如果看到很多线程卡在同一个方法循环里尤其是循环里没有 sleep、没有 wait就基本能判断是自旋了。有一次排查一个定时任务线程池的问题四个线程全部落在同一个while (!owner.compareAndSet())循环里每个线程 CPU 占用接近 100%。原因就是临界区里做了一次比较耗时的数据处理持锁时间远超预期。处理方式也简单——把耗时的数据预计算挪到锁外临界区只保留内存置位这种短操作。如果确认了自旋过热常规的优化手段有几类缩短临界区把耗时的 I/O、计算移出锁减少锁粒度用分段锁或读写锁替代大而全的互斥锁如果自旋次数过多加入退让逻辑比如让线程在空转一定次数后执行Thread.yield()或LockSupport.parkNanos(1)退让逻辑有一个折中作用它能给其他线程让出 CPU避免空转线程把持锁线程的资源抢走同时又比直接挂起线程更快响应锁释放。6.2 关于自旋次数的两个调优经验JDK 版本不同自旋参数的默认值和启用方式也在变。以 JDK 8 为例-XX:UseSpinning默认开启-XX:PreBlockSpin10是默认自旋重试次数不过一旦开启自适应自旋PreBlockSpin的影响就会减弱。我自己做压测时见过一个有价值的现象把PreBlockSpin调到 20 或者 50短临界区场景的吞吐量确实小幅提升但线程数超过 CPU 核数后反而下降。原因是自旋等待的线程多了以后线程调度和缓存竞争压力超过了上下文切换的代价。所以调参时不能只看平均时间要同时观察 CPU 占用率和线程上下文切换指标。更实用的一招是用-XX:PrintFlagsFinal看当前 JVM 实际生效的自旋参数java -XX:PrintFlagsFinal -version | grep -i spin这能帮你确认你调试的 JVM 到底支持哪些开关避免在已经移除某个参数的版本上白忙活半天。6.3 区分 JUC 组件内部自旋和代码自旋排查线上问题时还要注意一种情况JUC 组件内部本身就带自旋逻辑比如ConcurrentLinkedQueue、ThreadPoolExecutor里的 worker 获取任务都可能出现短时间的 CAS 循环。这种自旋是正常的线程栈上的 lock 也未必代表有什么问题。要区分正常自旋和异常自旋关键是看自旋代码对应的临界区到底是不是预期内的短操作。如果是ThreadPoolExecutor里的空闲 worker 用workQueue.poll()去竞争任务这种自旋通常在几十纳秒到几微秒。如果是业务代码里自己写的 while 循环等待某个外部条件满足就要重点排查是否在临界区里做了重活。7. 自旋锁的扩展方向与亲身建议7.1 从自旋锁延伸到偏向锁、轻量级锁的理解把自旋锁吃透之后你会发现 Java 并发里的很多概念其实是连通的。偏向锁通过记录线程 ID 避免重复竞争轻量级锁用 CAS 自旋换掉系统调用AQS 的 acquireQueued 在挂起前也要先自旋几轮。它们背后都是同一个权衡能在用户态解决的事情就别轻易丢给操作系统。理解了这条主线看源码时会有一种“原来如此”的通透感。比如看 ReentrantLock 源码遇到for (;;)循环不会觉得奇怪而是知道这是有意为之的自旋策略。再比如你看到Thread.yield()常见于自旋退让逻辑就不会再把它当成“让线程休息一下”的工具。7.2 自己动手实现一个带超时和退让的自旋锁如果确实需要自己定制一把自旋锁可以从以下版本开始做模板public class BackoffSpinLock { private final AtomicBoolean locked new AtomicBoolean(false); private static final int MAX_SPIN 100; private static final long MAX_WAIT 1L; public void lock() { int spins 0; while (true) { if (!locked.get() locked.compareAndSet(false, true)) { return; } if (spins MAX_SPIN) { // 自旋到一定次数后主动退让避免 CPU 空转太久 LockSupport.parkNanos(MAX_WAIT); spins 0; } } } public void unlock() { locked.set(false); } }带超时意味着锁等待不会无限期带退让则是在自旋次数达到阈值后parkNanos一小段时间让出 CPU 资源。Base on 我的经验MAX_SPIN和MAX_WAIT的值需要基于真实机器的 CPU 数、临界区耗时来调整没有一通百通的参数。7.3 面试答题时的进阶表达这个标题下有不少热搜词是“java 面试题”“java 八股文”之类看得出很多人是为了面试准备来看的。如果面试被问到“自旋锁是什么”建议不要只背结论而是按展示理解的顺序说纠正一个点自旋锁不是在等待时挂起线程而是让线程反复执行 CAS 指令去尝试获取锁适合临界区极短的场景。然后带出 CAS 底层是 CPU 的原子指令再补充 synchronized 内部通过锁升级和自适应自旋来优化性能最后提一句“AQS 的 acquireQueued 也利用了有限自旋 挂起来折中性能和公平性”。这个过程既展示了对底层原理的掌握又拉回到了 JDK 本身的设计面试官大概率会顺着追问偏向锁、CAS、AQS 队列你能接得越深越说明是真正做过并发调优而不仅仅是背题。我在实际项目中养成了一个习惯每次给并发组件调优先看锁竞争的时间分布再决定要不要碰自旋参数。大多数情况下结构性优化比如用 Disruptor 式的无锁队列、减少临界区宽度、拆分热点数据比调整自旋次数有效得多。自旋锁是一门精巧的技艺但真正重要的是理解它背后的性能哲学——用有限的自旋等待换取用户态到内核态的过渡成本降低。如果要给一条最实在的建议那就是先写好清晰的锁粒度再谈自旋参数先用压力测试验证再决定是否值得手动实现一把自旋锁。