并发3️⃣消息传递:保护性暂停、生产者消费者
1、同步模式:保护性暂停
Guarded Suspension
- 场景:一个线程等待另一个线程的执行结果。
- 实现:线程关联同一个 GuardedObject 对象,用于在线程之间传递结果。
- 单任务版:无限期等待、超时等待。
- 多任务版:基于单任务版 + 集合管理。
- JDK 的 join()、Future,基于保护性暂停模式实现。
1.1、无限期等待
示例:线程 t1等待线程 t2 的执行结果。
-
result:线程执行结果。
-
getResult():获取执行结果。
synchronzied
:调用wait()
的前提是获得对象锁。while
:避免虚假唤醒。- 真唤醒:线程 t2 执行结束后唤醒 t1,t1 获得执行结果。
- 虚假唤醒:其它线程调用 notify()/notifyAll(),t1 仍未获得结果(即被唤醒时条件不满足)
- 唤醒后进入下一轮循环,null 则继续等待,非 null 则返回结果。
-
setResult():设置执行结果,唤醒等待线程(t1)
public class GuardedObject { private Object result; public Object getResult() throws InterruptedException { synchronized (this) { while (result == null) { // this.wait(); } return result; } } public void setResult(Object result) { synchronized (this) { // 处理完成 this.result = result; this.notifyAll(); } } }
测试
-
t1:调用 getResult() 尝试获取结果,在结果产生前处于等待(暂停)状态。
-
t2:业务处理需耗时 2 秒,处理结束后设置执行结果,设置结果后会唤醒等待线程。
GuardedObject go = new GuardedObject(); new Thread(() -> { try { LogUtils.debug("尝试获取结果"); LogUtils.debug(go.getResult()); } catch (InterruptedException e) { e.printStackTrace(); } }, "t1").start(); new Thread(() -> { // 模拟处理结果 LogUtils.debug("处理结果中"); SleepUtils.sleepSeconds(2); // 处理结束 LogUtils.debug("处理结束"); go.setResult("ok"); }, "t2").start();
-
结果:线程 t1 和 t2 同时开启,2 秒后 t2 执行结束并设置结果,t1 获取并打印结果。
1.2、超时等待
示例:在原有基础上,添加超时等待时间(mills)
Object getResult(long mills)
1.2.1、方案分析
方案:方法在原有基础上,将 wait() 改为 wait(mills)
- 分析:假设 t1 的超时等待时间为 5 秒(mills = 5)
- 假设 t1 在第 4 秒的时候被虚假唤醒,进入下一轮循环,t1 本来最多再等待 1 秒,而实际再会等待 5 秒。
- 即使 t1 没有被虚假唤醒,等待 5 秒后仍会进入下一轮循环再次等待,没有达到预期效果。
- 结论
- 循环体中的
wait(long)
的参数不能是 mills,应是 “剩余等待时间”。 - 剩余等待时间 = 超时等待时间 - 已等待时间(remain = mills - elapsed)
- 超时等待时间:方法参数传入
- 已等待时间:当前时间 - 初始时间(elapsed = now - start)
- 循环体中的
1.2.2、实现
getResult(long mills) 实现
相比 getResult() 的变化如下
-
初始时间:startTime
-
经历时间(已等待时间):初始为 0
-
while
-
剩余等待时间:每轮循环开始时计算,remain = mills - elapsed。
-
if:若剩余等待时间非正数,则跳出循环不再等待。
-
wait(remainTime):等待剩余时间。
-
更新已等待时间,now - start。
public Object getResult(long mills) throws InterruptedException { synchronized (this) { long startTime = System.currentTimeMillis(); long elapsedTime = 0; while (result == null) { long remainTime = mills - elapsedTime; if (remainTime <= 0) { break; } // 唤醒后更新经历时间,进入下一轮循环,null则继续等待,非null则返回结果 wait(remainTime); elapsedTime = System.currentTimeMillis() - startTime; } return result; } }
-
测试
-
t1:调用 getResult(long) 尝试获取结果,在结果产生前处于等待(暂停)状态,若超过指定时间则不再等待。
-
t2:业务处理需耗时 2 秒,处理结束后设置执行结果,设置结果后会唤醒等待线程。
GuardedObject1 go = new GuardedObject1(); long mills; new Thread(() -> { try { LogUtils.debug("尝试获取结果"); LogUtils.debug(go.getResult(mills)); } catch (InterruptedException e) { e.printStackTrace(); } }, "t1").start(); new Thread(() -> { // 模拟处理结果 LogUtils.debug("处理结果中"); SleepUtils.sleepSeconds(2); // 处理结束 LogUtils.debug("处理结束"); go.setResult("ok"); }, "t2").start();
-
结果:线程 t1 和 t2 同时开启,2 秒后 t2 执行结束并设置结果。
-
mills 为 3000:最多等待 3 秒,在第 2 秒时获得 t2 的执行结果。
-
mills 为 1000:最多等待 1 秒,1 秒时未得到结果则不再等待。
-
1.3、源码:join()
join() 的实现,体现了保护性暂停模式
区别:保护性暂停是等待另一个线程的处理结果,join() 是等待另一个线程结束(死亡)。
-
base:即初始时间(startTime)
-
now:即经历时间(已等待时间,elapsedTime)
-
参数判断:小于 0 则不合法
- 等于 0:调用 wait(0) 无限期等待。
- 大于 0:进入 while 循环
-
while:循环条件为当前线程存活(alive)
-
delay:即剩余等待时间(remainTime)
-
if:若剩余等待时间非正数,则跳出循环不再等待。
-
wait(delay):等待剩余时间。
-
更新已等待时间,now - base。
public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay <= 0) { break; } wait(delay); now = System.currentTimeMillis() - base; } } } public final void join() throws InterruptedException { join(0); }
-
1.4、多任务版等待
- 以上案例中,体现了一个线程等待另一个线程的执行结果。
- 多任务:n 个线程等待获得结果,有对应的 n 个线程处理并设置结果。
1.4.1、分析
- 涉及 n 对线程,需要有 n 个 GuardObject 负责传递结果。
- 如何使 n 个等待方线程与 n 个处理方线程一一对应?
- 为 GuardObject 设置唯一编号(id)。
- 等待方和处理方通过 id 关联同一个 GuardObject。
- 创建一个 Futures 类,用于管理多个 GuardObject 对象。
- 存储 GuardObject:使用 Map 集合,以便根据 id 区分、保证 id 唯一。
- 创建 GuardObject、生成 id
- 为避免 id 重复,不能由等待/处理线程创建。
- 为解耦,可通过工厂模式创建。
- 提供获取 guardObject、获取所有 id 的方法。
1.4.2、实现:Futures
注:在 GuardedObject 中添加 id 属性,以及 getter、构造方法。
-
存储 GuardObject:使用 Hashtable 保证线程安全。
-
生成 id:私有静态变量,每次递增 1,线程安全。
-
创建 GuardObject:存入集合后返回。
-
获取 GuardObject:从集合中取出并删除(线程只获取一次结果,GuardObject 无需考虑复用)。
-
获取所有 id:返回 Map 的 keySet。
public class Futures { private static final Map<Integer, GuardedObject> MAP = new Hashtable<>(); private static int id = 0; private static synchronized int generateId() { return id++; } public static GuardedObject generateGuardObject() { GuardedObject guardedObject = new GuardedObject(generateId()); MAP.put(guardedObject.getId(), guardedObject); return guardedObject; } public static GuardedObject getGuardObject(int id) { return MAP.remove(id); } public static Set<Integer> getIds() { return MAP.keySet(); } }
1.4.3、case:快递
线程模拟:定义 Resident 和 Courier 类,继承 Thread 类。
-
Resident:等待结果的线程(居民,等待快递)
-
Courier:执行并设置结果的线程(快递员,派送快递)
class Resident extends Thread { @Override public void run() { GuardedObject go = Futures.generateGuardObject(); try { // 尝试得到结果 LogUtils.debug("尝试取快递" + go.getId()); Object result = go.getResult(); LogUtils.debug("\t\t取出快递" + result); } catch (InterruptedException e) { e.printStackTrace(); } } } class Courier extends Thread { private final int id; public Courier(int id) { this.id = id; } @Override public void run() { GuardedObject go = Futures.getGuardObject(id); LogUtils.debug("派件中" + go.getId()); SleepUtils.sleepSeconds(2); LogUtils.debug("\t派件成功" + go.getId()); go.setResult(" 编号" + id); } }
测试:模拟 3 个居民,则对应有 3 个快递员
注:第一个 for 执行结束后再执行第二个 for,防止NPE。
-
Resident:创建 3 个线程,每个线程开启时都会创建一个 GuardedObject 并尝试获得结果。
-
Courier:获取 Futures 中集合的 KeySet,创建对应个数的线程,耗时 2 秒处理并设置结果。
int residentCount = 3; for (int i = 0; i < residentCount; i++) { new Resident().start(); } SleepUtils.sleepSeconds(1); for (Integer id : Futures.getIds()) { new Courier(id).start(); }
-
结果
- 同时开启 3 个 Resident 线程进行等待,1 秒后对应 3 个 Courier 线程开始处理业务
- 2 秒后处理结束并设置结果,与此同时 Resident 获取结果并输出。
1.5、小结
保护性暂停:一个线程等待另一个线程的执行结果。
- 实现:线程关联同一个 GuardedObject 对象。
- 单任务版
- 无限期等待:wait()
- 超时等待:wait(remain)
(remain = mills - elapsed,elapsed = now - start)
- 多任务版:基于单任务版 + 集合管理。
- 单任务版
- join() 原理:与保护性暂停的区别,在于 while 的判断条件是线程存活(alive)
- 保护性暂停是线程的一对一关系。
- 单任务版:单个一对一。
- 多线程版:多个一对一。
- 若要实现多对应关系,见生产者/消费者模式。
2、异步模式:生产者/消费者
- 相比保护性暂停模式,生产者/消费者模式支持多对应关系。
- 生产者/消费者中的消息队列用于线程间通信,消息框架(如 Rabbit MQ)用于进程间通信。
- JDK 的阻塞队列,基于生产者/消费者模式实现。
2.1、模式结构
2.1.1、结构
-
生产者:产生数据并存储于缓冲区中,无需关注数据的处理细节。
-
消费者:从缓冲区中获取数据并处理。
-
缓冲区:通常以队列的数据结构实现(FIFO)
2.1.2、缓冲区
缓冲区作用
- 解耦:生产者和消费者不直接互相依赖,而是依赖于缓冲区。
- 异步处理:
- 保护性暂停:线程同步(阻塞)等待另一个线程处理结果并立即消费。
- 生产者消费者:生产者无需同步等待结果的处理,将数据存储于缓冲区后即可执行其它任务。
- 并发:基于异步处理的特性,支持多线程并发。
- 缓冲:允许数据的产生速度和处理速度不一致。
- 未处理的数据存储于缓冲区。
- 达到缓冲容量上限后停止生成数据。
2.2、实现
2.2.1、Message 类
表示线程产生和处理的数据。
class Message {
private int id;
private Object message;
// 构造方法
// toString()、getter
}
2.2.2、MessageQueue 类
-
LinkedList:消息队列(即缓冲区),基于 Java 双向链表实现。
-
capacity:队列容量。
-
get():从缓冲区中取出数据。
synchronzied
:调用wait()
的前提是获得对象锁。while
:队列空时暂停消费,直到被 Producer 唤醒。- removeFirst():头删除,队头元素出队表示消费。
- notifyAll():唤醒由于队满而暂停生产的 Producer。
-
put():将数据存入缓冲区。
-
synchronzied
:同上 -
while
:队列满时暂停生产,直到被 Consumer 唤醒。 -
addLast():尾插入,向队尾加入数据。
-
notifyAll():唤醒由于队空而暂停消费的 Consumer。
public class MessageQueue { private final LinkedList<Message> queue; private final int capacity; public MessageQueue(int capacity) { queue = new LinkedList<>(); this.capacity = capacity; } public Message get() throws InterruptedException { synchronized (queue) { while (queue.isEmpty()) { LogUtils.debug("队列为空,暂停消费"); queue.wait(); } Message message = queue.removeFirst(); queue.notifyAll(); return message; } } public void put(Message message) throws InterruptedException { synchronized (queue) { while (queue.size() == capacity) { LogUtils.debug("队列已满,暂停生产"); queue.wait(); } LogUtils.debug("存储信息" + message.getId()); queue.addLast(message); queue.notifyAll(); } } }
-
2.2.4、测试
示例:缓冲区容量 = 3,生产者个数 = 5,消费者个数 = 2
-
producer:生产数据,存入缓冲区。
-
comsumer:每秒从缓冲区中取出一个数据进行处理。
int queueCapacity = 3; int producerCount = 5; int consumerCount = 2; MessageQueue mq = new MessageQueue(queueCapacity); for (int i = 0; i < producerCount; i++) { Message message = new Message(i, "message" + i); new Thread(() -> { try { mq.put(message); } catch (InterruptedException e) { e.printStackTrace(); } }, "p" + i).start(); } for (int i = 0; i < consumerCount; i++) { new Thread(() -> { while (true) { try { // 每秒处理一次 SleepUtils.sleepSeconds(1); LogUtils.debug("处理结果:" + mq.get()); } catch (InterruptedException e) { e.printStackTrace(); } } }, "c" + i).start(); }
测试
注意各时间节点
-
线程同时启动
- 生产者线程:由于容量 3,有 3 个线程成功存储信息,2 个线程 wait() 等待。
- 消费者线程:sleep() 睡眠 1 秒。
-
第一次 sleep 结束(二者几乎同时发生,因此未必按顺序显示在控制台)
- 消费者线程:各从队列中取出一个数据进行处理(FIFO)
- 生产者线程:队列中空出两个位置,剩余 2 个生产者线程存入信息。
-
第二次 sleep 结束:各从队列中取出一个数据进行处理(FIFO),队列中剩 1 个元素。
-
第二次 sleep 结束
- c1:比 c2 抢到时间片,从队列中取出最后一个数据进行处理,队列一空。
- c2:暂停消费。
-
第三次 sleep 结束:c1 暂停消费。