线程一多,锁竞争就成了避不开的话题。Java开发里但凡涉及并发,synchronized和ReentrantLock基本是默认答案,但很多人没意识到,这俩锁内部都用到了一个共同的基础机制——自旋。更直白点说,你在面试里背过的CAS、AQS、LongAdder,甚至是synchronized的轻量级锁升级过程,背后全是自旋在撑着。
这篇东西我打算从自旋锁的底层逻辑讲起,把CAS、AtomicInteger、CLH锁这些概念串成一条线,再带你手写一版可以实际落地的自旋锁,最后把自旋锁在JVM和并发容器里的真实应用场景扒一遍。不管你是被“Java面试八股文”折磨的求职者,还是想把并发性能再抠一抠的开发者,这篇文章都能给你一些能直接用上的东西。
1. 先搞清楚:自旋锁到底在解决什么问题
1.1 从“线程阻塞”的代价说起
要理解自旋锁,得先明白没有它会怎样。
假设你有一个共享计数器,两个线程要同时加一。最粗暴的方式是加个synchronized,线程A持有锁,线程B就在锁外面等着。这个“等着”在JVM层面的实现是:线程B被挂起(park),等锁释放后再被唤醒(unpark)。听起来很常规,对吧?问题在于,线程挂起和唤醒涉及操作系统内核态和用户态的切换,JVM还要做线程状态的维护和调度,一次完整的切换可能要消耗几微秒。如果临界区的代码只有几条指令,执行时间只有几十纳秒,那花费在阻塞和唤醒上的开销反而远大于实际业务操作的开销。
这就好比你去ATM取钱,操作只需要30秒,但你来回路上花了两个小时。真没必要。
自旋锁的思路完全不一样:线程发现锁被占用了,不做上下文切换,就在原地死循环,一遍一遍地去试锁有没有释放。整个过程线程状态始终是RUNNABLE,不涉及内核态切换,等锁的线程就像在ATM前排队的另一个人,死死盯着前面那个人,他一起身你立刻补位。
这两种策略的本质区别在于,阻塞锁用“让出CPU”换“不浪费CPU”,自旋锁用“占着CPU”换“不切换上下文”。场景适合的时候,自旋锁能把锁竞争的开销下降一个数量级。
1.2 自旋锁的适用边界
但自旋锁不能无脑用,它有个致命弱点:如果临界区代码执行时间太长,自旋等待的线程就会白白占着CPU核心,造成严重的计算资源浪费。
一般建议的判断标准是两条。第一,临界区要极短。比如对某个volatile变量做CAS操作、设置一个Flag、判断一个状态位,这类操作几乎瞬间完成,自旋等待很快就能撞上锁释放,收益巨大。第二,锁竞争不激烈。如果几十个线程同时在抢一个锁,每个线程都死循环自旋,那就是一场灾难,CPU会被打满,吞吐量反而暴跌。
这也解释了为什么Java里的高级锁普遍采用“先自旋、再阻塞”的组合策略——短等待时用自旋扛住,长时间等不到就用Park挂起,两头的好处都占。JVM里synchronized的锁膨胀过程就是这套逻辑,后面会细讲。
2. 原理拆解:从CAS到各种自旋锁实现
2.1 CAS和自旋锁的关系
自旋锁的前提是有一个“锁标志”,线程反复去尝试获取它。用什么操作来尝试?最底层就是CAS(Compare And Swap)。
CAS的过程用伪代码看是这样的:
public class SimulatedCAS { private volatile int value; public synchronized int compareAndSwap(int expectedValue, int newValue) { int oldValue = value; if (oldValue == expectedValue) { value = newValue; } return oldValue; } }看到那个synchronized没有?这仅仅是为了模拟CAS的原子性。真实场景中,CAS由CPU指令直接保证原子操作(x86平台对应的就是LOCK CMPXCHG指令),不需要加锁。CAS的核心语义是:只有当前内存值和预期值一致时,才把值更新为新值,否则不做操作,最后返回内存中的旧值。整个过程是一个不可分割的原子操作。
有了CAS,实现一个自旋锁就非常简单了:
import java.util.concurrent.atomic.AtomicBoolean; public class SpinLock { private final AtomicBoolean locked = new AtomicBoolean(false); public void lock() { // 自旋等待:CAS失败就一直重试,直到成功为止 while (!locked.compareAndSet(false, true)) { // 自旋中,可在这里适当做点别的事情(比如Thread.onSpinWait) } } public void unlock() { locked.set(false); } }这个代码就是自旋锁的最基本形态。compareAndSet(false, true)的含义是:如果当前布尔值是false(锁没人持有),就把它改成true(我拿到了锁),返回true退出循环;如果已经是true了,说明锁被别的线程占着,CAS失败,继续循环。
很多初学者会疑惑,就这么个while循环,性能能好到哪去?关键在于:当锁竞争不激烈时,CAS操作消耗极低,正在持锁的线程也很快就会释放,自旋的线程平均只需要重试几次就成功了。相比线程挂起和唤醒的昂贵消耗,这几轮CAS的成本几乎可以忽略不计。
2.2 TicketLock:给自旋排序,解决“饿死”问题
上面那个简单自旋锁有个很大的问题:不公平。多个线程同时自旋抢锁,CPU调度到谁、谁先抢到,完全是随机的,理论上一个线程可能永远抢不到锁,也就是“饿死”。
工程上常用TicketLock来解决这个问题。它的思路很像餐厅排队取号:每个线程来的时候先拿一个号码,持锁线程释放时递增“当前服务号码”,只有号码和当前服务号码相等的线程才能进入临界区。
import java.util.concurrent.atomic.AtomicInteger; public class TicketLock { private final AtomicInteger serviceNum = new AtomicInteger(0); private final AtomicInteger ticketNum = new AtomicInteger(0); public int lock() { // 取号:拿一个自增的号码 int myTicket = ticketNum.getAndIncrement(); // 自旋:等我的号码被服务到 while (serviceNum.get() != myTicket) { // 自旋等待 } return myTicket; } public void unlock(int myTicket) { // 服务下一个号码 serviceNum.compareAndSet(myTicket, myTicket + 1); } }注意这里的unlock要求传入lock时返回的号码,因为同一个线程可能多次调用lock,得确保释放的是自己的号。这个“按序服务”机制保证了公平性:先来的人号码小,一定先被服务到,不会出现饿死。
2.3 CLH锁:把自旋放到“前驱节点”上
TicketLock虽然公平,但有个性能隐患:所有线程都在自旋读取同一个serviceNum变量。在多核CPU下,这个变量会被频繁写到各个核心的缓存行,导致缓存一致性流量爆炸,也就是所谓的“缓存乒乓”。
CLH锁换了一种玩法。它维护一个隐式的链表,每个线程持有一个QNode节点,通过prev指向前一个节点。每个线程不再自旋读公共变量,而是只自旋读自己前驱节点的locked字段。前驱节点释放锁时把自己的locked改成false,后一个节点立即感知到。
import java.util.concurrent.atomic.AtomicReference; public class CLHLock { private final ThreadLocal<QNode> myNode = ThreadLocal.withInitial(QNode::new); private final AtomicReference<QNode> tail = new AtomicReference<>(new QNode()); public void lock() { QNode node = myNode.get(); node.locked = true; // 把当前节点放到队尾,返回前一个节点 QNode pred = tail.getAndSet(node); // 自旋,等前驱节点释放 while (pred.locked) { // 自旋等待 } } public void unlock() { QNode node = myNode.get(); node.locked = false; // 帮助GC回收,让前驱节点指向自己可以复用 myNode.set(new QNode()); } static class QNode { volatile boolean locked; } }这段代码看似有点绕,但思路其实很清晰:每个线程进来就把自己节点追加到队尾,然后盯着自己前驱的locked。由于每个线程只读“别人刚写过的那个变量”,且写顺序是天然的链表顺序,缓存一致性压力被分散了。CLH锁也是Java AQS中AbstractQueuedSynchronizer设计的重要灵感来源,虽然AQS实际用的是阻塞加自旋的组合,但排队思路是相通的。
这套从“简单自旋”到“TicketLock”再到“CLH锁”的演进路线,就是面试里经常考的“并发优化三板斧”:解决原子性、解决公平性、解决缓存一致性。理解了每一层为什么存在,那些八股题根本不用背。
3. Java里那些你天天在用的自旋锁
3.1 synchronized的锁膨胀与自旋
Javac编译时,synchronized会被编译为monitorenter/monitorexit指令。在HotSpot虚拟机里,锁的状态有一个著名的升级路径:无锁 -> 偏向锁(JDK15后被默认禁用) -> 轻量级锁 -> 重量级锁。
关键是轻量级锁阶段。线程进入同步块时,JVM会在当前线程栈帧中创建锁记录(Lock Record),然后尝试用CAS把对象头里的Mark Word替换为指向锁记录的指针。如果CAS成功,轻量级锁就拿到了。如果失败,说明锁被别的线程占着,JVM不会立刻把线程挂起,而是让它自旋一段时间,反复尝试。只有自旋超过一定次数(或者自旋线程数过多)后,锁才会膨胀为重量级锁,走阻塞唤醒的老路。
这就是为什么很多人说“synchronized在竞争不激烈时性能很好”——因为它的默认策略是先在用户态自旋,只有确实等不到了才进内核态。如果你的临界区很快就执行完,重量级锁的昂贵开销从头到尾都不会发生。
3.2 AQS同步队列里的自旋
ReentrantLock、CountDownLatch、Semaphore这些工具,底层全依赖AQS。AQS维护了一个CLH变体的等待队列,但在acquireQueued方法里,线程并不是一上来就park,而是先尝试CAS获取锁,失败后再把线程包装成Node放入队列,然后进入一个循环,循环里会检查前驱节点状态,如果前驱节点是HEAD且CAS成功,就说明拿到锁了,否则判断是否应该挂起。
简化后的AQS循环长这样:
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); // 前驱是head,尝试获取锁 if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 获取锁失败,检查是否需要park if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }注意这个for(;;)死循环,本质上就是自旋。和裸自旋的区别是:AQS不会让所有线程都无脑占用CPU,只在shouldParkAfterFailedAcquire返回true时才真正挂起。换句话说,AQS把“自旋”作为快速路径,把“阻塞”作为兜底路径,两者结合,既有自旋的低延迟优点,又能避免长时间自旋浪费CPU。
3.3 LongAdder和ConcurrentLinkedQueue中的自旋
JDK8加入的LongAdder在高并发计数器场景下全面碾压AtomicLong,靠的是“分段累加”。它内部维护一个Cell[]数组,每个线程通过ThreadLocalRandom散列到不同的Cell槽位,用CAS去累加自己的槽位,最后sum()时把所有槽位加起来。
这里的核心操作是casBase和casCell,都是标准的for(;;)写循环,也就是自旋CAS。和单一AtomicLong相比,LongAdder把竞争从“一个热点变量”分散到了“多个槽位”,每一个槽位上的冲突概率都大大降低,整体吞吐量自然就上来了。
ConcurrentLinkedQueue更典型。它的offer和poll方法全程基于CAS操作对链表节点指针做修改,用for(;;)循环把线程原地“黏”在操作上,直到成功为止。这也是无锁数据结构最典型的实现范式,完全依赖自旋CAS,完全不需要锁。
说到这里,有个很容易被忽略但非常关键的类:Thread.onSpinWait(),JDK9之后才加入。它做的事是给CPU发一个PAUSE指令提示——告诉处理器当前线程在自旋,可以让流水线更高效,同时降低功耗。强烈建议你在自旋循环里加上这一行,收益很小但几乎零成本:
while (!locked.compareAndSet(false, true)) { Thread.onSpinWait(); }4. 手写一个能上生产的自旋锁
4.1 需求分析:可重入、限时等待、等待提示
自己写着玩的自旋锁可以直接用一个AtomicBoolean完事,但真要放到项目里,至少得考虑三个问题:
第一,可重入。synchronized和ReentrantLock都支持同一个线程多次获取同一把锁。如果自旋锁不支持可重入,一个方法在持有锁的情况下调用另一个加锁方法,第二个lock()会感知到锁已被持有(还是自己持有的!),直接死锁。解决方式是在锁对象里记录持有者线程和重入计数,每次lock时先判断持有者是不是自己,是就计数加一,不是才自旋。
第二,限时等待。若某个线程持锁后卡死或执行特别久,其他线程就会无限自旋,CPU被白白耗尽。工程上应该加超时机制,如果自旋超过阈值,直接放弃并返回失败,避免地毯式自旋耗死系统。
第三,等待提示。Debug和监控时需要知道当前有多少线程在等锁,这个可以通过一个AtomicInteger的waiters计数器来做。
4.2 代码实现:ReentrantSpinLock
综合以上几点,我写了一个相对完整的可重入自旋锁:
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; public class ReentrantSpinLock { private final AtomicReference<Thread> owner = new AtomicReference<>(); private final AtomicInteger count = new AtomicInteger(0); private final AtomicInteger waiters = new AtomicInteger(0); public void lock() { Thread current = Thread.currentThread(); // 可重入:如果当前线程已经是锁持有者,直接增加计数 if (owner.get() == current) { count.incrementAndGet(); return; } waiters.incrementAndGet(); int spinCount = 0; while (true) { // 自旋等待直到成功 if (owner.compareAndSet(null, current)) { count.set(1); waiters.decrementAndGet(); return; } // 每自旋一定次数做一次调度让步或短暂休眠 if (++spinCount >= 100) { spinCount = 0; // 让出CPU时间片,避免单核机器上一直空转 Thread.yield(); // 极端情况下可以LockSupport.parkNanos(1),但会引入少量阻塞开销 } else { Thread.onSpinWait(); } } } public void lockWithTimeout(long millis) { Thread current = Thread.currentThread(); if (owner.get() == current) { count.incrementAndGet(); return; } long deadline = System.currentTimeMillis() + millis; waiters.incrementAndGet(); try { while (System.currentTimeMillis() < deadline) { if (owner.compareAndSet(null, current)) { count.set(1); return; } Thread.onSpinWait(); } // 超时,放弃获取锁 throw new RuntimeException("Acquire lock timeout"); } finally { waiters.decrementAndGet(); } } public void unlock() { Thread current = Thread.currentThread(); if (owner.get() != current) { throw new IllegalMonitorStateException("Not owner"); } int c = count.get(); if (c == 1) { // 最后一个重入层级,释放锁 owner.compareAndSet(current, null); count.set(0); } else { count.set(c - 1); } } public int getWaiterCount() { return waiters.get(); } }几个关键点值得说:
Thread.onSpinWait()放在自旋循环里,前面已经提过,这里是标准用法。- 每100次自旋后调用
Thread.yield(),这是一个非常实用的折中策略。纯自旋在单核机器上会耗尽唯一CPU,导致持锁线程根本得不到调度;适当yield能给持锁线程让路,整体反而更快。不过别太频繁,不然自旋的延迟优势就没了。 lockWithTimeout解决了“持锁者死循环导致全体自旋爆炸”的隐患。真实线上环境里,限时获取是自旋锁能上生产的一道保险丝。
4.3 压测思路:什么时候自旋锁真的更快
写完了要验证。我建议你做一个简单的压测:一个共享变量,N个线程各自执行100万次递增。分别用synchronized、ReentrantLock、AtomicLong、ReentrantSpinLock跑一遍,观察吞吐量和CPU占用。
真实测下来你会发现,线程数少(比如2~4个)、临界区短(只有一次CAS或一次赋值)的场景下,自旋锁和AtomicLong的吞吐量明显高于synchronized和ReentrantLock。但线程数一多,比如32个线程同时抢一把锁,自旋锁的吞吐量会断崖式下跌,CPU打到接近100%,远超阻塞类锁的CPU占用。
所以我的结论很明确:自旋锁适合在线程数可控、临界区极短、且对延迟敏感的场景下替代传统锁。如果线程数不可控,优先选择JUC提供的标准并发工具。
5. 实战避坑:自旋锁的正确打开方式
5.1 我会在哪些场景用它
实际项目里,我很少裸写自旋锁,大量使用的地方是以下三个:
第一,状态标志位。比如一个任务的初始化状态,多个线程只需要确认“是否已经初始化完成”。用一个volatile boolean或AtomicBoolean,配合自旋读取,比用Condition(条件变量)优雅得多,因为这里几乎没有长时间等待的可能。
第二,单写者多读者的标志更新。比如缓存项失效、配置热更新,多个读线程自旋检查一个版本号,版本号变化就重新加载。这种场景等待时间极短,自旋开销远低于线程唤醒。
第三,补偿式重试。例如操作数据库或远程接口时,依赖CAS或版本号做乐观锁,失败后进行有限次数的自旋重试。本质上也是自旋锁思想在业务层的应用,只不过不是锁而是“重试”。
用自旋锁而不是AQS锁,唯一的理由就是快。任何不能明确说“快在哪儿”的场景都不应该用自旋锁。
5.2 自旋锁最常见的坑
可重入问题前面提过了,不再重复。另一个大坑是线程优先级反转。假设线程A优先级很低,先拿到了锁,线程B优先级很高,一直在自旋等待。如果CPU调度策略偏向高优先级线程,低优先级的A可能迟迟得不到调度,B就一直空转。这种情况下自旋锁的性能和公平性都会出问题,JUC里的ReentrantLock可以设置fair=true来缓解,自旋锁没有这个能力。
还有一个隐藏问题:可见性。自旋循环里读取的锁状态变量,必须是volatile修饰的,或者通过AtomicXxx保证。如果你手写一个普通boolean,没加volatile,自旋线程可能永远看不到别的线程对它的修改,直接死循环。这个坑我用血泪教训验证过,排查了整整一下午。
另外要提醒的是,unlock()里不要用强一致性set(false)以外的操作。比如为了性能改成lazySet(false),在部分CPU上可能导致后一个线程的CAS提前成功,破坏互斥性。别在这上面做没必要的优化,直接用set最稳。
5.3 线上排查:自旋导致CPU飙高的识别方式
如果你发现线上某个服务CPU使用率异常高,top -H显示一堆线程处于RUNNABLE状态,jstack线程栈里有一堆循环等待的栈帧,比如卡在sun.misc.Unsafe.compareAndSwapInt或者自定义自旋方法上,就基本可以判断是自旋过度。
排查思路是这样的:先抓jstack样本,连续多抓几次(比如间隔1秒抓3次),看同一批线程是否一直卡在同一个自旋位置。如果是,说明这些线程长时间拿不到锁。基本可以断定临界区执行时间异常变长,或者持锁线程已经死掉、阻塞在外部IO上。
处理方式分三步:第一,检查持锁线程状态,是不是调用了阻塞式的网络/IO操作;第二,评估临界区是否过长,把不需要持锁的耗时操作挪出去;第三,如果确实是极短临界区但竞争极烈,考虑用LongAdder或分段结构替代单一热点锁。
6. 面试八股:自旋锁高频考点与速查表
6.1 “Java怎么保证数据一致性”这类问题的答案里一定有它
面试里最常被问到的“Java怎么保证数据一致性”,背后真正考的就是原子性和可见性。自旋锁配合CAS就是保证原子性的经典手段。正确的回答路径是这样的:
- 先讲
volatile只保证可见性和有序性,不保证原子性; - 再讲
AtomicInteger等原子类基于CAS自旋保证原子性,比如incrementAndGet()底层就是for(;;)循环里调compareAndSet; - 然后延伸到
synchronized和ReentrantLock,它们也会在JVM/AQS层面先使用自旋优化,自旋失败才挂起; - 最后可以提一句,自旋的本质是靠CPU原子指令(x86上的
LOCK CMPXCHG)+ 用户态重试来避免昂贵的线程上下文切换。
每说一步,面试官都能感觉到你不是在背八股,而是真正理解了一层一层为什么要这么设计。
6.2 synchronized、ReentrantLock、自旋锁对比速查表
下面这个表是我自己整理的高频对比,面试前扫一眼很有用:
| 对比维度 | synchronized | ReentrantLock | 手写自旋锁 |
|---|---|---|---|
| 锁升级 | 无锁/偏向/轻量/重量 | 无升级,直接AQS | 无 |
| 底层阻塞 | 自旋+重量级Monitor | 自旋+CAS+Park | 纯自旋 |
| 可重入 | 支持 | 支持 | 需自己实现 |
| 公平性 | 非公平 | 默认非公平,可选公平 | 默认不公平,可做 TicketLock |
| 中断响应 | 不支持 | 支持 | 需自己实现 |
| 适用临界区 | 不限 | 不限 | 极短 |
| 高竞争下风险 | 无 | 无 | CPU耗尽 |
| 是否建议业务代码直接使用 | 建议 | 建议 | 谨慎 |
对比的关键点在于“是否阻塞”。synchronized和ReentrantLock都有挂起线程的能力,自旋锁没有,这决定了它在高竞争场景下会“死扛到底”。这个差异面试时一定要讲清楚。
6.3 关于AQS的三连问
围绕自旋,AQS还有几个经典追问:
acquireQueued里为什么用for(;;)而不是直接lock? 因为AQS希望线程在抢锁失败后先自旋转一会儿,成功了就不必进入昂贵的park。直接lock()意味着立即放弃当前CPU,在短临界区场景下是巨大的浪费。
shouldParkAfterFailedAcquire是干什么的? 它检查前驱节点的waitStatus。如果前驱节点已经进入等待状态,当前线程就可以放心地park了。这保证了线程不会盲目自旋,只有确认“前方塞车”时才停下来等待。
AQS为什么用双向队列而不是简单的单向链表? 因为AQS支持取消排队。当一个线程被中断或超时,它需要从等待队列中移除自己,如果没有前驱指针,就没法高效的修改前驱节点的next指向。这也是CLH锁单纯前驱自旋方案在Java工程实现中被改造的原因之一。
这些追问的答案,本身就是从“为什么自旋”发散出去的,能答好这一串,说明你对并发的理解已经是合格线以上了。
我在实际项目里用过自旋锁的地方,基本都是一些“状态位判断”和“短暂重试”的场景,收益确实明显。但也踩过不少坑,最典型的就是忘了处理可重入性,线上直接出现诡异死锁。后来我给自己定了个规矩:自旋锁只在能说清楚“临界区为什么足够短”的情况下才用,其余一律交给JUC的标准并发工具。这个习惯帮我挡掉了不少麻烦。如果你准备在项目里引入自旋锁,建议先压测、再灰度、最后上线,别拿生产环境的稳定性去赌性能收益。