刚开始做 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 是如何做到原子比较并交换的
自旋锁的核心依赖是 CAS(Compare 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 AtomicReference<Thread> 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); } }这个模型里有一个关键设计:用AtomicReference<Thread>记录当前持有锁的线程,而不是用一个简单的 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 缓存里读到旧值,永远发现不了锁已经被释放。
这就是 JMM(Java 内存模型)的价值:volatile 底层使用内存屏障,阻止了指令重排序,同时让写操作强制刷新到主内存,让读操作从主内存获取最新值。自旋循环里的条件判断每走一次都会消费这个最新可见的值,如果这里不用 volatile,整个自旋逻辑就是无效的。
以后你自己设计并发组件时,凡是“多线程共享状态 + 循环读取判断”的模式,都要先确认状态字段的可见性是否可靠,这是自旋实现的第一道关卡。
3. synchronized 内部的锁升级与自旋机制
3.1 偏向锁、轻量级锁、重量级锁是怎么一回事
JDK 1.6 之后,synchronized 做了一轮非常关键的性能优化,核心就是引入锁升级路径。锁对象不再是一上来就线程互斥,而是根据实际竞争情况动态升级:无锁状态——偏向锁——轻量级锁——重量级锁。
偏向锁的逻辑是,如果一把锁从头到尾只有一个线程访问,那就让这个线程在锁对象头上记录自己的线程 ID,后续进出不再需要重复竞争。一旦出现第二个线程竞争,偏向锁撤销,升级到轻量级锁。轻量级锁的加锁过程就是典型的自旋加锁——线程在自己的栈帧中创建锁记录,用 CAS 把锁对象头部的 Mark Word 替换为指向锁记录的指针。CAS 失败说明锁被占用,线程就进入自旋等待。
只有自旋等待超过一定阈值,或者自旋期间又有新线程加入竞争,锁才会升级到重量级锁,此时线程真正进入操作系统的阻塞队列。相比它的表现,重量级锁要更可靠,也更“重”,适合临界区代码耗时较长或竞争异常激烈的场景。
3.2 JVM 中自旋参数如何调整,默认值是多少
JVM 对轻量级锁的自旋有一个默认的等待次数上限。早期的 HotSpot 有-XX:PreBlockSpin=10参数,表示默认自旋 10 次后如果还没拿到锁,就挂起线程。后来 JVM 引入了自适应自旋,不再固定次数,而是根据上一次同一把锁的自旋时间、竞争者数量等因素动态调整。
实际调优时可以用以下参数:
-XX:+UseSpinning -XX:PreBlockSpin=10不过在较新的 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 AtomicReference<Thread> 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 看来,当前值仍然等于预期值 A,CAS 成功。但实际上,这个值已经经历过其他线程的修改。
解决方案是加版本号或时间戳。比如AtomicStampedReference,它维护一个对象引用和整数版本号,每次修改都会增加版本号。这样即使数据变回原来的值,版本号也对不上,CAS 就会失败。如果自旋锁里用AtomicReference<Thread>存储线程对象,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:PreBlockSpin=10是默认自旋重试次数,不过一旦开启自适应自旋,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 式的无锁队列、减少临界区宽度、拆分热点数据)比调整自旋次数有效得多。自旋锁是一门精巧的技艺,但真正重要的是理解它背后的性能哲学——用有限的自旋等待,换取用户态到内核态的过渡成本降低。如果要给一条最实在的建议,那就是:先写好清晰的锁粒度,再谈自旋参数;先用压力测试验证,再决定是否值得手动实现一把自旋锁。