稀土掘金技术社区

Park 打断大反转!一次 park 不阻塞,参数化日志竟成幕后黑手?

在 Java 并发学习中,lock-support 的 park() 与 interrupt() 常被认为是 “中断后再次 park 不会阻塞”。但经过一系列精心设计的 Demo 测试,发现了一个令人意外的现象:

  1. 当中断状态为 true 时,再次调用 park() 的确不阻塞,这是符合预期的行为。
  2. 当用 Thread.interrupted() 清除中断标记后,预期下次 park() 会立即阻塞,但实测却发现:
  • 连续两次调用 park() 才能真正阻塞线程。
  • 更奇妙的是,仅修改了日志打印方式(参数化 vs 字符串拼接),竟能让 “第二次 park 一次就阻塞”!

本文深入拆解这一行为背后的核心原因:

  • 「permit(许可)与 Java 中断标志是两套独立机制」:
    • interrupt() 会隐式调用 unpark(),设置 permit=1;
    • park() 消耗 permit,触发阻塞或继续运行;
    • Thread.interrupted() 只清除 Java 标志,不影响 permit。
  • 日志格式、线程调度等细微时序变化会影响 permit 的设置和消费时机,从而决定 park() 是否阻塞。

1.中断状态为 true 时, park 不阻塞

首先我们都认为:打断 Park 阻塞的线程时,如果打断标记已经是 true, 再次调用 park() 时是无法阻塞线程的

验证上面的知识点,代码如下

