notifyall()易引发惊群,因唤醒所有等待线程但资源仅够一个消费,导致无效上下文切换与锁竞争;condition.signal()可精准唤醒单个线程,配合reentrantlock实现无惊群协作。

Java里notifyAll()为什么容易引发惊群
当多个线程在同一个Object.wait()上阻塞,而生产者调用notifyAll()时,JVM会唤醒所有等待线程——但资源(比如队列里的一个任务)通常只够一个线程消费。其余线程抢锁失败,或抢到锁后发现队列已空,只能立刻重新wait()。这过程不产生业务价值,却强制触发大量上下文切换和锁竞争。
- 典型场景:老式
BlockingQueue自实现、手写生产者-消费者模型、基于synchronized+wait()/notifyAll()的线程协作 - 现象:高并发下
vmstat显示cs(context switch)飙升,top里Java进程CPU高但吞吐没涨 - 根本原因:JVM规范未要求“精准唤醒”,
notifyAll()语义就是广播,无法绕过
Condition.signal()怎么精准避开惊群
ReentrantLock配合Condition是标准解法:signal()只唤醒等待队列头部的一个线程,避免无意义唤醒。关键在于每个Condition实例维护独立等待队列,且唤醒逻辑由AQS控制,天然支持单点唤醒。
- 必须用
lock.newCondition()创建,不能混用不同Condition对象 - 别在
synchronized块里调用Condition方法——会抛IllegalMonitorStateException - 示例:消费者取任务前先
condition.await(),生产者放完任务后condition.signal(),不是signalAll() - 注意:如果用
signalAll(),照样惊群——它只是把notifyAll()搬到AQS里,没改变语义
为什么LinkedBlockingQueue比自己手写更抗惊群
JDK自带的LinkedBlockingQueue内部用两把独立锁:takeLock和putLock,分别控制消费端和生产端。消费者只在takeLock的notEmpty条件上等待,生产者只唤醒这个条件;反之亦然。这就从结构上隔离了读写惊群。
- 对比:手写单锁队列中,
wait()和notifyAll()共用一把锁,读写互相干扰 - 风险点:若业务需要定制阻塞逻辑(如超时重试、优先级),直接继承
LinkedBlockingQueue可能破坏锁分离设计 - 替代选择:
ArrayBlockingQueue也是双条件+单锁,但用数组实现,吞吐略高、扩容成本为零
警惕CountDownLatch和CyclicBarrier的隐式惊群
它们本身不涉及锁竞争,但若多个线程在await()后立即争抢同一资源(比如共享数据库连接池),就会形成“唤醒后惊群”。这不是原语缺陷,而是使用模式导致的二次竞争。
立即学习“Java免费学习笔记(深入)”;
- 常见错误:用
CountDownLatch做启动协调,100个线程同时await()结束,然后一起调httpClient.execute()——连接池瞬间被打爆 - 缓解方式:在
await()后加小范围限流(如Semaphore)、或改用分批唤醒(多个CountDownLatch接力) - 更隐蔽的坑:
CyclicBarrier的barrierAction回调里若修改共享状态,后续所有线程会同步读该状态,容易引发缓存行伪共享
notify()就安全,却忽略了JVM规范不保证它一定唤醒“最久等待的那个”;或者用Condition但忘了signal()前要持有对应锁。这些细节不报错,但会让性能在高负载下突然掉档。











