比价请求已经超时,后台询价线程却仍在等待结果。仅让 HTTP 接口返回降级价格,不会自动结束所有后台工作;直接把线程强行停止,又可能留下只修改了一半的共享状态。可靠退出需要一套协作协议:谁发出请求,任务在哪里观察请求,阻塞时如何被唤醒,退出前如何释放资源。
这是“Java 并发工程”的第四篇。我们把中断、条件等待与 LockSupport 放在同一条问题线上,但不把它们当成可随意替换的三个 API。中断传递取消意图,条件等待围绕业务状态协调,park/unpark 提供构建更高层同步器所需的低层阻塞机制。
中断是一种协作请求
调用 thread.interrupt 通常会设置目标线程的中断状态,并使某些可中断阻塞操作作出响应。它不会在任意指令位置强制杀死线程。正在执行计算的任务需要主动检查状态;阻塞在不同 API 中的任务,则需要遵守对应 API 的中断契约。
例如,CountDownLatch.await 可以因中断抛出 InterruptedException。捕获异常后的代码决定是清理并退出、继续向上抛出,还是恢复中断标记后返回。真正让任务停止的,是任务自身执行了退出路径,而不是中断方法替它完成了全部业务清理。
不能把 volatile 停止标记看作所有情况下的替代品。它可以让计算循环知道应该停止,却不能自动唤醒一个一直阻塞在等待操作里的线程。需要将“发布取消状态”和“解除等待”配合起来,或者直接选用有成熟取消协议的执行组件。
三个中断方法要按调用对象区分
interrupt 是对目标线程发出请求,isInterrupted 观察某个线程的标记而不清除,静态 Thread.interrupted 观察并清除当前线程的标记。最后一个方法容易被误读成查询某个变量所指向的线程;规范要求关注的是实际执行调用的当前线程。
抛出 InterruptedException 的一些等待方法会清除中断状态。如果上层仍需要识别取消,而当前方法又不能继续抛出受检异常,常见处理是在清理后重新设置标记。是否恢复应结合方法的取消契约,不应机械地捕获、打印、然后继续原来的无限循环。
try {
queue.take();
} catch (InterruptedException cancelled) {
Thread.currentThread().interrupt();
return; // 当前层按约定结束任务
}
如果当前线程本身就是任务边界,也可以在完成清理后直接结束,但必须让调用方明确得知任务已取消。吞掉异常并返回一个看似正常的空结果,会把取消与业务成功混为一谈,后续重试和监控也就失去了依据。
等待的对象应当是条件
等待报价就绪时,真正关心的是 ready 是否成立,而不是有没有收到一次 signal。通知只是让等待者有机会再次检查状态。通知可能发生在等待之前,等待可能因中断、超时或虚假唤醒结束,其他线程也可能在当前线程重新取得锁之前改变条件。
因此,经典模式是持有保护状态的锁,在 while 中检查谓词,不满足就等待,醒来后重新检查。生产者同样持有这把锁,先修改状态,再通知等待者。这样才能把状态读取、等待队列登记与状态更新连成一致协议。
lock.lockInterruptibly();
try {
while (!ready) {
remaining = changed.awaitNanos(remaining);
if (remaining <= 0 && !ready) throw new TimeoutException();
}
consumeQuote();
} finally {
lock.unlock();
}
示例假设 remaining 已由调用方设置为正的超时预算,完整实验也检查预算耗尽。每次等待使用剩余时间,而不是每次唤醒后重新给一整段超时,否则反复唤醒可能让一个本应有限的等待不断延长。
wait、sleep 与 Condition 的关系
Object.wait 必须在持有相应监视器时调用,并在等待期间释放这个对象的监视器。它不会释放线程持有的所有其他锁。Thread.sleep 则不会因为睡眠而释放已持有监视器,也不提供共享变量的同步语义。
对于 ReentrantLock 创建的 Condition,await 会按约定释放关联锁并等待,返回或抛出中断异常前会重新获取锁。signal 不是把锁立即转交给某个等待者,发出信号的线程仍需退出临界区,等待者还需要重新竞争。
一个锁可以关联多个条件队列,例如队列未满与队列非空。用不同 Condition 可以把通知目标与业务谓词对应起来,但程序仍要保证谓词正确,不能仅靠多个名字不同的条件对象消除竞争。本文实验只使用一个条件,避免把队列实现细节盖过等待协议。
“先通知再等待”需要怎样理解
Object.notify 不会为未来的等待者存下一张长期通知票。如果程序不检查状态,只是先通知一次、稍后无条件 wait,就可能一直等不到新的通知。导图里“必须先 wait 后 notify”的口诀描述的是这种错误示例的风险,不是所有正确程序必须遵守的时间顺序。
正确程序完全可以先把 ready 设为 true,再由晚到的线程获取锁并检查条件。它发现条件已满足,就不需要进入等待。解决丢通知问题的关键是可靠保存业务状态,并在锁保护下协调检查与等待,而不是靠 sleep 调整谁先执行。
这也说明为什么测试不应只验证“某线程被叫醒了”。更有价值的断言是醒来后看到正确状态、未满足条件时不提前消费、取消时能够退出,以及无论成功失败都释放资源。
LockSupport 的许可最多保留一份
LockSupport 为每个线程关联一个许可。unpark 让许可可用,park 在许可可用时消耗它并返回,因此已经运行的线程可以先得到许可,再调用 park。连续两次 unpark 不会累积成两张票;如果需要计数语义,应使用 Semaphore 等更高层工具。
park 还可能因为中断或其他原因返回,不能把返回本身解释成业务条件成立。底层同步器通常在循环中检查自己的状态,只有确认条件满足才继续。没有共享状态协议的“裸 park”,很难独立承担可靠业务等待。
注意 unpark 在线程启动之前不保证有相同效果。中间的其他 park 也可能消费许可。实验使用已经运行的线程对自己 unpark,然后立即 park,避免把线程启动和其他阻塞操作混入许可演示。
blocker 不是需要被阻塞的线程
park(Object blocker) 阻塞的是调用它的当前线程。blocker 用来帮助诊断工具解释线程为何阻塞,可以通过 getBlocker 查询;它不是目标线程参数,也不是自动被获取的锁。要通知另一个线程,应调用 unpark(Thread)。LockSupport 文档明确区分了这两个参数的作用。
实验启动一个围绕退出谓词循环 park 的工作线程。主线程观察到它的 blocker 后发出中断,工作线程返回并检查中断状态。park 不抛出 InterruptedException,也不会像一些可中断等待方法那样清除标记,代码必须自行决定下一步。
如果忽略已经设置的中断状态并继续 park,后续调用可能不断立即返回,形成忙循环。因此处理中断不仅关系到退出正确性,也关系到是否意外持续消耗 CPU。先确定上层契约,再选择清除、传播或结束任务。
五项实验怎样避免偶然顺序
python3 run.py --work /tmp/java-concurrency-run --group CoordinationLab
本机五项测试通过:中断等待任务后捕获异常并恢复标记;验证静态 interrupted 的清除效果;在关联锁内修改条件并唤醒;先发许可后 park 返回;诊断 blocker 并验证 park 中断后的状态。细节见实测摘要。
阶段同步使用带超时的门闩,状态观察有截止时间,工作线程异常会传回主线程。测试没有使用随意的睡眠来保证等待者已经到达某行代码,也没有断言线程必须在某个毫秒内被操作系统调度。
五项通过不表示我们成功制造了虚假唤醒。循环写法来自 API 契约,不能因为某次没有观察到虚假唤醒就删掉谓词检查。许可实验也只验证明确控制的调用顺序,不把它扩展为任意位置调用 unpark 都不会失效。
把超时预算传到整个调用链
真实报价任务可能先等待线程池执行,再获取本地锁,最后访问商家服务。如果每一步都重新获得三秒预算,整体响应可能远超三秒。更稳妥的设计是由入口设定截止时间,各层根据剩余预算决定等待、降级或取消。
本系列的五秒超时主要用作测试卡死保护,示例中的短超时用于触发 API 行为,它们不是推荐的线上配置。生产参数需要根据下游延迟、重试预算和用户请求时限确定,也需要明确取消能否传递到正在进行的 I/O。
资源释放应覆盖所有退出路径。拿到锁后放在 finally 中释放,打开的连接按所属组件契约关闭,临时上下文在作用域结束时清理。把这些责任绑定到明确的任务边界,才能让取消成为可预测的行为,而不是只在日志里多一条中断异常。
多个等待者需要共同维护谓词
只有一个等待者时,通知选择看起来简单;多个消费者等待同一份报价时,条件可能在被唤醒线程真正执行前再次变化。例如一个线程先取走唯一结果,另一个线程随后取得锁,就必须重新发现“结果已经被消费”,继续等待或按协议退出。把 while 改成 if,会遗漏这一合法交错。
使用 signalAll 可以让所有等待者重新评估各自条件,但也可能带来更多无效唤醒。使用 signal 则要求明确被选中的等待者是否有能力推动流程。如果多个不同条件混用一个等待队列,唤醒了不能继续工作的线程,可能导致其他等待者一直没有机会。工程实现应先把条件与通知对象对应起来,再讨论减少唤醒次数。
等待者取消后,生产者也需要知道该结果是否还应被保留。单纯唤醒线程不会处理积压数据、关闭输入或者释放连接。一个可终止的队列通常需要除了“非空”之外的关闭状态,使等待循环能够在没有新数据时也合法结束。
这些问题说明线程协作不是两行通知代码,而是由状态、等待、唤醒和终止组成的协议。本篇选择单条件的有限实验,把最小机制解释清楚;扩展为生产者消费者组件时,应增加关闭、重复关闭、取消与并发提交等测试,不能只复制一次成功唤醒的示例。
面试归纳
可以用一句完整的业务描述串起本篇:请求超时后发出中断,任务在可中断等待处响应并退出;等待正常结果时用同一把锁保护谓词,唤醒后循环复查;构建同步器时可用 park/unpark 的许可机制,但 park 返回不代表条件已满足。
下一篇沿 ReentrantLock 的获取和释放路径理解 AQS,观察这里的中断、排队与许可如何被组织成一个可复用的同步框架。