承接的问题
第 8 章用 CAS 把「读-改-写」变成了一个原子指令。但 CAS 只解决「怎么改」,没有解决「改不了的时候怎么办」。
两个线程同时扣减同一本书的库存,一个成功,另一个的 compareAndSet 返回 false。失败者有三个选择:
- 立刻重试,直到成功——自旋;
- 让出 CPU,等别人通知——阻塞;
- 放弃,向上返回失败——超时/快速失败。
第三种是业务层的选择,前两种是运行时和锁实现的选择。synchronized、ReentrantLock、LockSupport 都在这个问题上做文章。这一节把「等」这件事拆开:自旋在等什么、阻塞在等什么、park/unpark 的许可到底保证什么。9.2 会用这些原语解释 ReentrantLock 的行为,卷二则会看到 AQS 如何把它们组合成队列。
自旋:用 CPU 换一次上下文切换
最原始的自旋锁长这样:
import java.util.concurrent.atomic.AtomicReference;
public final class SpinLock {
private final AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while (!owner.compareAndSet(null, current)) {
Thread.onSpinWait();
}
}
public void unlock() {
owner.set(null);
}
}
lock() 不睡眠、不排队、不让出 CPU,失败了就再试一次。Thread.onSpinWait() 是给 CPU 的提示:当前在忙等,可以降低功耗、让超线程的另一个逻辑核拿到更多执行资源;它不是 sleep(0),不会触发调度,也不会让出时间片。
自旋的收益来自避免上下文切换。一次线程阻塞再唤醒,涉及用户态到内核态、调度队列、栈的保存与恢复,代价通常在微秒级;如果锁的持有时间比这个还短,自旋几次就拿到了,比阻塞划算。反过来,如果临界区里有 IO、有长循环、有另一个锁,自旋就是纯烧 CPU。
自旋在现实中的样子
我写了一个最小的对照:一个线程在 volatile 标志上自旋,另一个线程 park,1 秒后取线程 dump:
"spin-waiter" #25 [26115] ... cpu=1252.37ms elapsed=1.26s ... runnable
java.lang.Thread.State: RUNNABLE
at SpinDump.lambda$main$0(SpinDump.java:10)
"park-waiter" #26 [43267] ... cpu=0.15ms elapsed=1.26s ... waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
- parking to wait for <0x000000061fdeb870> (a java.lang.Object)
at java.util.concurrent.locks.LockSupport.park(java.base@25.0.5/LockSupport.java:223)
两个线程存活时间都是 1.26 秒,自旋线程累计消耗 1252 毫秒 CPU,park 线程只消耗 0.15 毫秒。注意这是线程累计 CPU 时间,不是墙钟耗时,比吞吐数字稳定;但在负载 100 以上的机器上,自旋线程能拿到多少核仍取决于调度,所以这里只引用这个对照的数量级,不把它当作基准数据。
这张 dump 还说明一个排查问题:自旋线程的状态是 RUNNABLE,和真正干活的线程在 dump 里没有区别。线上看到 CPU 100% 且大量 RUNNABLE,不能只看状态,要结合栈顶是不是自旋循环、以及 jdk.ThreadPark/jdk.JavaMonitorEnter 的缺失来判断。
JDK 25 里没有可调的自旋参数
老资料会让你调 -XX:PreBlockSpin 或 -XX:+UseSpinning。在 JDK 25 上这些参数已经不存在:
$ java -XX:+UseSpinning -version
Unrecognized VM option 'UseSpinning'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
HotSpot 的自适应自旋是内部实现:monitor 竞争时先自旋一小段时间,失败再膨胀为重量级锁并阻塞;自旋次数由运行时根据历史成功率调整。它没有对外开关,也不应该成为应用调优的旋钮。应用层能控制的只有自己写的自旋循环。
什么时候不要自旋
- 临界区里有 IO、数据库调用、远程调用:持锁时间以毫秒计,自旋必然烧满一个核。
- 等待线程数大于可用核数:自旋线程会挤占持锁线程的 CPU,越旋越慢。
- 单核机器:自旋的线程不让出 CPU,持锁线程根本没机会运行。
- 需要公平性、可中断、超时:裸自旋锁一个都不提供。
自旋锁适合的是「几十条指令、无分支、无阻塞」的极短临界区,例如某些计数器、序列号生成器。业务代码里的借还书流程不属于这一类。
LockSupport 的许可模型
LockSupport.park() 阻塞当前线程,unpark(Thread) 唤醒指定线程。它不依赖监视器,也不要求在 synchronized 块里调用。理解它的关键是许可(permit),而不是「信号」。
可以把每个线程想象成有一个布尔许可,初始为 false:
unpark(t):把t的许可置为true。如果t正在park,它会被唤醒;如果许可已经是true,再unpark一次没有任何额外效果。park():如果许可为true,立刻消费掉许可并返回;如果为false,阻塞,直到被unpark、被中断,或者虚假唤醒。
这个模型有两个直接推论:许可最多只有一个,所以两次 unpark 不能换两次 park;许可可以提前存,所以先 unpark 再 park 不会丢信号。下面的程序把这几种边界都跑了一遍:
import java.util.concurrent.locks.LockSupport;
public class ParkPermit {
static final class LoanGate {
@Override
public String toString() {
return "loan-gate";
}
}
static final Object BLOCKER = new LoanGate();
public static void main(String[] args) throws Exception {
Thread main = Thread.currentThread();
// 1. 先 unpark 后 park:许可被保留,park 立即返回
LockSupport.unpark(main);
long t0 = System.nanoTime();
LockSupport.park(BLOCKER);
System.out.printf("case1 unpark-then-park: returned after %.3f ms%n",
(System.nanoTime() - t0) / 1_000_000.0);
// 2. 许可最多一个:两次 unpark 只留一个,第二次 park 必须等
LockSupport.unpark(main);
LockSupport.unpark(main);
t0 = System.nanoTime();
LockSupport.park(BLOCKER);
System.out.printf("case2 park #1: returned after %.3f ms (permit consumed)%n",
(System.nanoTime() - t0) / 1_000_000.0);
Thread watchdog = new Thread(() -> {
LockSupport.parkNanos(300_000_000L);
LockSupport.unpark(main);
}, "watchdog");
watchdog.start();
t0 = System.nanoTime();
LockSupport.park(BLOCKER);
System.out.printf("case2 park #2: returned after %.0f ms (watchdog fired at 300 ms)%n",
(System.nanoTime() - t0) / 1_000_000.0);
watchdog.join();
// 3. 阻塞中被 interrupt:park 返回,但不清除中断标志
Thread sleeper = new Thread(() -> {
long s = System.nanoTime();
LockSupport.park(BLOCKER);
System.out.printf("case3 interrupted while parked: returned after %.3f ms, isInterrupted=%s%n",
(System.nanoTime() - s) / 1_000_000.0,
Thread.currentThread().isInterrupted());
}, "sleeper");
sleeper.start();
Thread.sleep(200);
sleeper.interrupt();
sleeper.join();
// 4. 进入 park 前中断标志已置位:立即返回,返回后标志仍在
main.interrupt();
System.out.println("case4 before park: isInterrupted=" + main.isInterrupted());
t0 = System.nanoTime();
LockSupport.park(BLOCKER);
System.out.printf("case4 park with interrupt set: returned after %.3f ms, isInterrupted=%s%n",
(System.nanoTime() - t0) / 1_000_000.0, main.isInterrupted());
Thread.interrupted();
LockSupport.unpark(main);
System.out.println("case5 after unpark: isInterrupted=" + main.isInterrupted());
LockSupport.park(BLOCKER);
System.out.println("case5 after consuming permit: isInterrupted=" + main.isInterrupted());
}
}
本机输出(JDK 25,负载很高,毫秒数只用于说明「是否等待」,不可作为性能数据):
case1 unpark-then-park: returned after 0.013 ms
case2 park #1: returned after 0.005 ms (permit consumed)
case2 park #2: returned after 305 ms (watchdog fired at 300 ms)
case3 interrupted while parked: returned after 208.347 ms, isInterrupted=true
case4 before park: isInterrupted=true
case4 park with interrupt set: returned after 0.017 ms, isInterrupted=true
case5 after unpark: isInterrupted=false
case5 after consuming permit: isInterrupted=false
逐条读:
- case1 证明许可可以预存。先
unpark再park,park立刻返回,不会阻塞到下一次唤醒。 - case2 证明许可上限是 1。两次
unpark后第一次park立即返回并消费许可,第二次park只能等看门狗在 300 毫秒后补的那次unpark。 - case3 证明中断能唤醒
park,且park不会清除中断标志。这一点和Thread.sleep、Object.wait相反:后两者抛出InterruptedException时会清除标志。 - case4 证明进入
park前中断标志已经置位时,park立即返回,标志仍然保留。 - case5 证明
unpark本身不会设置中断标志,消费许可也不会。
因为 park 不清中断标志,标准的恢复中断写法仍然适用:
try {
LockSupport.park(this);
} finally {
if (Thread.interrupted()) {
// 在这里做清理;如果本方法不处理中断,就恢复标志
Thread.currentThread().interrupt();
}
}
实际上多数情况下不需要在 park 后面手动恢复,因为它本来就没清;需要小心的是你在 park 返回后调用了 Thread.interrupted() 去查询,那一次查询会把标志清掉。
park 与 wait 的区别
两者都能让线程等待,但契约完全不同:
| 维度 | Object.wait() | LockSupport.park() |
|---|---|---|
| 前提 | 必须持有该对象的监视器 | 无前提,随时可调用 |
| 是否释放锁 | 释放监视器 | 不释放任何锁 |
| 唤醒方式 | notify / notifyAll | unpark(thread) / 中断 / 虚假唤醒 |
| 信号是否可存 | 不可存,先 notify 会丢 | 可存一个许可 |
| 中断行为 | 抛 InterruptedException,清标志 | 直接返回,不清标志 |
| 虚假唤醒 | 允许 | 允许 |
| 目标线程 | 只能唤醒等待同一监视器的线程 | 精确唤醒指定线程 |
「先 notify 会丢」是 wait 最容易出错的点:通知发生在等待之前,等待者永远等不到。park/unpark 用许可解决了这个问题,代价是你必须自己维护条件,许可只负责唤醒,不负责告诉你为什么醒。
虚假唤醒与条件循环
park 的 Javadoc 明确写着:调用可能「无理由地」返回(spurious return)。操作系统、JVM 的唤醒机制、甚至中断都可能让它返回。所以 park 必须放在循环里,循环条件是真正的业务谓词:
while (!hasWork()) {
LockSupport.park(this);
}
takeWork();
只写一次 if 是错的,和第 5.2 节的 wait 错误是同一类。两者的区别只在于:wait 的谓词受监视器保护,park 的谓词通常由 volatile 字段或并发容器维护。
blocker:给 dump 留一条线索
park(Object blocker) 比无参版本多一个对象,JVM 会把它记录在阻塞线程上。线程 dump 里能直接看到:
"loan-parker" #25 [26627] ... waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
- parking to wait for <0x000000035fc186a8> (a ParkDump$LoanGate)
at java.util.concurrent.locks.LockSupport.park(java.base@25.0.5/LockSupport.java:223)
parking to wait for ... (a ParkDump$LoanGate) 这一行就是 blocker 的价值。无参 park() 的 dump 里没有这一行,只能看到 Unsafe.park 和 LockSupport.park,排查时无法判断线程在等哪个业务对象。自己写等待逻辑时,总是传一个有名字的 blocker。
自旋还是阻塞:一张判断表
| 场景 | 选择 | 理由 |
|---|---|---|
| 临界区几十条指令、无阻塞 | 自旋 | 切换成本高于等待时间 |
| 临界区含 IO、远程调用 | 阻塞 | 自旋会烧满 CPU |
| 等待者远多于核数 | 阻塞 | 自旋线程抢走持锁线程的 CPU |
| 需要超时、可中断 | 阻塞 + 超时 | 自旋无法被中断 |
| 单核或容器 CPU 配额很小 | 阻塞 | 自旋线程不让出 CPU |
| 中断延迟敏感、持锁极短 | 自适应自旋 | 由运行时决定,不要手写死循环 |
真实的高性能锁通常是混合策略:先尝试几次 CAS(自旋),失败后进入队列并 park。ReentrantLock 的非公平模式就是这么做的:先 tryAcquire 插队,失败再排队。卷二会展开 AQS 的 acquire 流程,这里只需要知道自旋和阻塞不是二选一,而是同一把锁的两个阶段。
在 libraryd-conc 里落地
借阅服务里有一个真实需要「等待通知」的角色:对账 worker。它平时不干活,收到借阅单号才处理;没有活时应该阻塞,而不是空转轮询数据库。
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.locks.LockSupport;
public final class LoanAuditWorker implements AutoCloseable {
private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
private final Thread worker;
private volatile boolean running = true;
public LoanAuditWorker() {
worker = Thread.ofPlatform().name("loan-audit").start(this::loop);
}
public void submit(String loanId) {
queue.add(loanId);
LockSupport.unpark(worker); // 许可最多一个,重复调用无害
}
private void loop() {
while (running) {
String loanId = queue.poll();
if (loanId == null) {
LockSupport.park(this); // blocker 会出现在线程 dump 里
continue;
}
audit(loanId);
}
}
private void audit(String loanId) {
// 调用下游对账;失败重试策略由调用方决定
}
@Override
public void close() {
running = false;
LockSupport.unpark(worker);
try {
worker.join(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这段代码有三处值得说明:
- 工作项放在队列里,许可只负责唤醒。
submit先入队再unpark;即使unpark发生在 worker 判断poll()为空之后、park()之前,许可已经被存下,park()会立即返回,循环重新poll()就能看到新元素。反之,如果只用volatile boolean hasWork加park,就会丢通知。 - 重复
unpark不会积累许可。 连续提交 10 个单号,可能只留下 1 个许可;但队列里有 10 个元素,worker 被唤醒一次后会把它们全部poll出来,所以不会漏单。 close用running加unpark做两阶段终止。 如果 worker 正在audit,join(1000)最多等 1 秒;方法返回不等于线程已经结束,这一点第 6.2 节讲过。
自旋在这个类里没有位置:对账是 IO 密集操作,等待时间不可预测,空转会白烧一个核。
常见错误与边界
把 park 当成信号量。 unpark 两次不等于唤醒两次。需要计数就用 Semaphore,需要排队就用 BlockingQueue。
只 park 一次,不写循环。 虚假唤醒、中断、提前到达的许可都会让 park 返回;返回不代表条件成立。
在 park 前不检查条件。 正确顺序是「检查条件 → 不满足才 park」。park 的许可模型让检查和 park 之间不需要额外同步:通知即使发生在检查之后、park 之前,也会被存成许可,不会丢:
while (!condition) {
LockSupport.park(this);
}
以为 park 会释放锁。 如果线程持有 ReentrantLock 再 park,锁仍然在自己手里,其它线程进不来,很容易自己把自己锁死。wait 会释放监视器,park 不会。
在单核容器里手写自旋。 CPU limit 为 1 的 Pod 里,自旋线程会把仅有的 CPU 占满,持锁线程得不到调度,表现为「锁永远拿不到」,而不是「慢」。
用 Thread.onSpinWait() 当延时。 它的语义是「我在自旋」,不是「睡一会」。需要退避时用 parkNanos 或 Thread.yield(),并且要验证退避是否真的降低了竞争。
小结
- 自旋用 CPU 换上下文切换,只适合极短、无阻塞的临界区;等待者多于核数或单核环境下自旋会拖慢持锁线程。
- 线程 dump 里自旋线程是
RUNNABLE,park线程是WAITING (parking),两者的 CPU 累计时间差了两个数量级以上(本机 1252ms vs 0.15ms)。 - JDK 25 没有
UseSpinning/PreBlockSpin,自适应自旋是 HotSpot 内部行为,不是应用调优参数。 park/unpark基于每线程一个许可:许可最多一个、可以先存、不跨线程转移;unpark不设置中断标志。park被中断时直接返回且不清除中断标志,这一点和sleep/wait相反。park可能虚假唤醒,必须放在条件循环里;park不释放任何锁,不能拿来替代wait。- 给
park传 blocker,线程 dump 才能显示在等哪个对象。
下一节把 park/unpark 放回 ReentrantLock,看它在可中断、超时、公平性和多条件队列上比 synchronized 多给了什么,以及 JDK 25 修掉虚拟线程 pinning 之后这些理由还剩几条。
阅读导航:上一节 8.3 LongAdder 与高竞争累加 | 下一节 9.2 ReentrantLock 与 synchronized 的取舍
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。