ARTICLE DETAIL

资讯详情

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

LockSupport许可机制深度解析:从wait/notify缺陷到AQS底层

LockSupport许可机制深度解析:从wait/notify缺陷到AQS底层 上周排查一个线上问题让我又双叒叕一次意识到LockSupport的地位被严重低估了。那是一个基于wait/notify手写的简易任务分发器偶发性出现任务不执行或者重复执行的诡异现象。查到最后根因就是notify早于wait执行导致通知丢失。这个坑我在好几个项目里见过每次解释起来都要从wait/notify的先天缺陷讲起。后来我们把底层调度统一换成了LockSupport问题才彻底消失。LockSupport是java.util.concurrent里绝大多数同步工具的地基AQS、各种显式锁、线程池全都建立在它的许可机制之上。这篇就把LockSupport许可机制的规则、边界和它在AQS底层的真实运作方式一次讲透想看透Java并发底层或者被wait/notify坑过的同学都可以从这里入手。1. 从wait/notify到LockSupport为什么并发库需要一块干净的停车位1.1 wait/notify在处理线程调度时的四个尴尬先回顾一下大家最常见的线程阻塞/唤醒方式Object.wait配合Object.notify/notifyAll。这套机制在JDK 1.0就有了配合synchronized使用但在构建复杂并发工具时它有几个非常别扭的约束。第一必须先持有对象监视器锁。调用wait、notify、notifyAll的线程必须已经进入synchronized块否则直接抛出IllegalMonitorStateException。这等于把锁和等待/通知强行绑在一起有些场景下根本不需要持锁却被迫先锁。第二notify先于wait执行时通知直接丢失。这是wait/notify最著名的坑如果线程A先执行notify线程B后执行wait那么B会永远等下去。因为notify只是临时唤醒当时正在等待的线程没有任何记忆机制。简单任务分发器偶发不执行几乎都是这个原因。第三notify唤醒的是哪个线程不可控且不可指定。notify从等待集合里随便挑一个notifyAll则全部唤醒再让它们竞争。如果你想精准唤醒某一个特定线程比如只唤醒负责处理订单的那一个workerwait/notify做不到。第四中断会以异常形式打破等待。线程在wait中被interrupt会产生InterruptedException调用方必须处理异常否则编译都过不去而且wait块会把中断状态清掉。这会迫使你的业务代码里到处都是try/catch写起来很累。这四个问题单独看还能忍叠在一起就非常难受尤其是要设计像ReentrantLock这种要求精确唤醒、公平排队、可中断的同步器时。1.2 JUC需要一个一次一个许可的底层原语JDK 5引入java.util.concurrent包时Doug Lea面临一个选择在wait/notify之上搭建AQS还是另起炉灶。最终他选择在更底层动手直接在JVM层面提供了Unsafe.park和Unsafe.unpark然后由LockSupport封装出来。为什么因为同步器最核心的操作就是让当前线程停住和精准唤醒某个线程这两个动作必须足够轻量、足够自由不能被synchronized的锁语义绑架。LockSupport提供的API本身很朴素park()让当前线程阻塞parkNanos(long nanos)、parkUntil(long deadline)支持超时unpark(Thread thread)唤醒指定线程。还有带blocker参数的版本专门用于诊断信息传递。注意一个细节park()不要求你必须持有任何锁unpark()唤醒的是指定的Thread对象这就把wait/notify最头疼的两个问题直接解掉了。我把LockSupport理解为JUC的上层建筑和JVM之间的水泥层。它内部调用的是Unsafe.park/unpark而这两兄弟操作的是HotSpot里Parker对象的一个int counter字段。理解了这个counter就理解了整个许可机制。1.3 用单格停车场理解许可机制许可机制这个词听起来抽象其实用一个生活模型就能讲清楚。想象停车场里只有一个车位每个线程是一辆车。unpark就是发放一张停车许可券park就是拿到许可券才让进场没拿到就在门口等着。关键规则来了这个停车许可券最多只有一张。某个线程手里已经有券了你再给他发一张第二张会被直接丢掉因为他最多只能占用一个车位。这就是为什么会说unpark不累计。另外许可券一旦发放如果线程还没来park这个券就一直在那存着等线程来park时直接消费掉立即进场。这就解决了notify丢失的问题——unpark早于park执行许可也不会消失。park则像是进场时的闸机。如果手里有券闸机直接放行同时把券收走如果没券闸机拦着线程进入阻塞状态直到有人塞给他一张券或者超时、中断等强行抬杆事件发生。有了这个模型下面四条硬规则就很好理解了。我每条都写了可运行的验证代码建议你复制去IDE里跑一下比看十遍文档都管用。2. 许可机制的四条硬规则每条都能用代码验证2.1 规则一park消耗许可unpark发放许可这是最基础的一条。每个线程内部维护一个许可状态初始为0。unpark(thread)把状态置1park()检查状态如果是0阻塞当前线程直到有人unpark如果是1立即把状态归0并返回不阻塞注意归0这一步它意味着park是有代价的经过一次park手里的许可就被消耗掉了。如果之后想再次park必须重新获得许可。所以你可以把许可理解为一次性通行证用一次就没了。2.2 规则二unpark先于park通知不丢失这条和wait/notify形成最鲜明的对比也是LockSupport最迷人的地方。看代码import java.util.concurrent.locks.LockSupport; public class UnparkThenParkExample { public static void main(String[] args) throws Exception { Thread worker new Thread(() - { try { Thread.sleep(1000); } catch (InterruptedException e) { // 忽略只是为了模拟晚于主线程执行park } long start System.nanoTime(); System.out.println(线程开始park); LockSupport.park(); System.out.println(park返回耗时: (System.nanoTime() - start) ns); }); worker.start(); // 主线程先给worker发许可此时worker还没走到park Thread.sleep(100); LockSupport.unpark(worker); worker.join(); } }输出线程开始park park返回耗时: 0 ns把主线程里那行unpark注释放掉程序就会卡在park处。这个实验能直接证明unpark先执行许可被存起来了后续park会瞬间消费这个许可。而wait/notify时代notify早一步执行等于话白说怎么可能有这种待遇。2.3 规则三重复unpark不累计许可最多只有1个很多人第一次写LockSupport会下意识觉得unpark两次就发了两张许可券后面park两次都能立即通过。这是错觉。底层counter在unpark时只会被置为1哪怕你unpark一百次也还是1。看这个可以安全跑完的验证代码import java.util.concurrent.locks.LockSupport; import java.util.concurrent.TimeUnit; public class RepeatedUnparkExample { public static void main(String[] args) throws Exception { Thread t new Thread(() - { // 连续给自己发两个许可 LockSupport.unpark(Thread.currentThread()); LockSupport.unpark(Thread.currentThread()); long start System.nanoTime(); LockSupport.park(); System.out.println(第1次park耗时: (System.nanoTime() - start) ns); start System.nanoTime(); // 第二次改用带超时的park为了不让程序永久卡住 LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); System.out.println(第2次park耗时: (System.nanoTime() - start) ns); }); t.start(); t.join(); } }输出类似第1次park耗时: 0 ns 第2次park耗时: 100000000 ns第一次park秒回第二次只能靠超时返回。这证明第二个unpark确实被丢弃了。如果有人向你保证我连续unpark两次就能唤醒两次直接用这个实验说话。2.4 规则四中断会静默放行但不抛异常这可能是最反直觉的一条。线程调用Thread.sleep时收到中断会抛InterruptedException调用wait时也会抛异常。唯独park收到中断之后就像什么都没发生一样直接返回而且中断标志位保持为true。import java.util.concurrent.locks.LockSupport; public class ParkInterruptExample { public static void main(String[] args) throws Exception { Thread t new Thread(() - { System.out.println(线程进入park); LockSupport.park(); System.out.println(park返回中断标记: Thread.currentThread().isInterrupted()); }); t.start(); Thread.sleep(300); t.interrupt(); t.join(); } }输出线程进入park park返回中断标记: true看到没有park没有抛异常而是静默放行像个被保安悄悄放进去的VIP。同时中断标志没有被清除还是true。这个小细节是很多隐蔽bug的来源下一节会重点展开。3. 把LockSupport用崩过的四个场景3.1 把unpark当成notifyAll唤醒效果对不上使用wait/notify时notifyAll一次能唤醒所有等待线程notify也能随机唤醒一个。但unpark必须传入明确的Thread对象一次只能唤醒一个线程。很多刚上手的人误以为调用unpark就能唤醒所有因为park阻塞的线程结果只唤醒了一个。更隐蔽的错误是把unpark和许可的概念搞混。比如想唤醒三个线程在循环里写了三次unpark(threadA)反复只能唤醒A一次。正确写法是对每个线程各调一次unpark(threadA)、unpark(threadB)、unpark(threadC)。由于许可不累计同一个线程调用多次等于只发了一张券。3.2 中断标志没清掉后面的park全部失效这是我在生产环境真实踩过的坑。背景是一个常驻线程循环处理任务每一轮开头用park等待新任务。某次运行中线程因为外部原因被interrupt那次park立刻返回了。代码里用isInterrupted()判断了一下但是只记了日志没清除标志。从下一轮开始每次执行到park都会秒回根本不阻塞。CPU直接打满日志里全是park返回。问题根源就是park不会清除中断标志而标志是true时park会把被中断当成被唤醒。正确做法是用Thread.interrupted()取出中断标志并主动清除Thread.interrupted(); // 返回当前中断状态并重置为false或者如果你确实要响应中断就自己抛InterruptedException。判断时不要用isInterrupted()这个方法不清除状态。这个细节尤其重要因为线程池里很多线程是靠检查中断状态来响应关闭的你把标志清了线程可能就不再响应shutdown了。因此要分清楚业务代码里做清除后继续跑框架代码里尽量保留标志位。3.3 只park不循环条件竞争下偶发跑飞很多新手写消费逻辑习惯这样if (!ready) { LockSupport.park(); } // 执行业务看起来没问题但存在竞态窗口。假设线程A检查ready时发现是false正准备park线程B在A检查之后、park之前把ready设为true然后调用unpark(A)。A随后进入park此时许可已经被B发放了A的park会立即返回这算运气好的情况。但更危险的是另一种顺序B在A检查前就把ready设为true并unparkA检查时发现ready已经是true跳过park直接执行业务这也是对的。真正的问题是一旦有第三个线程参与竞争A被唤醒后ready可能已经被别的线程改回falseA直接执行就会出错。标准写法是while循环这是所有并发库的共识while (!ready) { LockSupport.park(); }唤醒之后重新检查条件不满足就继续park。遇到虚假唤醒、超时返回、中断返回都安全。这也是面试里常问的为什么是while不是if的原因。我建议凡是写park一律配合while不要用if这能省掉90%的偶发问题。3.4 在synchronized块里park把自己锁死在门外park不会像wait那样释放任何锁因为它根本不需要锁。但问题恰恰出在这如果你在synchronized块里调用park线程是带着锁阻塞的。其他线程想进入这个synchronized块来unpark门都进不来死锁就成了必然。假设有段代码synchronized (lock) { // 条件不满足就阻塞等待 while (!ready) { LockSupport.park(); // 持锁阻塞 } }生产者在别的地方哪怕写LockSupport.unpark(consumer)也要先抢lock才能进到unpark附近可消费者拿着lock不松手。这个线程间的握手直接断裂。有人可能会说那我把unpark放在synchronized外面但问题不在于unpark放在哪而在于park持锁的状态本身很危险——因为你永远无法保证unpark所在的代码路径不需要抢同一个锁。因此park最好只在无锁上下文或只占用非常轻量的原子变量时使用。要配合synchronized使用条件等待请用Object.wait至少它还会释放锁。4. 在AQS和线程池里看LockSupport的真实用法4.1 AQS的parkAndCheckInterrupt为什么用interrupted()而不是isInterrupted()JUC的基石AbstractQueuedSynchronizer内部大量使用LockSupport。其中最有代表性的一小段代码是parkAndCheckInterruptprivate final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }这段代码解答了一个经典疑问为什么这里用Thread.interrupted()不用isInterrupted()原因正是3.2节那个坑。线程在park中被中断后park会静默放行但保留中断标志。如果不清除标志线程下次循环再尝试获取锁时又会立刻进park又立刻被中断标志放行形成空转。AQS要用interrupted()把标志取出来把中断这件事变成方法的返回值向上传递同时把标志清零让后续park能正常阻塞。4.2 acquireQueued里的标准循环检查-阻塞模式AQS的锁获取本质上就是一个while循环加park的组合。以独占模式为例final boolean acquireQueued(final Node node, int arg) { boolean interrupted false; try { for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) { interrupted true; } } } catch (Throwable ex) { cancelAcquire(node); throw ex; } }看到那个for(;;)了吗这就是一个永不停歇的检查-条件不满足-park-醒来再检查循环。线程先检查自己是不是队列中的第一个人如果是尝试获取锁获取失败或被挤到后面就让park把自己挂起。等前驱线程释放锁时LockSupport.unpark后继节点这个线程醒来回到循环顶部继续检查。整个机制里没有一个notifyAll唤醒完全靠对指定线程的unpark完成精确、可控、无广播浪费。4.3 线程池Worker的中断唤醒与LockSupport的配合线程池里的Worker类继承自AbstractQueuedSynchronizer它锁定的不是业务数据而是自己的工作权。Worker调用lock()时如果拿不到锁就会通过AQS进入park状态。线程池执行shutdown时会对空闲Worker调用Thread.interrupt()从而让这些park中的Worker被中断放行配合线程池的状态检查完成安全关闭。这里能看到LockSupport的另一个优势线程池中的线程可能在执行任务也可能在等待任务。用wait/notify你需要区分各种锁对象、等待条件很容易出现唤醒到错误线程。而LockSupport的unpark本身带目标线程配合AQS的队列结构能够精确地把只该唤醒的那个线程挑出来。这也是为什么JUC敢在ReentrantLock、Semaphore、CountDownLatch这类精细同步器上只依赖LockSupport。5. 实战建议什么场景该用LockSupport以及怎么用才不会翻车5.1 一个可直接抄的一次性门闩实现如果你想自己构建一个轻量级同步器可以用LockSupport写一个一次性门闩latch。这个实现包含了几个关键要素volatile条件变量、先检查条件的while循环、以及unpark时针对具体线程的精准唤醒。import java.util.concurrent.locks.LockSupport; public class OneShotLatch { private volatile boolean fired false; private volatile Thread waiter; public void await() throws InterruptedException { waiter Thread.currentThread(); while (!fired) { LockSupport.park(); if (Thread.interrupted()) { throw new InterruptedException(); } } } public void fire() { fired true; Thread t waiter; if (t ! null) { LockSupport.unpark(t); } } }这里有两个易错的点。一是await里先给waiter赋值再检查fired这个顺序利用了unpark先于park也能生效的特性避免了fire先执行导致唤醒信号彻底丢失。二是每次park返回后检查中断用interrupted()清除标志并重新抛出这样下一次循环再park时不会因为中断标志残留而秒回。5.2 什么样的情况应该放弃LockSupport说实话LockSupport虽然好用但它的语义很简单除非你在写并发库否则大部分时候应该用现成的工具。例如需要一个可以通知多次、支持多个等待条件的环境优先用Condition它比裸park精确得多需要控制并发访问数量用Semaphore它天然支持多许可和公平/非公平策略需要生产者消费者缓冲区用BlockingQueue里面已经用Lock/Condition封装好了需要异步结果、任务编排用CompletableFuture它的内部实现同样基于LockSupport但提供了更友好的API只有当你需要的是一种极简的一线程对一线程的一次性唤醒或者你要自己设计自定义同步器时才是LockSupport的用武之地。切记不要用它去实现复杂的多条件等待那会把你拖进一个又一个竞态深坑。5.3 用jstack排查parking状态的小技巧线上排查线程问题jstack是最直接的武器。LockSupport阻塞的线程在线程栈里会显示成这样pool-1-thread-1 #11 prio5 os_prio0 cpu12.34ms elapsed100.12s tid0x00007f8e9c0b5800 nid0x2c36 waiting on condition [0x00007f8e9b3b9000] java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:341) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:916)注意State里那个(parking)标记。很多初学者看到WAITING就慌了以为线程死掉了。其实(parking)只是说明它是通过LockSupport停下来的属于正常阻塞和WAITING (on object monitor)完全不同。如果你写了带blocker参数的park线程栈里还能看到具体的阻塞对象可以顺手加上LockSupport.park(等待任务队列);这在排查多线程互相等待时能直接定位到每个线程到底卡在哪个业务环节比看纯调用栈省事得多。踩过几次坑之后我的体会是理解LockSupport许可机制最核心的就是三句话许可最多只存一个unpark不累计中断会静默放行。至于使用时的动作习惯记住先while检查条件再park唤醒后重新检查一次就足够安全了。Java并发世界里所有的复杂其实都构建在这些不起眼的小规则之上把这几条规则弄扎实后面的AQS、线程池读起来会顺畅很多。
返回列表