排查线上高CPU问题的时候,我经常在jstack里看到一堆线程卡在RUNNABLE状态,而不是大家熟悉的BLOCKED或者WAITING。很多人第一反应是“死循环了”,其实相当一部分概率是自旋——尤其是用了AtomicInteger、LongAdder,或者碰到JVM重量级锁竞争的时候。自旋这个东西,在Java并发知识里看起来很简单,但真正把它吃透、会用、能在面试里讲清楚,需要把CPU、JVM和并发工具库串成一条线。
这篇文章就从“自旋到底是什么”讲起,结合Java里的各种实际使用场景,再把手写自旋锁的完整演进过程拆开,最后聊一聊线上性能排查和面试中常见的问法。适合正在学并发的同学、准备Java面试的人,以及写过多线程代码但没仔细研究过锁底层逻辑的工程师。
1. 自旋到底在做什么:从一次抢锁开始
1.1 为什么线程宁可在原地转圈也不睡觉
先问一个问题:当一个线程想要获取锁,但锁正被别的线程持有,它该怎么做?
最直观的方案是“阻塞等待”:当前线程主动挂起,让出CPU,等持有锁的线程释放锁之后,再由操作系统或者JVM把它唤醒。这个方案很省CPU,但它有一个隐藏成本——线程状态切换。从运行态到阻塞态,再从阻塞态恢复到运行态,需要操作系统完成一次线程调度,涉及用户态和内核态的切换,这个开销在硬件层面往往要几十到几万个时钟周期。
如果临界区的代码很短,比如只是做一个count++、改一个标志位,锁可能只需要几百纳秒就释放了。这时候让一个线程经历“挂起-唤醒”的完整流程,反而是杀鸡用牛刀:大部分时间都浪费在调度上了,真正干活的窗口期早就过去了。
所以就有了另一种策略:线程不挂起,而是在原地循环试探锁是否被释放。这种“排空转圈、等待条件满足”的机制,就是自旋。拿生活里的场景类比:排队办理业务时,一个人选择坐在沙发上等叫号(阻塞),另一个人站在柜台旁边盯着前面的人(自旋)。如果前面的人马上就走,站着等的效率明显更高;如果前面的人要办理很久,站着等就纯属浪费时间。
自旋的核心假设是:锁的持有时间很短。在这个前提下,轮询重试比线程切换好得多。
1.2 自旋的核心模型和基础分类
用一个通用模型来描述自旋:
volatile boolean isLocked = false; while (isLocked) { // 什么都不做,或者做一点小让步,继续等 } // 抢锁逻辑线程反复读取共享状态,直到条件满足才继续往下走。这个循环里的读取必须保证可见性,否则线程可能一直读到旧值,陷入无限空转。理解了这一点,再看Java并发领域里的自旋就有框架了。
按实现方式,自旋大致可以分成两类。
一类是“纯轮询式自旋”,线程通过循环读共享变量判断条件,典型代表是手写的自旋锁,比如用volatile标志位加CAS去抢锁。这类自旋不依赖复杂的数据结构,理解起来最直接。
另一类是“CAS式自旋”,CAS本身是CPU提供的一条原子指令,用来比较并交换内存值,但单次CAS失败不等于操作结束,通常外面套一层for循环或while循环,失败就重试。AtomicInteger的incrementAndGet、AtomicReference的compareAndSet,底层走的都是这个套路。严格说,CAS不是自旋,但“CAS+Loop自旋重试”的组合是Java并发工具里最常见的形态。
这两种自旋看起来差别不大,但背后要解决的问题不一样。前者解决“等待锁释放”,后者解决“并发修改失败后重试”。把这两条线都捋清楚,才算真正理解了Java里的自旋。
2. Java并发库里的自旋,远比你想的常见
2.1 AtomicInteger的for循环,本质就是自旋CAS
很多人天天用AtomicInteger,但没意识到它就是自旋机制最典型的例子。看一下getAndAddInt在OpenJDK里的实现逻辑:
public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; }这是一个do-while循环:先读取当前值,然后通过CAS把当前值替换为目标值。如果CAS成功,循环结束;如果CAS失败,说明有其他线程抢先修改了,于是重新读取、重新尝试。
关键点在于,这个循环不是空转,它每次失败都会重新读取最新值,再用最新值去做CAS。这就是“自旋重试”的典型节奏。如果并发冲突很严重,多个线程都在抢着做累加操作,这个循环会持续执行很多次,线程始终保持RUNNABLE状态,CPU占用率自然会上来。
实际使用中,如果发现AtomicLong的累加操作在高并发下性能下降,多半不是CAS本身慢,而是自旋的失败率太高了。每失败一次,就多一次无谓的循环消耗。这也是为什么在高并发统计场景里,很多人会用LongAdder替代AtomicLong——目的就是通过分段降低冲突,让CAS的自旋次数降下来。
2.2 LongAdder和StampedLock里藏着的自旋锁
LongAdder设计得比AtomicLong聪明:它内部维护了一个Cell数组,把竞争分散到多个桶上。初始只有一个Cell,访问时和AtomicLong差不多,都是CAS自旋。但竞争激烈的时候,它会扩容,让不同的线程尽量落在不同的Cell上。
这里就有一个隐藏的自旋锁:LongAdder扩容和初始化Cell数组时,需要保证线程安全。它不像ReentrantLock那样用重量级锁,而是维护了一个cellsBusy字段,线程通过CAS把cellsBusy从0改成1来获得“扩容锁”,失败就在循环里重试。这就是一个典型的自旋锁应用,代码里甚至能看到Thread.yield()之类的让步操作,目的是给持有锁的线程腾出CPU时间片。
再看StampedLock。Java 8引入的StampedLock提供了一种乐观读机制:读线程先尝试读取StampedLock的state,如果发现写锁没有被占用,就直接执行临界区代码;只有在真正发现写操作介入时,才升级为普通的读锁。
在StampedLock的readLock源码里,有一段经典的for循环:读锁会通过CAS修改state,如果失败不会立刻进入阻塞队列,而是会先自旋一小段时间,观察写锁是否马上释放。这个自旋过程让StampedLock在“读多写少”的场景下比ReentrantReadWriteLock快不少。但它也有代价:StampedLock不是可重入锁,而且自旋不当会导致CPU飚高,使用时要非常小心。
2.3 Thread.onSpinWait:告诉CPU“我在高效地等”
Java 9开始提供了一个专门的方法:Thread.onSpinWait()。这个方法放在自旋循环体内部,告诉CPU“当前线程在自旋等待,请做相应优化”。
不要小看这个调用。在x86平台上,它最终会编译成PAUSE指令。这条指令的作用有两个:一是降低自旋循环带来的功耗,避免CPU执行单元满负荷空转导致发热;二是减少内存顺序冲突,避免因为自旋循环频繁读写内存导致其他核心的内存访问被拖慢。
在实际手写自旋锁的时候,循环体里加一行Thread.onSpinWait()是一个非常好的习惯,代码可读性和底层执行效率都会提升。当然,如果用纯Java的synchronized和并发工具类,JVM已经在内部做了类似优化,不需要你手动干预,但自己实现锁的时候一定用上。
3. JVM自己也没闲着:锁升级路径里的自适应自旋
3.1 synchronized锁升级:我该在哪个阶段看到自旋
Java 6之后,synchronized不再是一上来就给线程加重量级锁,而是做了一系列优化,形成了“无锁-偏向锁-轻量级锁-重量级锁”的升级路径。
偏向锁负责处理“只有一个线程反复进入临界区”的情况,不需要CAS操作。一旦出现第二个线程竞争,偏向锁会撤销,升级为轻量级锁。轻量级锁靠CAS把对象头里的Mark Word替换成指向锁记录的指针来抢锁,抢不到就说明竞争升级,开始膨胀为重量级锁。
那自旋出现在哪里?重要的一点是:重量级锁监视器(ObjectMonitor)的进入过程里,JVM会先让竞争线程做一次有限次数的自旋,尝试在阻塞前拿到锁。为什么要这样?因为线程从运行态转为阻塞态的成本太高,如果锁很快就被释放,自旋就能避免这次状态切换。这段逻辑在HotSpot虚拟机的ObjectMonitor::enter、以及后续的Adaptive Spinning策略里都有体现。
还有一个角度看轻量级锁:轻量级锁的CAS操作如果失败,并不代表没机会了,在某些实现流程里线程会先自旋几轮,观察持有锁的线程是否马上释放。如果锁竞争仍然激烈,才进入锁膨胀流程。整个设计思路是:能不阻塞就不阻塞,能少切换就少切换。
3.2 自适应自旋:JVM的“动态旋回数”
早期JVM的自旋次数是固定的,通常默认1000次。如果一个锁平均要自旋5000次才能拿到,固定值就显得不够;如果锁平均自旋100次就能拿到,固定值又白白烧掉了900次CPU。
自适应自旋改变了这种“一刀切”的做法。JVM会记录每个锁对象上“拿到锁的线程自旋了多少次”的历史数据,以及最近一次自旋是否成功。如果过去自旋成功率很高,JVM就允许下一次自旋更多次;如果成功率不高,就减少自旋次数,甚至直接跳过自旋去阻塞。
这在操作系统里其实是个很朴素的思想,类似缓存命中率统计。虽然是JVM内部的黑盒机制,但面试时能把这个逻辑讲出来,往往是加分项。
3.3 容易被忽略的线索:自旋中线程状态是RUNNABLE
做性能排查时,一个非常实用的观察点是:阻塞中的线程状态通常是BLOCKED或WAITING,但自旋中的线程永远是RUNNABLE。
所以当你用jstack导线程栈时,看到大量线程处于RUNNABLE状态,而且栈顶停在某个CAS或循环上的时候,别急着断定是死循环,先想想是不是自旋。这两个方向,一个指向代码逻辑Bug,一个指向锁竞争过热,排查路径完全不一样。
4. 手写自旋锁:从能跑到能用的进阶
4.1 第一版:基于AtomicBoolean的简易自旋锁
理论说再多,不如手写一遍。先看最基础的自旋锁:
public class SimpleSpinLock { private final AtomicBoolean locked = new AtomicBoolean(false); public void lock() { while (!locked.compareAndSet(false, true)) { // 自旋等待,什么都不干 } } public void unlock() { locked.set(false); } }这个锁能跑,但它有两个明显问题。
第一,它不是可重入的。如果一个线程在持有锁的情况下再次调用lock(),CAS会失败,线程把自己永远锁死了。这在业务代码里很难避免,因为一个方法可能嵌套调用另一个加锁方法。
第二,循环里没有让出CPU的技巧,所有等待线程都在全速空转,对超线程和功耗不友好。可以加入Thread.onSpinWait()做一些改善。
4.2 进阶版:可重入自旋锁
处理可重入性,需要记录持有锁的线程,以及重入次数:
public class ReentrantSpinLock { private final AtomicReference<Thread> owner = new AtomicReference<>(); private int holdCount = 0; public void lock() { Thread current = Thread.currentThread(); if (current.equals(owner.get())) { holdCount++; return; } while (!owner.compareAndSet(null, current)) { Thread.onSpinWait(); } holdCount = 1; } public void unlock() { Thread current = Thread.currentThread(); if (current.equals(owner.get()) && holdCount > 0) { if (--holdCount == 0) { owner.set(null); } } } }这个版本的核心改进在于:owner字段用AtomicReference存储持有锁的线程,同一个线程重复进入时不走CAS竞争,直接增加计数。unlock时先把计数减到0,才真正把owner清空。注意,重入计数必须是普通的int,因为只有持有锁的线程才会修改它,不需要额外原子化。
不过,这套代码还有公平性问题:如果多个线程同时等待锁,谁能抢到完全靠运气,不会按照到达顺序排队。线程饥饿是可能出现的。
4.3 公平版:TicketLock
要保证公平性,可以考虑TicketLock。它的思路和银行取号一样,每个线程先取一个号码,然后等待叫号,号码递增顺序就是获取锁的顺序。
public class TicketLock { private final AtomicInteger ticketNumber = new AtomicInteger(0); private final AtomicInteger servingNumber = new AtomicInteger(0); public int lock() { int myTicket = ticketNumber.getAndIncrement(); while (servingNumber.get() != myTicket) { Thread.onSpinWait(); } return myTicket; } public void unlock(int myTicket) { servingNumber.compareAndSet(myTicket, myTicket + 1); } }TicketLock保证了公平性:每个线程自旋等待的不是“同一个锁状态”,而是“自己的号码”。这个设计有一个额外好处,就是当锁释放时,只会有一个特定的线程醒来(从自己的判断条件来看),不会出现多个线程同时竞争同一个CAS,减少了缓存一致性流量。
代价是TicketLock不是可重入锁,想支持可重入还要维护一个持有者线程和重入计数。实际项目中很少直接手写这类锁,但面试时从简单版本讲到公平版本,已经足够展示对自旋锁的深入理解。
5. 实战避坑:什么时候该放弃自旋
5.1 自旋锁不适用的场景
自旋看起来很美好,但它的适用面其实很窄。第一个不适用场景是临界区执行时间长。如果锁平均持有时间超过几次线程切换的成本,所有等待线程都在空转烧CPU,整体吞吐反而更差。判断标准很简单:锁持有时间越长,越应该用阻塞锁。
第二个不适用场景是单核CPU。单核环境下,一个线程自旋等待时,持有锁的线程根本没有机会被调度执行,因为CPU被自旋线程霸占了。这种情况下自旋只会白白消耗时间片,完全等不到锁释放。虽然现代服务器基本都是多核,但在云原生容器里看到vCPU分配有限时,仍然要考虑这个因素。
第三个场景要特别注意:如果持锁线程可能因为IO等待、网络请求、锁嵌套而长时间不释放锁,外部线程自旋等于慢性自杀。业务代码里,“千万不能用自旋锁包裹一个可能阻塞的操作”是一条铁律。
5.2 自旋的坑:可见性、伪共享与ABA
关于可见性,手写自旋锁里如果只把isLocked声明成普通boolean,那么等待线程可能永远看不到持有线程对它做的修改。Java内存模型里,普通字段的写入不保证对其他线程立即可见,必须用volatile修饰。AtomicReference、AtomicBoolean本身内部就是volatile语义,所以用它们实现锁天然规避了可见性问题,但自写变量时要格外小心。
关于伪共享,自旋锁最怕的是多个线程同时高频读写同一个缓存行上的不同变量。现代CPU以缓存行(通常是64字节)为单位做一致性同步,如果两个变量恰好落在同一个缓存行里,互相之间会产生大量无谓的同步开销。Java里可以通过@Contended注解在JDK内部类里做缓存行填充,业务代码也可以给关键变量加padding,但原则上,能降低自旋频率比追求缓存行微优化更重要。
关于ABA问题,CAS自旋里的典型陷阱是:比较的值从A变成B再变成A,CAS会认为没有变化,从而错误地更新。经典场景是链表栈的并发pop操作,一个节点被弹出后地址又复用,CAS会把已经改变后的栈当成原来的栈,产生逻辑错误。解决方式是用AtomicStampedReference带上版本号。面试官问到这个点的时候,往往是想考察候选人对CAS局限性的理解。
5.3 自旋中的异常与死锁风险
手写自旋锁还有一个非常容易踩的坑:unlock被跳过。如果临界区里抛出了运行时异常,没有用try-finally包裹unlock,锁就永远得不到释放,其他线程全部进入无限自旋。
规范的写法是这样:
spinLock.lock(); try { // 临界区代码 } finally { spinLock.unlock(); }这个习惯对于任何锁都适用,但对于自旋锁尤其重要,因为等待线程不会像阻塞锁那样挂起,它们会全力空转,多等一秒就是多烧一秒CPU。
5.4 线上怎么判断“线程在自旋”
有一次线上服务CPU飙高,我查top看到某个Java进程占了300%多,jstack之后发现大量线程停在StampedLock的readLock方法上,线程状态清一色RUNNABLE。顺着栈往下看,入参是一个热点商品的查询接口,QPS高得离谱。本质上是那条读取路径在极端竞争下触发了大量自旋重试,最终CPU被打满。
判断线程是否在自旋,最直接的手段就是jstack。线程栈顶是LockSupport.park这样的调用,状态是WAITING,那是正常阻塞;栈顶是循环CAS或某个自旋方法,状态是RUNNABLE,那才是自旋。再辅助看线程CPU占用,用top -H -p [pid]找到对应线程ID,如果占用率长期高位,自旋嫌疑就非常大。
这种场景下,正确的处理策略往往不是优化自旋参数,而是换工具:把StampedLock换成ReentrantReadWriteLock,或者调整业务读多写少的比例,从源头降低竞争频率。自旋是“健康”的等待方式,但如果所有线程都在自旋,那说明系统的并发设计出了问题。
6. 面试高频:怎么把自旋讲得又全又好记
6.1 自旋锁和阻塞锁的区别,什么时候选谁
这个几乎是并发面试必问题。两者的本质区别在于“等待方式”:自旋锁让线程保持运行态,反复检查条件;阻塞锁让线程挂起,等待系统唤醒。
| 维度 | 自旋锁 | 阻塞锁 |
|---|---|---|
| 线程状态 | RUNNABLE,持续占用CPU | BLOCKED/WAITING,释放CPU |
| 等待开销 | 每次循环成本低,但空转时间不可控 | 线程切换成本高,但等待期不消耗CPU |
| 适用场景 | 锁持有时间短、临界区小 | 锁持有时间长、临界区有IO或阻塞操作 |
| 公平性 | 通常不保证,容易线程饥饿 | 可以设计为公平锁 |
| 典型实现 | 手写CAS锁、AtomicInteger内部循环 | synchronized、ReentrantLock |
最佳回答方式是:先讲清楚上下文切换的代价,再引出自旋的动机,最后给出适用条件“锁持有时间短于线程切换成本”就能达到面试官心里预期的答案。
6.2 synchronized锁升级过程中哪里出现过自旋
关于synchronized,常见误区是只回答“偏向锁、轻量级锁、重量级锁”这几个名词,却说不清自旋的位置。你可以这样组织答案:
- 轻量级锁通过CAS在对象头上抢锁,如果CAS失败,线程不会立刻阻塞,而是先自旋尝试等待锁释放。
- 重量级锁ObjectMonitor的竞争过程中,JVM同样允许线程自旋一定次数,失败后再进入阻塞队列。
- JVM使用自适应自旋,根据历史成功率动态调整单次自旋次数,有效减少无谓空转。
如果面试官追问“什么时候锁膨胀”,就答:当竞争线程发现锁迟迟拿不到、或者自旋次数已经用尽时,轻量级锁会膨胀为重量级锁,之后等待线程走阻塞唤醒流程。
6.3 手写自旋锁要注意哪些问题
很多面试官会让候选人现场写一个自旋锁,代码本身不复杂,但考察点都在“注意”里:
第一,可见性。锁状态字段需要用volatile修饰,或使用AtomicReference等自带volatile语义的类。第二,原子性。抢锁过程必须用CAS,不能是先判断再加锁的两步操作,因为这两个操作之间会插入其他线程。第三,可重入性。如果业务存在嵌套加锁,就需要记录持有线程和重入次数,否则会自我死锁。第四,公平性。默认CAS抢锁不公平,可以用TicketLock或排队机制确保先来先得。第五,等待让步。循环体里用Thread.onSpinWait()降低CPU功耗。另外,一定要提一下unlock要放在finally里,这个细节非常加分。
6.4 为什么有了synchronized还用AtomicInteger
最后聊一个经常被顺带问起的问题:并发场景下,为什么有人用AtomicInteger,不用synchronized或Lock?
答案要从“锁粒度”的角度说。synchronized和ReentrantLock锁定的是代码块,进入临界区时必须串行执行;原子类的目标是把冲突范围缩小到“单次CPU指令级别”。AtomicInteger.getAndIncrement()不需要进入临界区,它的自旋CAS只保护了一个变量的更新,其他没有竞争的行完全可以并行执行。在高并发计数的场景里,这种细粒度的“无锁编程”比绷着一个大锁要高效很多。
但也别神化无锁。CAS自旋解决不了复杂逻辑的原子性需求,比如多个变量的一致性更新、复合状态切换,这时候老老实实加锁比写一个容易出错的“无锁数据结构”靠谱得多。自旋和无锁不是银弹,它们只是工具盒里的两种方案,选哪个完全看临界区的复杂度。
我自己在日常开发里,最常碰到的自旋还是在原子变量和LongAdder这类工具里,手写自旋锁的场合反而很少。但我觉得每个写并发的工程师都应该亲手实现一次自旋锁。这个过程会逼着你把volatile、CAS、缓存行、上下文切换这些碎片知识串起来,以后再看到jstack里那些RUNNABLE的线程,一眼就能判断出它是在干活还是在空转。能“看清”这个层次,比背多少八股文都管用。