ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

自旋锁与CAS:从AtomicInteger到synchronized锁升级的Java并发解析

自旋锁与CAS:从AtomicInteger到synchronized锁升级的Java并发解析 排查线上高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不是自旋但“CASLoop自旋重试”的组合是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 AtomicReferenceThread 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再变成ACAS会认为没有变化从而错误地更新。经典场景是链表栈的并发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持续占用CPUBLOCKED/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的线程一眼就能判断出它是在干活还是在空转。能“看清”这个层次比背多少八股文都管用。
返回列表