private static void interruptPark() throws InterruptedException {    Thread interruptParkThread = new Thread(() -> {        Thread thread = Thread.currentThread();        for (int i = 0; i < 5; i++) {            log.debug("第 {} 次执行 park 操作", i + 1);            LockSupport.park();            log.debug("线程继续运行,当前打断状态为:{}", thread.isInterrupted());        }    }, "interruptParkThread");
    // 开启线程    interruptParkThread.start();    // 等待线程进入 park    TimeUnit.SECONDS.sleep(1);    log.debug("执行 interrupt 打断操作...");    interruptParkThread.interrupt();}
23:42:47.069  -  第 1 次执行 park 操作 23:42:48.078  -  执行 interrupt 打断操作...23:42:48.078  -  线程继续运行,当前打断状态为:true23:42:48.078  -  第 2 次执行 park 操作23:42:48.078  -  线程继续运行,当前打断状态为:true23:42:48.078  -  第 3 次执行 park 操作23:42:48.078  -  线程继续运行,当前打断状态为:true23:42:48.078  -  第 4 次执行 park 操作23:42:48.078  -  线程继续运行,当前打断状态为:true23:42:48.078  -  第 5 次执行 park 操作23:42:48.078  -  线程继续运行,当前打断状态为:true

2.清除中断标志后,需要连续两次 park 才阻塞

那么我就又想到,如果打断标记是 false,使用 LockSupport.park() 的时候,应该会阻塞线程吧?

当我书写下面的 Demo 想要验证上面的想法的时候,就出现了一个有趣的现象:

打断标记是 false 时,第一次使用 LockSupport.park() 并没有阻塞线程,当我连续调用两次 LockSupport.park() 才将线程阻塞住!

有趣的是,我调整了一下日志的输出,既然惊奇的发现恢复打断标记为 false 之后,只需要一次 LockSupport.park() 就能够阻塞线程了,详细请看下面的 Demo 和对应的输出

privatestaticvoidinterruptPark() throws InterruptedException {    Thread interruptParkThread = new Thread(() -> {        Thread thread = Thread.currentThread();        for (int i = 0; i < 5; i++) {            // thread.isInterrupted() 只判断当前线程是否被打断,不会清空打断标记            if (thread.isInterrupted()) {                // Thread.interrupted() 判断当前线程是否被打断,但会清除打断标记                Thread.interrupted();                // 这一步查看打断标记一定是 false                log.debug("interrupted 清除打断标记, 打断状态一定为false, 查看结果为:{}", thread.isInterrupted());            }            try {                TimeUnit.SECONDS.sleep(1); // 睡眠一秒,在进行打断            } catch (InterruptedException e) {                throw new RuntimeException(e);            }            log.debug("第 " + (i + 1) + " 次执行 park 操作");            LockSupport.park();            log.debug("线程继续运行,当前打断状态为:{}", thread.isInterrupted());        }    }, "interruptParkThread");
    // 开启线程    interruptParkThread.start();    // 等待线程进入 park    TimeUnit.SECONDS.sleep(2);    log.debug("执行 interrupt 打断操作...");    interruptParkThread.interrupt();}
09:54:55.537   -  第 1 次执行 park 操作09:54:56.545   -  执行 interrupt 打断操作...09:54:56.545   -  线程继续运行,当前打断状态为:true09:54:56.546   -  interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false09:54:57.561   -  第 2 次执行 park 操作09:54:57.561   -  线程继续运行,当前打断状态为:false09:54:58.565   -  第 3 次执行 park 操作

当我连续调用两次 LockSupport.park() 才将线程阻塞住

privatestaticvoidinterruptPark() throws InterruptedException {    Thread interruptParkThread = new Thread(() -> {        Thread thread = Thread.currentThread();        for (int i = 0; i < 5; i++) {            // thread.isInterrupted() 只判断当前线程是否被打断,不会清空打断标记            if (thread.isInterrupted()) {                // Thread.interrupted() 判断当前线程是否被打断,但会清除打断标记                Thread.interrupted();                // 这一步查看打断标记一定是 false                log.debug("interrupted 清除打断标记, 打断状态一定为false, 查看结果为:{}", thread.isInterrupted());            }            try {                TimeUnit.SECONDS.sleep(1); // 睡眠1秒,在进行打断            } catch (InterruptedException e) {                throw new RuntimeException(e);            }            log.debug("第 " + (i + 1) + " 次执行 park 操作");            LockSupport.park();            if(i == 1){                LockSupport.park(); // 连续两次 park            }            log.debug("线程继续运行,当前打断状态为:{}", thread.isInterrupted());        }    }, "interruptParkThread");
    // 开启线程    interruptParkThread.start();    // 等待线程进入 park    TimeUnit.SECONDS.sleep(2);    log.debug("执行 interrupt 打断操作...");    interruptParkThread.interrupt();}
09:56:12.675  -  第 1 次执行 park 操作09:56:13.664  -  执行 interrupt 打断操作...09:56:13.664  -  线程继续运行,当前打断状态为:true09:56:13.666  -  interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false09:56:14.678  -  第 2 次执行 park 操作

3.日志书写方式影响 park 执行结果

下面的 Demo 中,我仅仅调整了日志的输出,我想让日志书写规范一点,所以将 log.debug("第" + (i + 1) + "次执行 park 操作"); 修改为了 log.debug("第" + (i + 1) + "次执行 park 操作");

有趣的事情就发现了... 我并没有使用两次 park,但是在第二次 park 线程的时候,是成功阻塞的!

privatestaticvoidinterruptPark() throws InterruptedException {    Thread interruptParkThread = new Thread(() -> {        Thread thread = Thread.currentThread();        for (int i = 0; i < 5; i++) {            // thread.isInterrupted() 只判断当前线程是否被打断,不会清空打断标记            if (thread.isInterrupted()) {                // Thread.interrupted() 判断当前线程是否被打断,但会清除打断标记                Thread.interrupted();                // 这一步查看打断标记一定是 false                log.debug("interrupted 清除打断标记, 打断状态一定为false, 查看结果为:{}", thread.isInterrupted());            }            try {                TimeUnit.SECONDS.sleep(1); // 睡眠1秒,在进行打断            } catch (InterruptedException e) {                throw new RuntimeException(e);            }            log.debug("第 {} 次执行 park 操作", i + 1);            LockSupport.park();            log.debug("线程继续运行,当前打断状态为:{}", thread.isInterrupted());        }    }, "interruptParkThread");
    // 开启线程    interruptParkThread.start();    // 等待线程进入 park    TimeUnit.SECONDS.sleep(2);    log.debug("执行 interrupt 打断操作...");    interruptParkThread.interrupt();}
09:56:49.762  -   第 1 次执行 park 操作09:56:50.772  -  执行 interrupt 打断操作...09:56:50.773  -   线程继续运行,当前打断状态为:true09:56:50.773  -   interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false09:56:51.786  -   第 2 次执行 park 操作

4.park 的底层原理

为了探究是什么原因造成了这个奇怪的现象,我发现了 park 和 interrupt 的一些底层原理,用来解释这个现象

每个线程都有自己的一个 Parker 对象 (由 C++ 编写,java 中不可见),由三部分组成 _counter , _cond 和 _mutex 打个比喻

  • 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足
    • 「因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮」
    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
Image
  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0
Image
  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0

5.实验现象讨论

LockSupport 内部的许可是线程私有的,底层是通过 Unsafe.park 实现,许可保存在线程的对象内部,JVM 并没有为开发者暴露任何查询接口

但是查询资料得知 JVM 的特性

1. Thread.interrupted() 不会清除 Parker 的 permit,Thread.interrupted() 只清除 Thread 对象上的 中断标志位(Java 层面),不会影响底层的 park permit
  • permit 是由 LockSupport.unpark() 或底层 JavaThread::interrupt() 调用 parker()->unpark() 设置的,一旦设置,将一直保留,直到被 park() 消耗掉
  • 《LockSupport 文档》明确指出 permit 最多只有一个;park() 会消耗,而 unpark() 重发许可
  • docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/LockSupport.html?utm_source

// interrupted方法调用native方法interrupt0(),而interrupt0()的c++源码如下osthread()->set_interrupted(true);_SleepEvent->unpark();         // 唤醒 Thread.sleep()parker()->unpark();           // 唤醒 LockSupport.park()_ParkEvent->unpark();         // 唤醒 synchronized / wait()
2. permit 与中断标志是完全独立的两套机制
  • 设置中断标志;
  • 调用 _SleepEvent.unpark()、parker()->unpark()、_ParkEvent.unpark(),从而触发许可或唤醒阻塞线程
  • Parker 内部维护的是一个 二元许可状态(permit=0/1),与中断标志是两套机制
  • JavaThread::interrupt() 会同时:

所以即使后来 Thread.interrupted() 清除了中断标志,许可依然存在,可以被下一次 park() 直接消耗并立即返回

第 1 次执行 park 操作执行 interrupt 打断操作...线程继续运行,当前打断状态为:trueinterrupted 清除打断标记... false第 2 次执行 park 操作

对于上面输出的 permit 状态的演变:

  1. 起初 permit = 0 ➝ 第一次 park() 阻塞;
  2. interrupt() 调用:设置 permit = 1 + 中断标志 = true;
  3. 第一次 park() 被唤醒并 consumes permit ➝ permit = 0;
  4. Thread.interrupted() 清除中断标志,无 permit 损耗;
  5. 第二次 park():permit=0 ➝ 阻塞成功。
  6. ✅ 精准验证了 permit 被第一次 park() 而非 Thread.interrupted() 消耗了。

至于为什么出现参数化日志导致 “不阻塞” 的现象,我的解释是

  • 参数化日志 log.debug("第 {} 次执行 park 操作",...) 会 「推迟参数替换和字符串拼接」,影响代码执行顺序;
  • 这可能导致第二次 park() 调用「发生在 permit 还没被消费」的位置;
  • 所以就出现了 “第二次不阻塞” 的现象,但这来源于「时序微调」,而非机制本身的变化

总结

最后来一个总结

  1. interrupt() 会 隐式执行 unpark,增加 permit,但 permit 是一次性变量,会被下一次 park() 消耗
  2. Thread.interrupted() 只影响 Java 层的中断标志,并不会触发任何 permit 操作;而 thread.interrupt() 会调用底层的 unpark,从而增加 permit。permit 是否被 park 消耗决定了线程是否阻塞
  3. permit 的存在与否决定 park() 是否阻塞,日志与时序影响可能导致行为微妙差异
  4. 使用 park() 时需谨慎管理许可状态、线程中断以及防虚假唤醒