上周排查一个线上问题,让我又双叒叕一次意识到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则全部唤醒再让它们竞争。如果你想精准唤醒某一个特定线程,比如只唤醒负责处理订单的那一个worker,wait/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)把状态置1,park()检查状态:
- 如果是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并unpark,A检查时发现ready已经是true,跳过park,直接执行业务,这也是对的。真正的问题是,一旦有第三个线程参与竞争,A被唤醒后ready可能已经被别的线程改回false,A直接执行就会出错。
标准写法是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。其中最有代表性的一小段代码是parkAndCheckInterrupt:
private 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 prio=5 os_prio=0 cpu=12.34ms elapsed=100.12s tid=0x00007f8e9c0b5800 nid=0x2c36 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、线程池读起来会顺畅很多。