
线程一多锁竞争就成了避不开的话题。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和自旋锁的关系自旋锁的前提是有一个“锁标志”线程反复去尝试获取它。用什么操作来尝试最底层就是CASCompare 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 ThreadLocalQNode myNode ThreadLocal.withInitial(QNode::new); private final AtomicReferenceQNode 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 AtomicReferenceThread 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可以设置fairtrue来缓解自旋锁没有这个能力。还有一个隐藏问题可见性。自旋循环里读取的锁状态变量必须是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、自旋锁对比速查表下面这个表是我自己整理的高频对比面试前扫一眼很有用对比维度synchronizedReentrantLock手写自旋锁锁升级无锁/偏向/轻量/重量无升级直接AQS无底层阻塞自旋重量级Monitor自旋CASPark纯自旋可重入支持支持需自己实现公平性非公平默认非公平可选公平默认不公平可做 TicketLock中断响应不支持支持需自己实现适用临界区不限不限极短高竞争下风险无无CPU耗尽是否建议业务代码直接使用建议建议谨慎对比的关键点在于“是否阻塞”。synchronized和ReentrantLock都有挂起线程的能力自旋锁没有这决定了它在高竞争场景下会“死扛到底”。这个差异面试时一定要讲清楚。6.3 关于AQS的三连问围绕自旋AQS还有几个经典追问acquireQueued里为什么用for(;;)而不是直接lock 因为AQS希望线程在抢锁失败后先自旋转一会儿成功了就不必进入昂贵的park。直接lock()意味着立即放弃当前CPU在短临界区场景下是巨大的浪费。shouldParkAfterFailedAcquire是干什么的 它检查前驱节点的waitStatus。如果前驱节点已经进入等待状态当前线程就可以放心地park了。这保证了线程不会盲目自旋只有确认“前方塞车”时才停下来等待。AQS为什么用双向队列而不是简单的单向链表 因为AQS支持取消排队。当一个线程被中断或超时它需要从等待队列中移除自己如果没有前驱指针就没法高效的修改前驱节点的next指向。这也是CLH锁单纯前驱自旋方案在Java工程实现中被改造的原因之一。这些追问的答案本身就是从“为什么自旋”发散出去的能答好这一串说明你对并发的理解已经是合格线以上了。我在实际项目里用过自旋锁的地方基本都是一些“状态位判断”和“短暂重试”的场景收益确实明显。但也踩过不少坑最典型的就是忘了处理可重入性线上直接出现诡异死锁。后来我给自己定了个规矩自旋锁只在能说清楚“临界区为什么足够短”的情况下才用其余一律交给JUC的标准并发工具。这个习惯帮我挡掉了不少麻烦。如果你准备在项目里引入自旋锁建议先压测、再灰度、最后上线别拿生产环境的稳定性去赌性能收益。