Park 打断大反转!一次 park 不阻塞,参数化日志竟成幕后黑手?
在 Java 并发学习中,lock-support 的 park() 与 interrupt() 常被认为是 “中断后再次 park 不会阻塞”。但经过一系列精心设计的 Demo 测试,发现了一个令人意外的现象:
当中断状态为 true时,再次调用park()的确不阻塞,这是符合预期的行为。当用 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();// 等待线程进入 parkTimeUnit.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();// 这一步查看打断标记一定是 falselog.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();// 等待线程进入 parkTimeUnit.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();// 这一步查看打断标记一定是 falselog.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();// 等待线程进入 parkTimeUnit.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();// 这一步查看打断标记一定是 falselog.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();// 等待线程进入 parkTimeUnit.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 时,仅是消耗掉备用干粮,不需停留继续前进
当前线程调用 Unsafe.park() 方法 检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁 线程进入 _cond 条件变量阻塞 设置 _counter = 0
调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1 唤醒 _cond 条件变量中的 Thread_0 Thread_0 恢复运行 设置 _counter 为 0
5.实验现象讨论
LockSupport 内部的许可是线程私有的,底层是通过 Unsafe.park 实现,许可保存在线程的对象内部,JVM 并没有为开发者暴露任何查询接口
但是查询资料得知 JVM 的特性
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()
设置中断标志; 调用 _SleepEvent.unpark()、parker()->unpark()、_ParkEvent.unpark(),从而触发许可或唤醒阻塞线程
Parker 内部维护的是一个 二元许可状态(permit=0/1),与中断标志是两套机制 JavaThread::interrupt() 会同时:
所以即使后来 Thread.interrupted() 清除了中断标志,许可依然存在,可以被下一次 park() 直接消耗并立即返回
第 1 次执行 park 操作执行 interrupt 打断操作...线程继续运行,当前打断状态为:trueinterrupted 清除打断标记... false第 2 次执行 park 操作
对于上面输出的 permit 状态的演变:
起初 permit = 0 ➝ 第一次 park() 阻塞; interrupt() 调用:设置 permit = 1 + 中断标志 = true; 第一次 park() 被唤醒并 consumes permit ➝ permit = 0; Thread.interrupted() 清除中断标志,无 permit 损耗; 第二次 park():permit=0 ➝ 阻塞成功。 ✅ 精准验证了 permit 被第一次 park() 而非 Thread.interrupted() 消耗了。
至于为什么出现参数化日志导致 “不阻塞” 的现象,我的解释是
参数化日志 log.debug("第 {} 次执行 park 操作",...)会 「推迟参数替换和字符串拼接」,影响代码执行顺序;这可能导致第二次 park()调用「发生在 permit 还没被消费」的位置;所以就出现了 “第二次不阻塞” 的现象,但这来源于「时序微调」,而非机制本身的变化
总结
最后来一个总结
interrupt() 会 隐式执行 unpark,增加 permit,但 permit 是一次性变量,会被下一次 park() 消耗 Thread.interrupted() 只影响 Java 层的中断标志,并不会触发任何 permit 操作;而 thread.interrupt() 会调用底层的 unpark,从而增加 permit。permit 是否被 park 消耗决定了线程是否阻塞 permit 的存在与否决定 park() 是否阻塞,日志与时序影响可能导致行为微妙差异 使用 park() 时需谨慎管理许可状态、线程中断以及防虚假唤醒