ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java自旋锁从原理到实战:synchronized、AQS与CPU飙高排查

Java自旋锁从原理到实战:synchronized、AQS与CPU飙高排查 你有没有遇到过这种情况服务CPU突然飙高top上看线程占比一个个都像在打满负荷工jstack抓下来满屏都是RUNNABLE栈顶堆在sun.misc.Unsafe.getAndAddInt或者AbstractQueuedSynchronizer这一类调用上。有经验的同事扫一眼不紧不慢说一句“自旋了。”没错就是这个词。Java的自旋是并发世界里绕不开的高频考点可惜大部分人是在八股文里背过概念真到了理解源码、排查线上、回答面试题的时候又说不太透。这篇文章我从几个侧面把“Java里到底是怎么自旋的”讲清楚自旋为什么存在、synchronized锁升级里的自旋、JUC并发包源码里的自旋、手写自旋锁的几种姿势、CPU飙高时如何判断是不是自旋惹的祸最后整理一套面试问答思路。不管你是准备Java面试还是被线上并发问题折腾得头疼又或者单纯想把AQS和原子类看明白这篇都值得读完。1. 一秒钟看懂自旋用CPU时间换锁等待延迟1.1 一次上下文切换的开销到底有多大线程在抢锁失败之后面前其实有两条路一条是老老实实挂起等锁释放了再被唤醒另一条就是“我不睡了我就在这盯着锁一放我立刻抢”。挂起这条路听起来很温和但代价不小。线程从运行态切到阻塞态再从阻塞态唤醒回到运行态中间涉及用户态和内核态的切换、寄存器保存恢复、缓存失效等一系列操作。一次上下文切换的耗时通常在微秒级别竞争激烈时甚至更高。可很多临界区代码的执行时间就是几十纳秒到几百纳秒撑死了几条CAS指令。你为一个本来就极短的操作去付出一次完整阻塞唤醒的开销属于典型的捡了芝麻丢西瓜。自旋就是来解决这个匹配错位的。所谓自旋就是线程在锁暂时不可用时不放弃CPU占用权而是用循环不断重试获取锁。它拿CPU时间去赌锁马上就会释放赌赢了就省了一次阻塞唤醒的调度开销赌输了CPU就被白白烧掉一段。生活里也有类似的场景。比如你在等电梯如果电梯明明已经到了这一层附近你守在门口等是最快的但如果电梯刚从天台往下走到负一层你还站门口傻等那真不如先挂着等信号来了再上去。自旋锁赌的就是“电梯马上就到”。1.2 自旋的适用边界不是无条件的既然自旋的核心是“赌”那它就不是万能的。我在实际中判断一个场景适不适合自旋基本看三个条件同时满足才算靠谱锁的持有时间极短。临界区里的操作通常只有几条指令像i、状态标记、链表头插入这类。多核处理器。假设只有单核自旋线程不释放CPU真正持有锁的另一个线程反而可能没机会运行这两个线程实际是在“轮流被调度”白白耗费系统资源。竞争不激烈。大多数情况下线程能一把抢到锁自旋循环只是偶尔多转几圈。这三个条件缺一个自旋都可能变成灾难。而且这个逻辑不只存在于Java里Linux内核有spinlockmacOS内核也有spinlock底层思路完全一致等待时间极短的情况下用忙等待代替睡眠。内核态里有些中断上下文连睡眠都不允许那就只能自旋。我整理了一个直观对比等待方式CPU占用响应延迟适用场景阻塞唤醒低高微秒级调度开销临界区长、竞争激烈自旋等待高低原地重试临界区短、竞争弱、多核一句话总结自旋是拿CPU资源换响应速度不能滥用。2. synchronized的锁升级里自旋藏在轻量级锁这个环节2.1 从对象头MarkWord到轻量级锁CAS抢占很多初学者以为synchronized一上来就是重量级锁实际上JVM早把这条路优化得面目全非。在HotSpot里对象头中有一个叫MarkWord的区域里面记录了锁状态。锁的状态大致走这样一条链路无锁 - 偏向锁 - 轻量级锁 - 重量级锁一开始没有竞争时JVM会尝试用偏向锁第一个进入同步块的线程把MarkWord标记为自己的线程ID后续再进入只需要比对线程ID是否相同开销极小。一旦有另一个线程也来抢这块锁偏向锁就失效了锁膨胀为轻量级锁。这时候每个竞争线程会在自己的栈帧中创建一个LockRecord然后通过CAS操作尝试把对象头的MarkWord替换成指向自己LockRecord的指针。CAS成功代表锁到手CAS失败说明锁已经被别人持有。到这里重点就来了CAS失败之后线程不会立刻挂起。JVM会先让这个线程自旋重试也就是在循环里反复执行CAS持续观察锁是否被释放。如果自旋期间持锁线程及时释放了锁这个线程就能以极低延迟拿到锁完全不用触发内核调度。只有自旋等待超过阈值或者同一时刻竞争线程数太多锁才会进一步膨胀为重量级锁由操作系统完成阻塞与唤醒。这也是为什么很多短临界区场景下synchronized的性能完全不输甚至优于显式锁——它通过“CAS加自旋”避开了真正的线程切换。注意偏向锁在现代JDK里已经不是默认选项了。JDK 15开始默认禁用了偏向锁JEP 374因为它的维护成本和收益在现代应用里越来越不成正比。但轻量级锁加自旋这条路径依然保留而且依旧是低竞争场景下的主力优化。2.2 自适应自旋JVM自己决定转几圈早年间JVM的自旋次数是固定写死的一个锁失败了就转固定次数转完还没拿到再膨胀成重量级锁。这种方式太死板锁竞争激烈时转那么多次纯属浪费锁竞争不激烈时又可能转得不够错过快速释放的窗口。JDK 1.6之后引入了自适应自旋。这个词听着玄乎其实本质就是JVM针对每个锁对象做了“历史战绩统计”如果同一个锁上一次自旋等待成功过说明锁持有者通常很快就会释放这次就多转几圈如果上次自旋半天还是失败说明这块锁竞争激烈或者持有时间偏长下次就直接少转甚至不转尽早走重量级锁阻塞流程。JVM还会参考锁持有线程当前的状态。如果持有锁的线程此刻正在可运行队列里说明它有机会尽快执行完临界区自旋就有价值如果持有者已经被换出CPU那自旋再久也是给别人做嫁衣。对普通开发者来说自适应自旋的意义在于我们通常不需要手动调整JVM自旋参数。早年我还会折腾-XX:UseSpinning和-XX:PreBlockSpin这类参数现在基本不用了把临界区代码写得尽量短、尽量避免在锁内做IO剩下的交给JVM就好。2.3 锁消除、锁粗化与自旋属于同一家族的兄弟很多面试者会把锁优化的几种手段混为一谈其实它们解决的问题完全不一样。锁消除是JIT编译器的活如果JVM通过逃逸分析发现某个对象的同步块根本不会被其他线程访问它就直接把synchronized去掉这个锁从物理上就不存在了。锁粗化则是把多个相邻的同步块合并成一个更大范围的同步块减少反复加锁解锁的次数。而自旋解决的是另一个问题锁竞争已经发生了当前线程抢不到锁我又不想立刻阻塞该怎么办。一个是消灭锁一个是减少加锁次数还有一个是等待策略三者属于并发优化的不同维度不是一回事。理解这个区别会让你的答案比普通背八股的人更有层次感。3. 走进JUC源码那些“不睡觉”的自旋3.1 AtomicInteger的CAS重试循环JUC包里的原子类是无锁编程的主力它们的底层几乎都是同一个套路CAS失败自旋重试。拿JDK 8里的Unsafe来说AtomicInteger.incrementAndGet()最终会走到这一段逻辑public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v getIntVolatile(o, offset); } while (!compareAndSetInt(o, offset, v, v delta)); return v; }这段代码就是最朴素的自旋。第一次读取内存中当前值v尝试CAS把它改成v delta。如果CAS失败说明有别的线程抢先改了值循环重新读取最新值再试一次。整个过程没有任何加锁也没有任何阻塞靠的是乐观地“我赌没人跟我抢抢输了就再来一次”。你可以把CAS自旋想象成几个人同时抢着在公告栏上填一个格子手快的直接写上去了手慢的看到格子内容变了重新看一眼最新内容再往上填。没有排队没有叫号所有竞争全靠硬件指令的原子性兜底。这段代码值得反复琢磨因为它是整个JUC原子工具包的灵魂。你理解了getAndAddInt后面看AtomicReference、AtomicBoolean、AtomicLong甚至ConcurrentHashMap里的部分操作全都是同一种模式。3.2 AQS的CLH变体队列自旋几步再挂起ReentrantLock、CountDownLatch、Semaphore这些同步工具底层几乎都挂在AbstractQueuedSynchronizerAQS上。AQS的实现不是纯自旋也不是纯阻塞而是自旋与park混合。以ReentrantLock.lock()为例核心方法是acquirepublic final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }第一波tryAcquire失败后线程不会立即自暴自弃而是加入一个CLH变体等待队列然后进入acquireQueued。这个方法的循环很有意思for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; }线程入队后会先检查自己是不是队头之后第一个节点。如果是就再尝试一次获取如果前驱不是head或者尝试获取失败它不会立刻死循环空转而是执行shouldParkAfterFailedAcquire根据前驱节点的waitStatus决定是否把前驱状态修改为SIGNAL也就是“轮到你唤醒我”。这步做完之后下一次循环才会真正通过LockSupport.park把线程挂起。你可以这样理解AQS的自旋策略新加入的线程先自旋一段时间期间不断尝试获取锁。如果这段时间内前一个线程刚好释放锁它就低成本地抢到如果迟迟拿不到就把自己包装成节点明确告诉前驱“释放后记得唤醒我”然后安安静静地挂起。自旋是试探park是退路两者配合才提得上优雅。要注意的是park之后的线程状态是WAITING(parking)不消耗CPU自旋中的线程状态是RUNNABLE持续占用CPU。这个差异是定位并发问题时的分水岭后面排查章节会继续用到。3.3 ConcurrentHashMap与LongAdder热点分散后的自旋重试ConcurrentHashMap里也有大量CAS重试。经典的一段是往空桶放第一个节点if (tabAt(tab, i) null) { if (casTabAt(tab, i, null, new Node(hash, key, value, null))) break; }多个线程同时往同一个空桶里放头部节点时只有一个CAS能成功其余线程会自动重新循环读到最新数组状态后继续尝试。扩容迁移过程中多个线程协助搬运链表节点也是靠CAS推进状态。LongAdder则是把“分散热点”这件事做到了极致。它的内部是一个Cell数组每个线程通过哈希定位到自己的CellCAS只在自己的Cell上做累加。某个Cell冲突太大时数组会扩容让更多线程分散到更细的粒度上。为什么高并发下LongAdder通常比AtomicLong好就是因为AtomicLong是所有线程抢同一个变量CAS成功率低自旋失败次数自然高得吓人LongAdder把概率分散开每个线程的CAS命中率上去了自旋重试次数就降下来了。极端情况下我压测过16线程并发递增LongAdder吞吐量几乎是AtomicLong的好几倍CPU占用还更低。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把false换成true成功即获得锁unlock()直接复位为false。逻辑很简单但真要用它你会踩到好几个坑第一个坑是不公平。所有线程都对准同一个AtomicBoolean开抢谁手快谁赢没有任何队列机制极端情况下某些线程可能一直饿死。第二个坑是可重入性。这个锁自己再进入同一段锁代码时会直接死锁除非你额外记录持有线程和重入次数。第三个坑是总线流量。所有线程高频CAS同一个volatile变量缓存一致性协议会把底层带宽直接打满线程一多性能不升反降。我在自己的无锁队列练习里写过这种锁结论是只适合教学业务代码千万别用。4.2 TicketLock用号码牌保证公平想要公平一个朴素的想法就是排队发号。TicketLock的思路和银行叫号一样每个线程先取一个递增号码然后循环等待柜台叫到自己的号。public class TicketLock { private final AtomicInteger ticket new AtomicInteger(0); private final AtomicInteger current new AtomicInteger(0); public int lock() { int myTicket ticket.getAndIncrement(); while (current.get() ! myTicket) { // 轮询等待叫号 } return myTicket; } public void unlock(int myTicket) { current.compareAndSet(myTicket, myTicket 1); } }这种实现保证了公平先到先得不会有饥饿。但它有一个问题所有线程都在轮询同一个current变量。即使锁很快释放大家也在同时盯着同一个共享内存地址。线程越多可见性争用越凶缓存一致性流量反而比简单自旋锁还大。对于核心线程数少、要求严格公平的场景TicketLock可以接受但线程规模一上去它并不是理想选择。4.3 CLH与MCS换一种自旋位置降低总线争用更聪明的做法是让每个线程不要在全局共享变量上自旋而是各自盯着自己的前驱节点。CLH锁的思路是线程进来时创建一个Node通过tail.getAndSet(node)把自己挂到队尾同时记下前驱节点。然后它只轮询前驱节点的locked字段。前驱释放锁时把自己的locked置为false后续节点立刻感知接着获取锁。这样每个线程盯的是不同的地址缓存友好度大幅提升。AQS的等待队列本质上就是CLH的变体只不过它把“永不停歇的轮询”换成了“轮询几步后挂起”。MCS锁则更彻底让每个线程直接在自己的本地节点上自旋而不是观察前驱节点。引入tail指向当前最后一个节点新线程把自己的节点挂上去后设置前驱节点的next字段指向自己然后在自己节点上等待。这样做的好处是NUMA架构下每个线程只在本地内存上自旋跨CPU访问最少。三种自旋锁变体可以这样对比锁实现公平性轮询对象总线流量最适合场景简易CAS锁不公平同一个共享变量高临界区极短、线程少TicketLockFIFO公平同一个叫号变量高少量线程需要公平CLH锁FIFO公平各自前驱节点低通用并发库、AQS基础MCS锁FIFO公平线程本地节点极低NUMA架构底层很多面试问“CLH锁和MCS锁区别是什么”如果你能说出“CLH轮询前驱、MCS轮询自己、MCS对NUMA更友好”这一点基本就已经打败了九成八股选手。不过还是要强调一遍生产环境中手写自旋锁基本是博眼球的“性能毒药”。JVM自带的synchronized和JUC工具包已经内置了完善的混合等待策略非特殊场景没必要自己造轮子。5. 当自旋失控CPU飙高的排查与调优实录5.1 判断自旋还是阻塞jstack线程状态一看便知年前有一次排查线上服务CPU飙高top显示某个进程CPU占到800%一堆线程都在RUNNABLE状态。一开始团队怀疑是死循环抓了jstack一看栈顶全部压在AtomicBoolean.compareAndSet和Thread.yield附近这才反应过来是自旋竞争太激烈。排查步骤我习惯这样走# 1. 找到进程内CPU占用最高的线程tid top -Hp pid # 2. 把线程tid转成十六进制 printf %x\n tid # 3. 根据十六进制nid过滤线程栈 jstack pid | grep -A 30 nid0x十六进制拿到栈之后关键看线程状态和调用栈状态是WAITING(parking)栈顶有Unsafe.park这是正常的阻塞等待不烧CPU。状态是RUNNABLE栈顶反复出现getAndAddInt、compareAndSet、acquireQueued这类方法多半是在自旋重试。如果看到大量线程都在同一个锁的acquireQueued里自旋说明锁竞争已经到了比较夸张的地步。这里有个容易踩的认知误区RUNNABLE只是线程“可运行”并不代表它此刻一定在占用CPU。但当你看到几十个线程齐刷刷卡在CAS相关栈上而这种状态还持续很久那基本可以判定它们在做高强度的自旋空转。5.2 单个线程自旋的退避优化与策略选择单纯从代码层面止血可以考虑给自旋加退避策略。最温和的做法是让出CPU时间片while (!locked.compareAndSet(false, true)) { Thread.yield(); }Thread.yield()只是把当前线程从运行态送回可运行态让操作系统有机会把CPU分给其他线程。它依然属于“可运行”不会真正挂起所以不能完全止住自旋烧CPU但能降低单一线程霸占核心的概率。更强一些的退避是LockSupport.parkNanoswhile (!locked.compareAndSet(false, true)) { LockSupport.parkNanos(1); // 纳秒级睡眠降低总线争用 }这会让线程短暂让出执行权减少高频率CAS对缓存一致性协议的压力。不过这些都是止损手段。真正治本的是回过头检查锁设计临界区是不是太长、共享变量是不是太多、能否用ThreadLocal或者LongAdder这种无锁结构替代。我踩过几次坑后的经验是越是出现需要靠退避续命的自旋锁越说明锁的设计已经不符合自旋的适用条件了。这时候就应该换方案而不是继续调参数。5.3 压测数据说话临界区长度如何决定锁选择面对“到底该用synchronized还是ReentrantLock还是自己写自旋”这种问题最靠谱的做法是你自己跑一次JMH微基准。测试变量控制在三个线程数、临界区执行时间、锁类型。我跑过的典型结论是这样的线程数少、临界区短比如只做一次CAS自旋锁和synchronized差异极小谁都不会成为瓶颈。线程数超过16个竞争明显加剧时synchronized由于有自适应自旋兜底表现稳定手写无界自旋锁则开始大量空转CPU占用明显上升。临界区一旦长到微秒级甚至更高比如在锁内做集合遍历、IO或复杂计算阻塞型等待反而更优因为自旋线程空等的成本远高于一次阻塞唤醒。所以不要死记“自旋快”这个结论。自旋快的前提是临界区极短如果你的锁里已经包含了网络请求、数据库查询那无论自旋转多少圈都没用趁早改成阻塞或异步才是正路。6. 面试实战自旋问题怎么答才不被当八股文背6.1 高频问题与参考回答方向我把这些年面试里见过的高频自旋问题整理成了速查表每个问题都附一个高质量的回答方向问题参考回答方向避坑提示什么是自旋锁线程抢锁失败后不释放CPU用循环反复检查锁状态直到成功或达到退出条件别只背“忙等”要能解释为什么需要它synchronized什么时候自旋偏向锁失效进入轻量级锁后CAS失败先自旋超时或竞争加剧才膨胀为重量级锁不要漏掉“自适应自旋”这个点自旋次数由谁决定早期参数固定JDK1.6后为自适应根据锁历史竞争情况和持有者状态动态调整不要只说固定次数显得知识停留在旧版本单核CPU上自旋有意义吗基本没有自旋线程不释放CPU持有锁线程更难被调度执行不要武断说“完全无效”要讲清为何AQS为什么不一来自旋到底纯自旋适合极短临界区长等待会浪费CPUAQS采用“自旋几步再park”的混合式等待关键点在于“先试探、后挂起”线上怎么识别线程在自旋jstack看到RUNNABLE线程栈顶在CAS/acquireQueued等位置WAITING(parking)则是阻塞主动点出线程状态与CPU的关系更能加分6.2 让面试官点头的加分表达事实证明面试官想听到的从来不是“自旋就是忙等等锁”。你要是能把自己对于“自旋与阻塞并非对立关系”的理解讲出来会明显比背诵派高一个段位。一个不错的思路是串起完整链路synchronized在无竞争时走偏向路径有竞争后变成轻量级锁并通过CAS加自旋尝试自旋失败最终膨胀为重量级锁阻塞线程而JUC里的原子类是无锁加自旋AQS等待队列是先自旋后park的混合策略。这一条线本身就构成了你对Java并发底层信任链路的完整理解。再加一层实际经验就更有说服力你可以说自己在压测里发现长临界区下一直自旋会导致CPU空转、整体吞吐下降所以要么选用阻塞的ReentrantLock要么用LongAdder这种热点分散的数据结构。这些话一放出来对方就知道你是真写过代码、真做过性能对比的。7. 我踩过几次坑之后的真实想法个人习惯上我现在很少在业务代码里手写自旋锁。真正值得手写自旋的地方基本都在无锁队列、底层计数器这类本身就是几十条指令的临界区里。大多数业务场景synchronized的轻量级锁优化和JUC的CAS重试已经足够帮我们兜住。最后再分享一个小技巧平时遇到线程卡在自旋状态时我一定会先问“它到底在等什么”。多数情况下答案不是锁性能差而是锁竞争本身设计得不合理。自旋没有绝对的好坏关键是你知不知道它在哪转、为什么转、转多久。这个认知比记住多少条源码注释都更值钱。
返回列表