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() 时需谨慎管理许可状态、线程中断以及防虚假唤醒
相关推荐
非ban必选13 分钟前
spring-ai-alibaba之Rag 增强问答质量
java·人工智能·spring
码农12138号30 分钟前
BUUCTF在线评测-练习场-WebCTF习题[RoarCTF 2019]Easy Java1-flag获取、解析
java·web安全·网络安全·ctf·buuctf·任意文件下载漏洞
LaoZhangAI1 小时前
GPT-4o mini API限制完全指南:令牌配额、访问限制及优化策略【2025最新】
前端·后端
LaoZhangAI1 小时前
FLUX.1 API图像尺寸设置全指南:优化生成效果与成本
前端·后端
rockmelodies1 小时前
【JAVA安全】Java 集合体系详解
java·python·安全·集合
Z_W_H_1 小时前
【SpringBoot】实战-开发接口-用户-注册
java·spring boot·spring
Kookoos1 小时前
ABP VNext + EF Core 二级缓存:提升查询性能
后端·.net·二级缓存·ef core·abp vnext
生活百般滋味,人生需要笑对。 --佚名1 小时前
springboot如何redis锁
java·spring boot·redis
月初,1 小时前
SpringBoot集成Minio存储文件,开发图片上传等接口
java·spring boot·后端
oioihoii1 小时前
C++11迭代器改进:深入理解std::begin、std::end、std::next与std::prev
java·开发语言·c++