Java并发编程:synchronized 与 ReentrantLock + Condition 的深度对比——从Monitor队列到惊群效应与精确唤醒
- 前言
- 正文
- 一、每个Java对象天生都能当锁?Monitor的底层结构
- 形象比喻:锁对象就像一个会议室。
- 关键区别:
- 二、synchronized的线程状态流转全过程
- 完整过程演绎
- 1. 抢锁阶段:
- 2. 条件不满足,主动等待:
- 3. 被通知唤醒:
- 为什么被唤醒后还要再抢锁?
- 惊群效应的根源:
- 三、ReentrantLock + Condition的高明之处:按等待性质分类的多个队列
- 四、生产者-消费者完整代码对比
- 五、为什么JDK的高并发容器都用Condition?
- 总结:从synchronized到Condition的进化
前言
最近和很多读者深入交流了Java多线程中两个核心的等待/通知机制:synchronized + wait/notify 和 ReentrantLock + Condition。很多同学在学习时都会疑惑:为什么Condition更高级?为什么高并发容器都用Condition?为什么synchronized容易出现“惊群效应”?
今天我们把这些问题全部串起来,用最通俗易懂的语言,从底层实现讲起,一步步把Monitor的两个队列、线程状态流转、惊群效应、Condition的精确唤醒全部讲清楚。读完这篇,你会对Java并发等待/通知机制有彻底的理解!
正文
一、每个Java对象天生都能当锁?Monitor的底层结构
先来一个基础但超级重要的知识点:Java中每个对象都可以作为锁对象,因为它们都继承了Object类的wait()、notify()、notifyAll()方法。
当一个对象第一次被用作锁(比如进入synchronized(obj)块)时,JVM会给它分配一个叫Monitor(对象监视器)的结构。Monitor就像这个对象的“管家”,里面主要有三样东西:
- Owner:当前持有锁的线程(锁的“主人”)。只有Owner才能执行临界区代码。
- Entry List(入口等待队列):还没抢到锁的线程被park(阻塞)在这里,等着锁释放后一起竞争。
- Wait Set(等待队列):已经抢到锁,但业务条件不满足,主动调用wait()的线程,会被放到这里深度睡眠。
形象比喻:锁对象就像一个会议室。
- Owner:正在会议室里开会的人。
- Entry List:门口排队的想进来开会的人(抢不到门票)。
- Wait Set:已经进过会议室,但发现“现在没内容可讨论”,主动出去休息室睡觉的人。
关键区别:
- Entry List里的线程:是因为抢不到锁而阻塞。
- Wait Set里的线程:是抢到了锁,但条件不满足,主动释放锁去睡觉。
二、synchronized的线程状态流转全过程
我们用生产者-消费者场景来一步步看线程是怎么流动的。
完整过程演绎
1. 抢锁阶段:
线程A先进入synchronized(obj),成功抢到锁 → 成为Owner,进入临界区。
其他线程(B、C、D)也想进来,发现锁被占了 → 被park到Entry List,在门口排队。
2. 条件不满足,主动等待:
线程A在临界区发现“缓冲区满了,没地方放东西”。
调用obj.wait():
立即释放锁(Owner变为null,会议室开门)。
自己走进Wait Set(休息室)睡觉,完全不参与抢锁。
锁释放后,Entry List的线程(B、C、D)被唤醒,开始竞争锁。
3. 被通知唤醒:
假设线程B抢到锁,消费后调用obj.notify()或notifyAll()。
notify():从Wait Set随机选一个线程唤醒。
notifyAll():把Wait Set里所有线程都唤醒。
超级重要!被唤醒的线程不会立刻拿到锁!
它们会从Wait Set转移到Entry List。
然后和Entry List里其他线程一起重新竞争锁。
只有抢到锁后,才能继续执行wait()后面的代码。
为什么被唤醒后还要再抢锁?
- wait()必须在持有锁的情况下调用,从wait()恢复也必须重新持有锁,确保条件检查和修改是原子的。
这也是为什么要用while检查条件(防止虚假唤醒),而不是if。
惊群效应的根源:
- synchronized只有一个Wait Set,所有等待原因的线程(生产者因为“满”、消费者因为“空”)都挤在这里。
- 一次notifyAll() → 把所有人转移到Entry List → 所有人抢锁 → 大部分醒来发现条件不满足,又wait()回去 → 大范围惊群,大量无谓的上下文切换,性能很差。
三、ReentrantLock + Condition的高明之处:按等待性质分类的多个队列
Java为了解决synchronized的痛点,引入了java.util.concurrent.locks包。ReentrantLock基于AQS(AbstractQueuedSynchronizer),底层更灵活。
AQS有一个同步队列(类似Entry List,用来排队等锁)。
lock.newCondition()可以创建多个Condition对象,每个Condition都有自己独立的等待队列(类似Wait Set,但可以有多个)。
JavaLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 生产者专用:缓冲区满时等待
Condition notEmpty = lock.newCondition(); // 消费者专用:缓冲区空时等待
关键点:不是每个线程一个队列,而是按“等待性质”分类
一个Condition队列里可以有多个线程,但这些线程的等待原因是相同的(同一种性质)。
比如:notFull队列里可能有50个生产者在等(都因为缓冲区满)。
notEmpty队列里可能有30个消费者在等(都因为缓冲区空)。
await/signal的工作原理:
await():释放锁 → 进入这个Condition的专属队列睡觉。
signal():从这个队列头部取一个线程 → 转移到AQS同步队列 → 去抢锁(和synchronized唤醒后去Entry List一样)。
signal()默认只唤醒一个(先进先出),signalAll()唤醒整个队列(但只限同类线程)。
惊群范围大幅缩小:
- 消费者消费后,只调用notEmpty.signal() → 只唤醒notEmpty队列里的消费者。
生产者的50个线程完全不受影响,继续睡觉。 - 即使signalAll(),也只影响同类线程,远比synchronized的“唤醒所有人”好太多。
加上signal()默认只醒一个,在大多数场景几乎杜绝了惊群。
“遥控器”比喻:Condition对象就像一个遥控器,你用哪个遥控器,就操作哪个队列,完全隔离。
四、生产者-消费者完整代码对比
synchronized版本(容易大范围惊群)
JavapublicclassBuffer{privatefinal Object lock=newObject();privatefinal Queue<Integer>queue=newLinkedList<>();privatefinal int capacity=10;publicvoidproduce(int item)throws InterruptedException{synchronized(lock){while(queue.size()==capacity){lock.wait();// 所有生产者进入同一个Wait Set}queue.offer(item);lock.notifyAll();// 唤醒所有人:生产者+消费者 → 大惊群}}publicintconsume()throws InterruptedException{synchronized(lock){while(queue.isEmpty()){lock.wait();// 所有消费者也进同一个Wait Set}int item=queue.poll();lock.notifyAll();// 同上returnitem;}}}ReentrantLock + Condition版本(按性质分类,精确唤醒)
JavapublicclassBuffer{privatefinal Lock lock=newReentrantLock();privatefinal Condition notFull=lock.newCondition();// 生产者队列privatefinal Condition notEmpty=lock.newCondition();// 消费者队列privatefinal Queue<Integer>queue=newLinkedList<>();privatefinal int capacity=10;publicvoidproduce(int item)throws InterruptedException{lock.lock();try{while(queue.size()==capacity){notFull.await();// 只进生产者专属队列}queue.offer(item);notEmpty.signal();// 只唤醒一个消费者(推荐)}finally{lock.unlock();}}publicintconsume()throws InterruptedException{lock.lock();try{while(queue.isEmpty()){notEmpty.await();// 只进消费者专属队列}int item=queue.poll();notFull.signal();// 只唤醒一个生产者}finally{lock.unlock();}}}新版本代码更清晰、性能更高。
五、为什么JDK的高并发容器都用Condition?
打开JDK源码:
ArrayBlockingQueue、LinkedBlockingQueue、ConcurrentHashMap(部分)等底层几乎全是用ReentrantLock + Condition。
原因:
多个独立队列 + 精确唤醒
支持超时等待(awaitNanos)、可中断、公平锁等高级特性
在高并发下性能远超synchronized
总结:从synchronized到Condition的进化
- synchronized + wait/notify:简单易用,但只有一个Wait Set,所有线程混在一起。wait()释放锁进Wait Set,notify后转移到Entry List再抢锁,notifyAll()容易造成大范围惊群。
- ReentrantLock + Condition:按等待性质分类多个队列,一个队列里多个同类线程。await/signal操作独立,signal()默认只醒一个,惊群范围大幅缩小,几乎杜绝。
推荐:简单场景用synchronized,复杂等待条件(如生产者-消费者、读写锁)强烈建议用Lock + Condition。
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/c5721d2340f843d7bddc55fcf6369142.jpeg#pic_center