《Java 并发编程·线程与内存模型》9.1 自旋、阻塞与 park/unpark

从自旋锁的 CPU 代价讲到 LockSupport.park/unpark 的许可语义:许可最多一个、先 unpark 后 park 也生效、中断不清标志、虚假唤醒为何必须循环检查。附本机线程 dump 与 park 边界情形的真实输出,并给出借阅服务的唤醒式 worker。

承接的问题

第 8 章用 CAS 把「读-改-写」变成了一个原子指令。但 CAS 只解决「怎么改」,没有解决「改不了的时候怎么办」。

两个线程同时扣减同一本书的库存,一个成功,另一个的 compareAndSet 返回 false。失败者有三个选择:

  1. 立刻重试,直到成功——自旋;
  2. 让出 CPU,等别人通知——阻塞;
  3. 放弃,向上返回失败——超时/快速失败。

第三种是业务层的选择,前两种是运行时和锁实现的选择。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 / notifyAllunpark(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 的取舍

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收