49、阻塞等待
在讲解条件变量和信号量时,我们留给大家思考如何使用条件变量或信号量来实现阻塞并发队列
本节我们就结合 JUC 源码来看下工业级的阻塞并发队列,到底是怎么实现的,都有哪些值得我们学习的地方
1、阻塞并发队列
阻塞并发队列具有两个特点
- 线程安全:也就是名称中 "并发" 的含义
- 支持读写阻塞:也就是名称中 "阻塞" 的含义
- 读阻塞指的是:队列为空时,读取操作会被阻塞,直到队列有可读的数据为止
- 写阻塞指的是:队列已满时,写入操作会被阻塞,直到队列有可写的空位为止
- 阻塞并发队列一般用于实现:生产者 - 消费者模型
在《数据结构与算法之美》中我们讲到,队列可以分为无界队列和有界队列,无界队列指的是队列的大小没有限制,有界队列指的是队列的大小有限制
- 对于有界队列:读、写均可以阻塞
- 对于无界队列:读可阻塞,但写不会阻塞
JUC 提供的阻塞并发队列有很多
比如:ArrayBlockingQueue、LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue
接下来我们讲解一下:这些阻塞并发容器的用法和实现原理
方法 / 处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
移除方法 | remove() | poll() | take() | poll(time, unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
2、BlockingQueue
ArrayBlockingQueue、LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue 的实现原理类似
它们都是基于 ReentrantLock 锁来实现线程安全,基于 Condition 条件变量来实现阻塞等待
因此我们把这 4 个阻塞并发队列放在一起来讲解,并且拿其中的 ArrayBlockingQueue 重点讲解
对于剩下的 3 个阻塞并发容器,我们只讲解跟 ArrayBlockingQueue 有差异的地方
2.1、ArrayBlockingQueue 源码
ArrayBlockingQueue 是基于 "数组" 实现的 "有界阻塞并发队列"(循环队列),队列的大小在创建时指定
ArrayBlockingQueue 跟普通队列的使用方式基本一样,唯一的区别在于读写可阻塞,这里就不再举例了
接下来我们结合源码具体讲解它的实现原理,重点看下它是如何实现线程安全且可阻塞的,ArrayBlockingQueue 的部分源码如下所示
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> { final Object[] items; int takeIndex; // 下一次出队时, 出队数据的下标位置 int putIndex; // 下一次入队时, 数据存储的下标位置 int count; // 队列中的元素个数 final ReentrantLock lock; // 加锁实现线程安全 private final Condition notEmpty; // 用来阻塞读, 等待非空条件的发生 private final Condition notFull; // 用来阻塞写, 等待非满条件的发生 public ArrayBlockingQueue(int capacity) { this(capacity, false); } public ArrayBlockingQueue(int capacity, boolean fair) { if (capacity <= 0) throw new IllegalArgumentException(); this.items = new Object[capacity]; lock = new ReentrantLock(fair); notEmpty = lock.newCondition(); notFull = lock.newCondition(); } // ... 省略核心函数 ... }
从上述源码我们还可以发现,ArrayBlockingQueue 支持公平和非公平两种工作模式,默认为非公平模式,它的公平性依赖锁的公平性来实现
当线程竞争锁来执行读写操作时,如果此时 "锁未被持有" 且 "锁的等待队列不为空"
- 对于非公平工作模式:线程可以 "插队" 竞争锁并执行后续读写操作
- 对于公平模式:线程会进入等待队列排队等待获取锁
ArrayBlockingQueue 中提供的入队、出队函数有很多,我们重点看下支持阻塞写的 put() 函数和支持阻塞读的 take() 函数
2.2、put()
put() 函数的源码如下所示
put() 函数的代码实现是 Condition 条件变量的标准使用方法:先加锁,为了避免假唤醒,循环调用 await() 函数等待非满条件的发生,最后执行业务逻辑并解锁
public void put(E e) throws InterruptedException { checkNotNull(e); lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); // 阻塞, 等待队列非满 enqueue(e); } finally { lock.unlock(); } }
上述 put() 函数中 enqueue() 函数的源码如下所示,在下面的代码中,putIndex 到达数组的最末尾之后,会重置为 0,重新指向数组的开头
因此我们可以得知,ArrayBlockingQueue 是一个循环队列(对循环队列实现原理不清楚的读者,请阅读《数据结构与算法之美》相关章节)
除此之外,put() 函数在调用 enqueue() 函数之前,就已经加了锁并且确保队列非满,因此 enqueue() 函数不需要处理线程安全问题以及队列满了的情况
enqueue() 函数执行完成之后,队列中添加了新的数据,于是就调用 notEmpty 条件变量上的 signal() 函数,唤醒其中一个执行阻塞读的线程
private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); // 唤醒执行阻塞读, 等待队列非空的线程 }
2.3、take()
接下来我们就看下出队操作是如何实现的,对应 take() 函数的源码如下所示
take() 函数代码结构跟 put() 函数类似
区别在于 take() 函数调用的是 notEmpty 条件变量上 await() 方法,等待非空条件的发生,并且在等到队列真正非空时,执行出队操作,也就是调用 dequeue() 函数
public E take() throws InterruptedException { lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); // 阻塞, 等待队列非空 return dequeue(); } finally { lock.unlock(); } }
dequeue() 函数的源码如下所示,跟 enqueue() 函数类似,dequeue() 函数也不需要处理线程安全问题以及队列为空的情况
当执行完出队操作之后,dequeue() 函数调用 notFull 条件变量上的 signal() 函数,唤醒其中一个执行阻塞写的线程
private E dequeue() { final Object[] items = this.items; @SuppressWarnings("unchecked") E x = (E) items[takeIndex]; items[takeIndex] = null; if (++takeIndex == items.length) takeIndex = 0; count--; notFull.signal(); // 唤醒执行阻塞写, 等待队列非满的线程 return x; }
2.4、offer() 和 poll()
实际上除了实现支持阻塞的 put() 函数和 take() 函数之外,ArrayBlockingQueue 还实现了非阻塞的 offer() 函数和 poll() 函数
两个函数的代码实现如下所示,它们只通过 ReentrantLock 锁来保证线程安全,并没有通过条件变量来实现阻塞读写
public boolean offer(E e) { checkNotNull(e); lock.lock(); try { if (count == items.length) { return false; } else { enqueue(e); return true; } } finally { lock.unlock(); } } public E poll() { lock.lock(); try { return (count == 0) ? null : dequeue(); } finally { lock.unlock(); } }
2.5、总结
我们对 put() 函数和 take() 函数的实现原理做下总结:读操作和写操作互相等待,如下图所示
- 读操作调用 notEmpty 上 await() 等待非空条件发生,执行完成之后,调用 notFull 上的 signal(),唤醒阻塞等待写的线程
- 写操作调用 notFull 上的 await() 等待非满条件的发生,执行完成之后,调用 notEmpty 上的 signal(),唤醒阻塞等待读的线程
2.6、其它类
详细了解 ArrayBlockingQueue 的实现原理之后,我们再来看下跟它比较类似的 LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue
- LinkedBlockingQueue 是基于 "链表" 实现的 "有界阻塞并发队列"
默认大小为 Integer.MAX_VALUE,这个值非常大,实际上就相当于无界队列,当然我们也可以在创建对象时指定队列大小 - LinkedBlockingDeque 跟 LinkedBlockingQueue 的区别在于:它是一个双端队列,支持两端读写操作
- PriorityBlockingQueue 是一个 "无界阻塞并发优先级队列",底层基于 "支持扩容的堆" 来实现,因此:写操作永远都不需要阻塞,只有读操作会阻塞
这 3 个并发阻塞队列的实现方式,跟 ArrayBlockingQueue 的实现方式类似
也是使用 ReentrantLock 锁来实现读写操作的线程安全性,使用 Condition 条件变量实现读写操作的阻塞等待,这里就不再展示源码做讲解了
实际上从这 4 个并发阻塞队列的实现方式,我们也可以总结得到,利用锁和条件变量,我们可以实现任何类型的并发阻塞容器,比如:并发阻塞栈、并发阻塞哈希表等
3、DelayQueue
DelayQueue 为 "延迟阻塞并发队列",底层基于优先级队列 PriorityQueue 来实现,因为 PriorityQueue 支持动态扩容,因此 DelayQueue 是无界队列
3.1、Delayed 接口
DelayQueue 中存储的每个元素都必须实现 Delayed 接口,提供延迟被读取时间 delayTime,PriorityQueue 按照 delayTime 的大小将元素组织成小顶堆
也就是说:堆顶的元素是 delayTime 最小的元素,最先出队
public interface Delayed extends Comparable<Delayed> { long getDelay(TimeUnit unit); }
3.2、示例
我们举个例子解释一下,示例代码如下所示
job1、job2、job3 的 delayTime 分别为 1s、2s、3s,线程 t1 和 t2 依次执行 take() 函数时,因为没有元素到期,所以均会被阻塞
- 当时间过去 1s 之后,job1 到期,线程 t1 从阻塞中唤醒,读取到 job1
- 当时间过去 2s 之后,job2 到期,线程 t2 从阻塞中唤醒,读取到 job2
这样就实现了一个简单的任务延迟执行框架
public class Job implements Delayed { private String name; private long endTime; // millisecond public Job(String name, long delay) { // delay 为延迟时间,System.currentTimeMillis() + delay 是终止时间 this.name = name; this.endTime = System.currentTimeMillis() + delay; // 注意 } public void run() { System.out.println("I am " + name); } @Override public long getDelay(TimeUnit unit) { // 现在距离终止时间还差多少 long diff = endTime - System.currentTimeMillis(); // 注意 return unit.convert(diff, TimeUnit.MILLISECONDS); } @Override public int compareTo(Delayed o) { return (int) (this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS)); } }
public class Demo { private static class JobRunnable implements Runnable { private DelayQueue<Job> jobs; public JobRunnable(DelayQueue<Job> jobs) { this.jobs = jobs; } @Override public void run() { try { Job job = jobs.take(); job.run(); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { DelayQueue<Job> jobs = new DelayQueue<>(); jobs.put(new Job("job1", 1000)); jobs.put(new Job("job2", 2000)); jobs.put(new Job("job3", 3000)); Thread t1 = new Thread(new JobRunnable(jobs)); Thread t2 = new Thread(new JobRunnable(jobs)); t1.start(); t2.start(); t1.join(); t2.join(); } }
3.3、take()
因为 put() 函数不支持阻塞,实现比较简单,只是通过加锁保证线程安全,所以我们重点看下比较复杂的支持阻塞的 take() 函数,take() 函数的源码如下所示
public E take() throws InterruptedException { lock.lockInterruptibly(); try { // 自旋, 以免假唤醒 for (; ; ) { E first = q.peek(); if (first == null) available.await(); // 条件变量: 可获得的, put() 函数会调用 signal() 唤醒它 else { long delay = first.getDelay(NANOSECONDS); if (delay <= 0) return q.poll(); // 元素到期被读取 if (leader != null) { // 非 leader 线程 available.await(); } else { // leader 线程 Thread thisThread = Thread.currentThread(); leader = thisThread; try { available.awaitNanos(delay); // 等待 delay 时间自动唤醒 } finally { if (leader == thisThread) leader = null; } } } } } finally { // 唤醒非 leader 线程 if (leader == null && q.peek() != null) available.signal(); lock.unlock(); } }
3.4、图示
实际上 take() 函数包含两部分独立的逻辑:针对 leader 线程的逻辑和针对非 leader 线程的逻辑,如下图所示
如果多个线程先后调用 take() 函数,那么第一个线程就是 leader 线程,剩下的线程为非 leader 线程
第一个线程执行读取操作完成之后,第二个线程便成为 leader 线程
非 leader 线程的处理逻辑比较简单,直接调用 await() 函数阻塞,等待 leader 线程读取完成之后调用 signal() 函数来唤醒
leader 线程的处理逻辑比较复杂,leader 线程读取的是队首的元素
如果队首元素的 delayTime > 0,那么 leader 线程会调用 awaitNanos() 阻塞 delayTime 时间,当 delayTime 时间过去之后,leader 线程自动唤醒
为了避免假唤醒(假唤醒来自于其他线程 "插队" 读取,待会讲解),leader 线程会检查队首元素的 delayTime 是否真正变为 <= 0
如果是则将队首元素出队,并且调用 signal() 唤醒第二个线程,第二个线程于是就成了 leader 线程,执行以上 leader 线程要执行的逻辑
实际上通过 take() 函数的源码,我们还可以发现:take() 函数的处理过程存在 "插队" 的行为
当一个线程执行 take() 函数时,如果检查发现队列不为空,并且队首元素的 delayTime <= 0
于是不管是否有其他线程在调用 await() 或 awaitNanos() 阻塞等待,这个线程都会直接读取队首元素并返回
4、SynchronousQueue
SynchronousQueue 是一个 "特殊的阻塞并发队列",用于两个线程之间传递数据
线程执行 put() 操作必须阻塞等待另一个线程执行 take() 操作,也就是说:SynchronousQueue 队列中不存储任何元素
因为在平时的开发中,SynchronousQueue 很少用到,所以我们不对其实现原理做深入分析,只对其用法做一个简单介绍,如下示例代码所示
// 先后输出: sleep done! take done! put done! public class Demo { public static void main(String[] args) throws InterruptedException { SynchronousQueue<String> sq = new SynchronousQueue<>(); Thread t1 = new Thread(new Runnable() { @Override public void run() { try { sq.put("a"); System.out.println("put done!"); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); Thread.sleep(3000); System.out.println("sleep done!"); sq.take(); System.out.println("take done!"); } }
5、LinkedTransferQueue
LinkedTransferQueue 是一个基于 "链表" 实现的 "无界阻塞并发队列"
它是 LinkedBlockingQueue 和 SynchronousQueue 的综合体,既实现了 LinkedBlockingQueue 的功能,又实现了 SychronousQueue 的功能
LinkedTransferQueue 提供的 transfer() 函数,跟 SynchronousQueue 中的 put() 函数的功能类似,调用 transfer() 函数的线程会一直阻塞,直到数据被其他线程消费才会返回
在平时的开发中,LinkedTransferQueue 用到的也比较少,我们对其实现原理也不做深入分析,我们只简单介绍一下它的用法,示例代码如下所示
// 先后输出: put done! sleep done! take done! transfer done! public class Demo { public static void main(String[] args) throws InterruptedException { LinkedTransferQueue<String> ltq = new LinkedTransferQueue<>(); Thread t1 = new Thread(new Runnable() { @Override public void run() { ltq.put("a"); // 不需要阻塞等待 System.out.println("put done!"); try { ltq.transfer("b"); // 等待 b 被读取才返回 System.out.println("transfer done!"); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); Thread.sleep(3000); System.out.println("sleep done!"); ltq.take(); // 读取 put() 写入的数据 ltq.take(); // 读取 transfer() 写入的数据 System.out.println("take done!"); } }
6、课后思考题
请借鉴阻塞并发队列的实现方式,实现阻塞并发栈和阻塞并发哈希表
阻塞并发栈的代码实现如下所示
public class BlockingStack<E> { private Object[] items; private int top = 0; private int capacity; private ReentrantLock lock = new ReentrantLock(); private Condition notEmpty = lock.newCondition(); // 等待栈非空条件发生 private Condition notFull = lock.newCondition(); // 等待栈非满条件发生 public BlockingStack(int capacity) { this.capacity = capacity; this.items = new Object[capacity]; } // 栈已满时,写入操作会被阻塞,直到栈有可写的空位为止 public void push(E elem) throws InterruptedException { lock.lock(); try { while (top == capacity) { notFull.await(); } this.items[top++] = elem; notEmpty.signal(); } finally { lock.unlock(); } } // 栈为空时,读取操作会被阻塞,直到栈有可读的数据为止 public E pop() throws InterruptedException { lock.lock(); try { while (top == 0) { notEmpty.await(); } return (E) items[--top]; } finally { lock.unlock(); } } }
阻塞并发哈希表的代码实现如下所示
public class BlockingHashMap<K, V> { private HashMap<K, V> hashMap = new HashMap<>(); private int capacity; private int size = 0; private ReentrantLock lock = new ReentrantLock(); private Condition notEmpty = lock.newCondition(); // 等待哈希表非空条件发生 private Condition notFull = lock.newCondition(); // 等待哈希表非满条件发生 public BlockingHashMap(int capacity) { this.capacity = capacity; } // 哈希表已满时,写入操作会被阻塞,直到哈希表有可写的空位为止 public void put(K key, V value) throws InterruptedException { lock.lock(); try { while (size == capacity) { notFull.await(); } hashMap.put(key, value); size++; notEmpty.signal(); } finally { lock.unlock(); } } // 哈希表为空时,读取操作会被阻塞,直到哈希表有可读的数据为止 public V get(K key) throws InterruptedException { lock.lock(); try { while (size == 0) { notEmpty.await(); } return hashMap.get(key); } finally { lock.unlock(); } } // 哈希表为空时,读取操作会被阻塞,直到哈希表有可读的数据为止 public V remove(K key) throws InterruptedException { lock.lock(); try { while (size == 0) { notEmpty.await(); } size--; V value = hashMap.remove(key); notFull.signal(); return value; } finally { lock.unlock(); } } }
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17493384.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步