此前,专门有一篇文章Day18 | 深入理解Object类,讲的是Object类中一些基础的方法。
当时挖了一个坑,今天我们来把他填上。
本文我们细致的讲一下,synchronized搭配wait()和notify()/notifyAll()方法以及ReentrantLock搭配Condition这两种线程间的协作与通信方式。
在并发编程中,线程基本上不会各干各的,而是需要协同配合。
为了协同完成某个任务,需要在共享数据的基础上进行信息交换和协调执行。
这里说的通信不是指的网络通信,而是在共享内存模型里,线程通过共享变量来传递信息,再加上wait/notify等机制实现协调。
一、wait/notify
wait(), notify(), notifyAll()都是java.lang.Object类里的方法。
这些方法都必须在synchronized同步块或同步方法里调用,因为调用这些方法之前,线程必须持有这个对象的监视器锁。
wait()方法调用之后,当前线程会释放持有的锁,并进入这个对象的等待集里,状态变成WAITING或 TIMED_WAITING。
notify()方法是唤醒这个对象等待集里的一个线程,具体是哪一个是由JVM决定的,不保证公平。
notifyAll()是唤醒这个对象等待集里所有的线程。
下面我们通过一个案例来看看wait/notify这一套机制是怎么实现线程通信的。
为了方便理解,我们假设有这样一个场景:
有一个生产线程负责做包子,还有一个消费者线程负责吃包子,生产的包子会放在蒸笼里,消费线程也需要从蒸笼里拿包子,他们公用一个蒸笼,而且这个蒸笼最多只能放5个包子。
就这样一个简单的场景,想想看有哪些点是需要协作的。
如果生产者发现蒸笼满了,他是不是应该停下来不做了,不然包子放哪儿。他得等消费者吃掉一些包子再做。
如果消费者发现蒸笼是空的,他是不是不能再继续吃了,不然就是在吃空气了。他应该停下来,等生产者做出新的包子再吃。
生产者做了一个新包子放到蒸笼里之后,他是不是应该通知正在等包子的消费者,嘿,有新包子了,可以来吃了。
如果消费者吃掉了一个包子,蒸笼就空了一个位置,他是不是应该通知正在等的生产者,嘿,有空位了,可以继续做了。
我们通过代码来看一下,具体的协作机制。
package com.lazy.snail.day39;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
/**
* @ClassName Day39Demo
* @Description TODO
* @Author lazysnail
* @Date 2025/8/1 9:41
* @Version 1.0
*/
public class Day39Demo {
public static void main(String[] args) {
// 只能装5个包子的蒸笼
SharedQueue sharedQueue = new SharedQueue(5);
// 做包子的线程
Thread producerThread = new Thread(() -> {
while (true) {
try {
sharedQueue.produce();
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "生产者");
// 吃包子的线程
Thread consumerThread = new Thread(() -> {
while (true) {
try {
sharedQueue.consume();
Thread.sleep(new Random().nextInt(1500));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "消费者");
producerThread.start();
consumerThread.start();
}
}
class SharedQueue {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
private int itemNumber = 0;
public SharedQueue(int capacity) {
this.capacity = capacity;
}
// 做包子
public void produce() throws InterruptedException {
synchronized (this) {
while (queue.size() == capacity) {
System.out.println("蒸笼满了,【" + Thread.currentThread().getName() + "】暂时不做了...");
// 调用wait(),释放锁并进入等待
this.wait();
}
int producedItem = ++itemNumber;
queue.add(producedItem);
System.out.println("【" + Thread.currentThread().getName() + "】做了个包子: " + producedItem + ",蒸笼里还有" + queue.size() + "个包子");
// 通知其他可能在等待的线程
this.notifyAll();
}
}
// 吃包子
public void consume() throws InterruptedException {
synchronized (this) {
while (queue.isEmpty()) {
System.out.println("蒸笼空了,【" + Thread.currentThread().getName() + "】暂时不吃了...");
// 调用wait(),释放锁并进入等待
this.wait();
}
int consumedItem = queue.poll();
System.out.println("【" + Thread.currentThread().getName() + "】吃了一个包子: " + consumedItem + ",蒸笼里还有" + queue.size() + "个包子");
// 通知其他可能在等待的线程
this.notifyAll();
}
}
}
我在关键的地方都写上了注释。
梳理一下整个过程,这里假设吃包子线程先启动并抢到锁,他发现蒸笼是空的,就调用了wait方法,这个时候吃包子线程就释放了锁,进入了等待状态。
做包子线程后启动,因为吃包子线程已经释放了锁,他进入produce后能干顺利的拿到锁,之后发现蒸笼是空,就开始做包子,做完放到了蒸笼里。之后调用了notifyAll方法,去唤醒所有在this对象上等待的线程(这里只有我们的吃包子线程)。
此时吃包子线程被唤醒了,从WAITING状态变成了RUNNABLE状态,但是这个时候他不会马上运行,因为他还需要重新去竞争锁。这个点,做包子线程还在synchronized块里,锁还没释放。
等做包子线程退出了synchronized块,锁也释放了,吃包子线程就可以和其他线程(我们这里没有其他线程了)竞争锁了。如果他成功的拿到了锁,就会在当初调wait方法的地方醒过来。
吃包子线程醒过来之后,还在while循环里,所以又会看看蒸笼是不是空,不是空的(之前做包子线程已经做了包子在里面),就开始取出包子吃了。吃完这个包子,吃包子线程又通过notifyAll方法,去唤醒所有在this对象上等待的线程(这里只有我们的做包子线程)。
然后synchronized块执行完,把锁释放了,又轮到了被唤醒的做包子线程,周而复始。
wait/notify机制通过“释放锁-等待”和“通知-重竞争锁”这两个操作,实现了线程之间在特定条件下的协作。
但是从最开始的定义就能看出,wait/notify机制还是有一些局限性。
比如notify不能控制唤醒的到底是哪一个线程,具体是由JVM决定的。
notifyAll会唤醒所有等待的线程,但是通常情况下只有一个线程能满足条件继续后续的逻辑处理,其他被唤醒的线程在重新获取锁后,发现条件还是不满足,只能再次进入等待状态。这样肯定会有一些不必要的上下文切换和资源竞争,影响效率。
说完wait/notify机制,我们来看一下另一种方式。
二、Condition接口
从上面我们已经知道,跟一个锁(对象监视器)关联的只有一个等待集。所有等待的线程都在里面,notifyAll一喊,所有线程都得出来看看,没法精准的控制,效率也低。
Condition接口就是为了解决这个问题,他允许一个Lock关联多个独立的等待集。
Condition对象跟Lock绑定在一起,不能单独的创建一个Condition,必须通过lock.newCondition()来获取。
创建的Condition实例,你可以理解成是专属于这个Lock的一个独立的、可命名的等待集。
还是用做包子、吃包子的案例来讲解。
wait/notify机制就好比,所有吃包子、做包子的人都在一个唯一的大厅里,包子做好了,所有在等的人,不管是要吃包子的还是等空位的都能听见他吼了一声,包子好了,人一多,就会感觉非常混乱。
Condition接口就好比给同一个蒸笼,配备了多个独立的VIP等候室。然后Condition对象和Lock(我们的蒸笼访问权)绑定在一起。每一个实例,你都可以想象成一个有特定用途的等候室。
比如:
Condition notFull = lock.newCondition();
这是给做包子的线程准备的,专门给蒸笼满了的时候等待空位的做包子线程使用。
Condition notEmpty = lock.newCondition();
这是给吃包子的线程准备的,专门给蒸笼没有包子的时候等待包子的吃包子线程使用。
那Condition到底是怎么工作的呢?
await()方法
如果一个吃包子线程拿到了锁,发现蒸笼是空的,他不能傻站着。他就会调用notEmpty.await(),这会发生:
吃包子线程被放到"吃包子等候室"(notEmpty的条件队列)里。
他会自动释放Lock,这样其他线程(比如做包子线程)才能拿到锁。
吃包子线程在等候室里开始休息(线程进入WAITING状态)。
同样,当一个做包子线程拿到锁后发现蒸笼是满的,他会调用notFull.await(),进入"做包子等候室"休息,并同样释放锁。
signal()方法
当一个做包子线程拿到了锁,做好一个包子放到蒸笼后,他需要通知可能在等待的消费者。
于是,他调用notEmpty.signal(),这就像有人来到"吃包子等候室"门口,打开门,说了句,有包子了。
系统会选择其中一个正在休息的等着吃包子的人。这个被叫醒的人不会马上冲向蒸笼。他会跟其他想吃包子的人一起排队拿锁,拿到锁之后才会从之前await()的地方继续执行。
下面来看一下包子铺的代码:
package com.lazy.snail.day39;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName Day39Demo2
* @Description TODO
* @Author lazysnail
* @Date 2025/8/1 11:16
* @Version 1.0
*/
public class Day39Demo2 {
public static void main(String[] args) {
// 基于Condition的新蒸笼
ConditionSharedQueue sharedQueue = new ConditionSharedQueue(5);
// 做包子的线程
Thread producerThread = new Thread(() -> {
while (true) {
try {
sharedQueue.produce();
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "生产者");
// 吃包子的线程
Thread consumerThread = new Thread(() -> {
while (true) {
try {
sharedQueue.consume();
Thread.sleep(new Random().nextInt(1500));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "消费者");
producerThread.start();
consumerThread.start();
}
}
class ConditionSharedQueue {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
private int itemNumber = 0;
private final Lock lock = new ReentrantLock();
// 做包子线程使用的条件:当蒸笼满了,做包子线程在这里等
private final Condition notFull = lock.newCondition();
// 吃包子使用的条件:当蒸笼空了,吃包子线程在这里等
private final Condition notEmpty = lock.newCondition();
public ConditionSharedQueue(int capacity) {
this.capacity = capacity;
}
// 做包子
public void produce() throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
System.out.println("蒸笼满了,【" + Thread.currentThread().getName() + "】进入做包子等候室...");
notFull.await();
}
int producedItem = ++itemNumber;
queue.add(producedItem);
System.out.println("【" + Thread.currentThread().getName() + "】做了个包子: " + producedItem + ",蒸笼里还有" + queue.size() + "个包子");
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 吃包子
public void consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
System.out.println("蒸笼空了,【" + Thread.currentThread().getName() + "】进入吃包子等候室...");
notEmpty.await();
}
int consumedItem = queue.poll();
System.out.println("【" + Thread.currentThread().getName() + "】吃了一个包子: " + consumedItem + ",蒸笼里还有" + queue.size() + "个包子");
notFull.signal();
} finally {
lock.unlock();
}
}
}
代码里没有使用对象内置的监视器锁了(依赖synchronized),而是显式的创建了一 ReentrantLock实例。
为了案例需要,在代码中创建了两个独立的Condition对象:
notFull表示蒸笼满了,做包子线程的等待室。
notEmpty表示蒸笼空了,吃包子线程的等待室。
在produce方法里,如果队列满了,原先的this.wait()被替换notFull.await()。表示做包子线程进入notFull这个专门的等候室。
同理,consume方法中也是一样。
在produce方法最后,用condition.signal()替代notifyAll(),只唤醒对应等待室里的线程。
consume方法中的condition.signal()也是同理。
这种方式有点像从广播模式变成了对讲机模式。
当让除了我们在示例代码中使用的await/signal方法。
Condition还提供了await(long time, TimeUnit unit):如果在指定时间内没有被signal,线程会自动醒来并返回false,避免无限期等待。
await()方法本身就是响应中断的。如果在等待时线程被interrupt(),它会抛出InterruptedException并醒来。
如果不想在等待的时候被中断,可以使用awaitUninterruptibly()。
还有基于截止日期的等待,awaitUntil(Date deadline),可以在一个绝对的时间点前等待。
其实关于Condition的使用及与监视器锁的对比描述在Condition类的源码中已经描述得比较清晰。
如果能把源码中的注释梳理清楚,也就掌握了二者的差异和Condition的使用。
这里还是建议大家自己去看一下,至于注释是英文的,有一万种方法可以解决。
结语
本文通过基于监视器锁的wait/notify与基于Condition搭配ReentrantLock的对比使用,简单的体验了多线程之间的协作过程。
其实ReentrantLock就是负责提供锁机制的,控制谁能进门,谁不能。
而Condition就负责提供精细的、分组的等待/通知机制,控制进门后去哪个房间已经哪个房间的会被叫醒。
两者结合就形成了一套可以处理复杂、高性能的并发系统开发工具。
下一篇预告
Day40 | Java中的ReadWriteLock读写锁
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多文章请关注我的公众号《懒惰蜗牛工坊》