Java 中如何低成本地等待和唤醒线程,并结合短暂自旋?
可以用 volatile 发布进度,让已满足条件的线程直接返回;需要等待的线程通过 LockSupport.park/unpark 阻塞和唤醒。这样可以避免所有等待者重新获取同一把业务锁,但广播唤醒和线程调度仍有成本,不能保证比 wait/notifyAll 更快。
也可以短暂自旋后再阻塞。自旋是否划算取决于等待时间、CPU 核数和线程数量,需要在实际负载下判断。
先区分执行权和进度等待
这个场景有两个独立条件:
- 执行权:同一时刻只能有一个线程推进任务。
- 完成进度:不同等待者分别等待自己需要的进度。
执行权可以由锁或 CAS 控制。进度可以用一个单调递增的 volatile 值表示,例如已经完成的序号。线程先读取进度,只有目标尚未达到时才进入等待流程。
notifyAll() 会让等待线程有机会继续运行,但 wait() 返回前必须重新获得对象监视器。换成 Condition.signalAll() 后,线程仍需要重新获取对应的锁。
用 LockSupport 避免等待者重新获取业务锁
下面的示例假设进度从 0 开始,且只有一个工作线程调用 completeTo。示例展示的是进度等待机制,执行者的选举需要另外实现。
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.locks.LockSupport;
public final class Progress {
private volatile long completed;
private final ConcurrentLinkedQueue<Thread> waiters =
new ConcurrentLinkedQueue<>();
public void await(long target) throws InterruptedException {
if (Thread.interrupted()) {
throw new InterruptedException();
}
if (completed >= target) {
return;
}
Thread current = Thread.currentThread();
waiters.add(current);
try {
// Register first, then check again before parking.
while (completed < target) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
LockSupport.park(this);
}
} finally {
waiters.remove(current);
}
}
// Must be called by a single worker thread.
public void completeTo(long value) {
if (value < completed) {
throw new IllegalArgumentException(
"Progress must not move backwards");
}
if (value == completed) {
return;
}
// Finish the corresponding work before publishing progress.
completed = value;
for (Thread waiter : waiters) {
LockSupport.unpark(waiter);
}
}
}
等待流程要注意三点:
- 先登记,再检查。如果首次检查后进度已经更新,再次检查可以避免线程错过通知后一直等待。
- 先发布进度,再唤醒。等待者观察到满足要求的进度时,可以看到工作线程在相应
volatile写入之前完成的写入。 - 始终循环检查条件。
park()可能虚假返回,也可能因中断返回。unpark()提供的许可最多保留一个,不能用它累计完成次数;完成状态必须保存在进度变量中。
如果 unpark() 发生在相应的 park() 之前,许可可以使后续调用直接返回,因此不要求双方精确配对。
这个实现仍会广播唤醒所有登记线程,部分线程可能发现目标尚未达到,然后继续等待。另外,队列遍历和 remove 都可能产生线性开销。这个示例只用于说明机制,并非通用的性能优化结论。
不同完成目标适合按阈值通知
如果等待者很多,而且目标进度差异较大,可以按目标值组织等待者。进度推进时,只唤醒目标已经满足的线程。
这种设计能减少无效唤醒,但必须协调好登记等待者、重新检查进度、移除等待者和发送通知之间的竞态,同时处理取消和中断。
也可以考虑基于 AbstractQueuedSynchronizer 的共享获取模式实现等待。不过,不能直接假定一个共享队列适合所有不同阈值:队首线程的目标尚未满足时,可能妨碍后面目标已经满足的线程及时获得传播唤醒,需要专门处理这种排队问题。
如何先短暂自旋,再进入阻塞?
可以在登记等待者之前,先用有限次数的循环读取 completed。Java 9 及以上可以在循环中调用 Thread.onSpinWait(),向运行时提供自旋提示。
Thread.onSpinWait() 不提供内存可见性,也不负责阻塞或唤醒。条件变量仍需要使用 volatile 或其他正确的同步机制;自旋结束后,也必须进入带有再次检查的登记与阻塞流程。
ReentrantLock 没有公开的“自旋指定次数后再阻塞”配置。JVM 或同步器内部可能采用优化策略,但不宜依赖某个版本的内部行为。Thread.yield() 也不能代替条件等待。
等待时间很短、CPU 有余量时,有限自旋可能减少调度开销。如果线程数量超过可用 CPU 数量,或者等待时间较长,自旋会占用工作线程需要的算力,反而推迟进度。
实际选择时要检查什么?
如果等待者数量不大,可以先保留 synchronized 配合 wait/notifyAll 的实现,通过测量确认锁竞争是否真是瓶颈。对于已经满足目标的请求,可以增加 volatile 快速检查路径。
只有在广播唤醒或重新获取锁确实成为主要成本时,再考虑 LockSupport 或按阈值通知。比较时应同时关注吞吐量、等待延迟和 CPU 使用率,不能仅比较 park() 的单次耗时。
还要注意,进度变量能够发布已经完成的写入,但如果工作线程随后继续修改同一份数据,读取者仍可能需要不可变快照、版本管理或额外同步,才能获得一致的数据视图。
备注:内容仅供参考。
