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() 时是无法阻塞线程的
验证上面的知识点,代码如下
privatestaticvoidinterruptPark()throwsInterruptedException { ThreadinterruptParkThread=newThread(() - { Threadthread=Thread.currentThread(); for(inti=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 - 线程继续运行,当前打断状态为:true2.清除中断标志后,需要连续两次 park 才阻塞那么我就又想到,如果打断标记是 false,使用 LockSupport.park() 的时候,应该会阻塞线程吧?
当我书写下面的 Demo 想要验证上面的想法的时候,就出现了一个有趣的现象:
打断标记是 false 时,第一次使用 LockSupport.park() 并没有阻塞线程,当我连续调用两次 LockSupport.park() 才将线程阻塞住!
有趣的是,我调整了一下日志的输出,既然惊奇的发现恢复打断标记为 false 之后,只需要一次 LockSupport.park() 就能够阻塞线程了,详细请看下面的 Demo 和对应的输出
privatestaticvoidinterruptPark()throws InterruptedException{ Thread interruptParkThread =newThread(() - { Thread thread = Thread.currentThread(); for(inti =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) { thrownewRuntimeException(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 =newThread(() - { Thread thread = Thread.currentThread(); for(inti =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) { thrownewRuntimeException(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 =newThread(() - { Thread thread = Thread.currentThread(); for(inti =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) { thrownewRuntimeException(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 时,仅是消耗掉备用干粮,不需停留继续前进当前线程调用 Unsafe.park() 方法检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁线程进入 _cond 条件变量阻塞设置 _counter = 0调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1唤醒 _cond 条件变量中的 Thread_0Thread_0 恢复运行设置 _counter 为 05.实验现象讨论LockSupport内部的许可是线程私有的,底层是通过Unsafe.park实现,许可保存在线程的对象内部,JVM 并没有为开发者暴露任何查询接口
但是查询资料得知 JVM 的特性
1. Thread.interrupted() 不会清除 Parker 的 permit,Thread.interrupted() 只清除 Thread 对象上的 中断标志位(Java 层面),不会影响底层的 park permitpermit 是由 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 状态的演变:
起初 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() 时需谨慎管理许可状态、线程中断以及防虚假唤醒AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线