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() 时是无法阻塞线程的

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

java 复制代码
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();
}
java 复制代码
23:42:47.069  -  第 1 次执行 park 操作 
23:42:48.078  -  执行 interrupt 打断操作...
23:42:48.078  -  线程继续运行,当前打断状态为:true
23:42:48.078  -  第 2 次执行 park 操作
23:42:48.078  -  线程继续运行,当前打断状态为:true
23:42:48.078  -  第 3 次执行 park 操作
23:42:48.078  -  线程继续运行,当前打断状态为:true
23:42:48.078  -  第 4 次执行 park 操作
23:42:48.078  -  线程继续运行,当前打断状态为:true
23: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 和对应的输出

java 复制代码
private static void interruptPark() 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();
}
java 复制代码
09:54:55.537   -  第 1 次执行 park 操作
09:54:56.545   -  执行 interrupt 打断操作...
09:54:56.545   -  线程继续运行,当前打断状态为:true
09:54:56.546   -  interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false
09:54:57.561   -  第 2 次执行 park 操作
09:54:57.561   -  线程继续运行,当前打断状态为:false
09:54:58.565   -  第 3 次执行 park 操作

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

java 复制代码
private static void interruptPark() 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();
}
java 复制代码
09:56:12.675  -  第 1 次执行 park 操作
09:56:13.664  -  执行 interrupt 打断操作...
09:56:13.664  -  线程继续运行,当前打断状态为:true
09:56:13.666  -  interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false
09:56:14.678  -  第 2 次执行 park 操作

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

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

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

java 复制代码
private static void interruptPark() 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();
}
java 复制代码
09:56:49.762  -   第 1 次执行 park 操作
09:56:50.772  -  执行 interrupt 打断操作...
09:56:50.773  -   线程继续运行,当前打断状态为:true
09:56:50.773  -   interrupted 清除打断标记, 打断状态一定为false, 查看结果为:false
09:56:51.786  -   第 2 次执行 park 操作

4. park 的底层原理

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

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

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

5. 实验现象讨论

<font style="color:rgb(77, 77, 77);">LockSupport</font> 内部的许可是线程私有的,底层是通过 <font style="color:rgb(77, 77, 77);">Unsafe.park</font> 实现,许可保存在线程的对象内部,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/do...
java 复制代码
// interrupted方法调用native方法interrupt0(),而interrupt0()的c++源码如下
osthread()->set_interrupted(true);
_SleepEvent->unpark();         // 唤醒 Thread.sleep()
parker()->unpark();           // 唤醒 LockSupport.park()
_ParkEvent->unpark();         // 唤醒 synchronized / wait()
  1. permit 与中断标志是完全独立的两套机制
    • Parker 内部维护的是一个 二元许可状态(permit=0/1),与中断标志是两套机制
    • JavaThread::interrupt() 会同时:
      • 设置中断标志;
      • 调用 _SleepEvent.unpark()、parker()->unpark()、_ParkEvent.unpark(),从而触发许可或唤醒阻塞线程

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

java 复制代码
第 1 次执行 park 操作
执行 interrupt 打断操作...
线程继续运行,当前打断状态为:true
interrupted 清除打断标记... 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() 时需谨慎管理许可状态、线程中断以及防虚假唤醒
相关推荐
你的人类朋友29 分钟前
Let‘s Encrypt 免费获取 SSL、TLS 证书的原理
后端
老葱头蒸鸡32 分钟前
(14)ASP.NET Core2.2 中的日志记录
后端·asp.net
失散131 小时前
分布式专题——23 Kafka日志索引详解
java·分布式·云原生·架构·kafka
西红柿维生素1 小时前
CPU核心数&线程池&设计模式&JUC
java
云虎软件朱总1 小时前
配送跑腿系统:构建高并发、低延迟的同城配送系统架构解析
java·系统架构·uni-app
18538162800余+1 小时前
深入解析:什么是矩阵系统源码搭建定制化开发,支持OEM贴牌
java·服务器·html
李昊哲小课1 小时前
Spring Boot 基础教程
java·大数据·spring boot·后端
code123131 小时前
tomcat升级操作
java·tomcat
码事漫谈1 小时前
C++内存越界的幽灵:为什么代码运行正常,free时却崩溃了?
后端
TanYYF1 小时前
HttpServletRequestWrapper详解
java·servlet