1.1w字,10图彻底掌握阻塞队列(并发必备)

开发 前端
队列是一种 先进先出的特殊线性表,简称 FIFO。特殊之处在于只允许在一端插入,在另一端删除,进行插入操作的端称为队尾,进行删除操作的端称为队头。队列中没有元素时,称为空队列。

[[374753]]

本文转载自微信公众号「源码兴趣圈」,作者malt  。转载本文请联系源码兴趣圈公众号。

什么是队列

队列是一种 先进先出的特殊线性表,简称 FIFO。特殊之处在于只允许在一端插入,在另一端删除

进行插入操作的端称为队尾,进行删除操作的端称为队头。队列中没有元素时,称为空队列

队列在程序设计中使用非常的多,包括一些中间件底层数据结构就是队列(基础内容没有过多讲解)

 

什么是阻塞队列

队列就队列呗,阻塞队列又是什么鬼

阻塞队列是在队列的基础上额外添加两个操作的队列,分别是:

 

  1. 支持阻塞的插入方法:队列容量满时,插入元素线程会被阻塞,直到队列有多余容量为止
  2. 支持阻塞的移除方法:当队列中无元素时,移除元素的线程会被阻塞,直到队列有元素可被移除

文章以 LinkedBlockingQueue 为例,讲述队列之间实现的不同点,为方便小伙伴阅读,LinkedBlockingQueue 取别名 LBQ

因为是源码解析文章,建议小伙伴们在 PC 端观看。当然,如果屏足够大当我没说~

阻塞队列继承关系

阻塞队列是一个抽象的叫法,阻塞队列底层数据结构 可以是数组,可以是单向链表,亦或者是双向链表...

LBQ 是一个以 单向链表组成的队列,下图为 LBQ 上下继承关系图

从图中得知,LBQ 实现了 BlockingQueue 接口,BlockingQueue 实现了 Queue 接口

 

Queue 接口分析

我们以自上而下的方式,先分析一波 Queue 接口里都定义了哪些方法

  1. // 如果队列容量允许,立即将元素插入队列,成功后返回 
  2. // 🌟如果队列容量已满,则抛出异常 
  3. boolean add(E e); 
  4.  
  5. //  如果队列容量允许,立即将元素插入队列,成功后返回 
  6. // 🌟如果队列容量已满,则返回 false 
  7. // 当使用有界队列时,offer 比 add 方法更何时 
  8. boolean offer(E e); 
  9.  
  10. // 检索并删除队列的头节点,返回值为删除的队列头节点 
  11. // 🌟如果队列为空则抛出异常 
  12. E remove(); 
  13.  
  14. // 检索并删除队列的头节点,返回值为删除的队列头节点 
  15. // 🌟如果队列为空则返回 null 
  16. E poll(); 
  17.  
  18. // 检查但不删除队列头节点 
  19. // 🌟如果队列为空则抛出异常 
  20. E element(); 
  21.  
  22. // 检查但不删除队列头节点 
  23. // 🌟如果队列为空则返回 null 
  24. E peek(); 

总结一下 Queue 接口的方法,分为三个大类:

  1. 新增元素到队列容器中:add、offer
  2. 从队列容器中移除元素:remove、poll
  3. 查询队列头节点是否为空:element、peek

从接口 API 的程序健壮性考虑,可以分为两大类:

  1. 健壮 API:offer、poll、peek
  2. 非健壮 API:add、remove、element

接口 API 并无健壮可言,这里说的健壮界限指得是,使用了非健壮性的 API 接口,程序会出错的几率大了点,所以我们 更应该关注的是如何捕获可能出现的异常,以及对应异常处理

BlockingQueue 接口分析

BlockingQueue 接口继承自 Queue 接口,所以有些语义相同的 API 接口就没有放上来解读

  1. // 将指定元素插入队列,如果队列已满,等待直到有空间可用;通过 throws 异常得知,可在等待时打断 
  2. // 🌟相对于 Queue 接口而言,是一个全新的方法 
  3. void put(E e) throws InterruptedException; 
  4.  
  5. // 将指定元素插入队列,如果队列已满,在等待指定的时间内等待腾出空间;通过 throws 异常得知,可在等待时打断 
  6. // 🌟相当于是 offer(E e) 的扩展方法 
  7. boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; 
  8.  
  9. // 检索并除去此队列的头节点,如有必要,等待直到元素可用;通过 throws 异常得知,可在等待时打断 
  10. E take() throws InterruptedException; 
  11.  
  12. // 检索并删除此队列的头,如果有必要使元素可用,则等待指定的等待时间;通过 throws 异常得知,可在等待时打断 
  13. // 🌟相当于是 poll() 的扩展方法 
  14. E poll(long timeout, TimeUnit unit) throws InterruptedException; 
  15.  
  16. // 返回队列剩余容量,如果为无界队列,返回 Integer.MAX_VALUE 
  17. int remainingCapacity(); 
  18.  
  19. // 如果此队列包含指定的元素,则返回 true 
  20. public boolean contains(Object o); 
  21.  
  22. // 从此队列中删除所有可用元素,并将它们添加到给定的集合中 
  23. int drainTo(Collection<? super E> c); 
  24.  
  25. // 从此队列中最多移除给定数量的可用元素,并将它们添加到给定的集合中 
  26. int drainTo(Collection<? super E> c, int maxElements); 

可以看到 BlockingQueue 接口中个性化的方法还是挺多的。本文的猪脚 LBQ 就是实现自 BlockingQueue 接口

源码解析

变量分析LBQ 为了保证并发添加、移除等操作,使用了 JUC 包下的 ReentrantLock、Condition 控制

  1. // take, poll 等移除操作需要持有的锁 
  2. private final ReentrantLock takeLock = new ReentrantLock(); 
  3. // 当队列没有数据时,删除元素线程被挂起 
  4. private final Condition notEmpty = takeLock.newCondition(); 
  5. // put, offer 等新增操作需要持有的锁 
  6. private final ReentrantLock putLock = new ReentrantLock(); 
  7. // 当队列为空时,添加元素线程被挂起 
  8. private final Condition notFull = putLock.newCondition(); 

ArrayBlockingQueue(ABQ)内部元素个数字段为什么使用的是 int 类型的 count 变量?不担心并发么

  1. 因为 ABQ 内部使用的一把锁控制入队、出队操作,同一时刻只会有单线程执行 count 变量修改
  2. LBQ 使用的两把锁,所以会出现两个线程同时修改 count 数值,如果像 ABQ 使用 int 类型,两个流程同时执行修改 count 个数,会造成数据不准确,所以需要使用并发原子类修饰

如果不太明白为什么要用原子类统计数量,猛戳这里

接下来从结构体入手,知道它是由什么元素组成,每个元素是做啥使的。如果数据结构还不错的小伙伴,应该可以猜出来

  1. // 绑定的容量,如果无界,则为 Integer.MAX_VALUE 
  2. private final int capacity; 
  3. // 当前队列中元素个数 
  4. private final AtomicInteger count = new AtomicInteger(); 
  5. // 当前队列的头节点 
  6. transient Node<E> head; 
  7. // 当前队列的尾节点 
  8. private transient Node<E> last

看到 head 和 last 元素,是不是对 LBQ 就有个大致的雏形了,这个时候还差一个结构体 Node

  1. static class Node<E> { 
  2.     // 节点存储的元素 
  3.     E item; 
  4.     // 当前节点的后继节点 
  5.     LinkedBlockingQueue.Node<E> next
  6.  
  7.     Node(E x) { item = x; } 

构造器分析这里画一张图来理解下 LBQ 默认构造方法是如何初始化队列的

  1. public LinkedBlockingQueue() { 
  2.     this(Integer.MAX_VALUE); 
  3.  
  4. public LinkedBlockingQueue(int capacity) { 
  5.     if (capacity <= 0) throw new IllegalArgumentException(); 
  6.     this.capacity = capacity; 
  7.     last = head = new Node<E>(null); 

可以看出,默认构造方法会将容量设置为 Integer.MAX_VALUE,也就是大家常说的无界队列

内部其实调用的是重载的有参构造,方法内部设置了容量大小,以及初始化了 item 为空的 Node 节点,把 head last 两节点进行一个关联

初始化的队列 head last 节点指向的 Node 中 item、next 都为空,此时添加一条记录,队列会发生什么样的变化

 

节点入队

需要添加的元素会被封装为 Node 添加到队列中, put 入队方法语义,如果队列元素已满,阻塞当前插入线程,直到队列中有空缺位置被唤醒

  1. public void put(E e) throws InterruptedException { 
  2.     if (e == null) throw new NullPointerException(); 
  3.     int c = -1; 
  4.     Node<E> node = new Node<E>(e);  // 将需要添加的数据封装为 Node 
  5.     final ReentrantLock putLock = this.putLock;  // 获取添加操作的锁 
  6.     final AtomicInteger count = this.count;  // 获取队列实际元素数量 
  7.     putLock.lockInterruptibly();  // 运行可被中断加锁 API 
  8.     try { 
  9.         while (count.get() == capacity) {  // 如果队列元素数量 == 队列最大值,则将线程放入条件队列阻塞 
  10.             notFull.await(); 
  11.         } 
  12.         enqueue(node);  // 执行入队流程 
  13.         c = count.getAndIncrement();  // 获取值并且自增,举例:count = 0,执行后结果值 count+1 = 2,返回 0 
  14.         if (c + 1 < capacity)  // 如果自增过的队列元素 +1 小于队列容器最大数量,唤醒一条被阻塞在插入等待队列的线程 
  15.             notFull.signal(); 
  16.     } finally { 
  17.         putLock.unlock();  // 解锁操作 
  18.     } 
  19.     if (c == 0)  // 当队列中有一条数据,则唤醒消费组线程进行消费 
  20.         signalNotEmpty(); 

入队方法整体流程比较清晰,做了以下几件事:

  1. 队列已满,则将当前线程阻塞
  2. 队列中如果有空缺位置,将数据封装的 Node 执行入队操作
  3. 如果 Node 执行入队操作后,队列还有空余位置,则唤醒等待队列中的添加线程
  4. 如果数据入队前队列没有元素,入队成功后唤醒消费阻塞队列中的线程

继续看一下入队方法 LBQ#enqueue 都做了什么操作

  1. private void enqueue(Node<E> node) { 
  2.     last = last.next = node; 

代码比较简单,先把 node 赋值为当前 last 节点的 next 属性,然后再把 last 节点指向 node,就完成了节点入队操作

假设 LBQ 的范型是 String 字符串,首先插入元素 a,队列如下图所示:

什么?一条数据不过瘾?没有什么是再来一条解决不了的,元素 b 入队如下:

队列入队如上图所示,head 中 item 永为空,last 中 next 永为空

 

LBQ#offer 也是入队方法,不同的是:如果队列元素已满,则直接返回 false,不阻塞线程

节点出队

LBQ#take 出队方法,如果队列中元素为空,阻塞当前出队线程,直到队列中有元素为止

  1. public E take() throws InterruptedException { 
  2.     E x; 
  3.     int c = -1; 
  4.     final AtomicInteger count = this.count;  // 获取当前队列实际元素个数 
  5.     final ReentrantLock takeLock = this.takeLtakeLocock;  // 获取 takeLock 锁实例 
  6.     takeLock.lockInterruptibly();  // 获取 takeLock 锁,获取不到阻塞过程中,可被中断 
  7.     try { 
  8.         while (count.get() == 0) {  // 如果当前队列元素 == 0,当前获取节点线程加入等待队列 
  9.             notEmpty.await(); 
  10.         } 
  11.         x = dequeue();  // 当前队列元素 > 0,执行头节点出队操作 
  12.         c = count.getAndDecrement();  // 获取当前队列元素个数,并将数量 - 1 
  13.         if (c > 1)  // 当队列中还有还有元素时,唤醒下一个消费线程进行消费 
  14.             notEmpty.signal(); 
  15.     } finally { 
  16.         takeLock.unlock();  // 释放锁 
  17.     } 
  18.     if (c == capacity)  // 移除元素之前队列是满的,唤醒生产者线程添加元素 
  19.         signalNotFull(); 
  20.     return x;  // 返回头节点 

出队操作整体流程清晰明了,和入队操作执行流程相似

队列已满,则将当前出队线程阻塞

队列中如果有元素可消费,执行节点出队操作

如果节点出队后,队列中还有可出队元素,则唤醒等待队列中的出队线程

如果移除元素之前队列是满的,唤醒生产者线程添加元素

LBQ#dequeue 出队操作相对于入队操作稍显复杂一些

  1. private E dequeue() { 
  2.     Node<E> h = head;  // 获取队列头节点 
  3.     Node<E> first = h.next;  // 获取头节点的后继节点 
  4.     h.next = h; // help GC 
  5.     head = first;  // 相当于把头节点的后继节点,设置为新的头节点 
  6.     E x = first.item;  // 获取到新的头节点 item 
  7.     first.item = null;  // 因为头节点 item 为空,所以 item 赋值为 null 
  8.     return x; 

出队流程中,会将原头节点自己指向自己本身,这么做是为了帮助 GC 回收当前节点,接着将原 head 的 next 节点设置为新的 head,下图为一个完整的出队流程

出队流程图如上,流程中没有特别注意的点。另外一个 LBQ#poll 出队方法,如果队列中元素为空,返回 null,不会像 take 一样阻塞

 

节点查询

因为 element 查找方法在父类 AbstractQueue 里实现的,LBQ 里只对 peek 方法进行了实现,节点查询就用 peek 做代表了

peek 和 element 都是获取队列头节点数据,两者的区别是,前者如果队列为空返回 null,后者抛出相关异常

  1. public E peek() { 
  2.     if (count.get() == 0)  // 队列为空返回 null 
  3.         return null
  4.     final ReentrantLock takeLock = this.takeLock; 
  5.     takeLock.lock();  // 获取锁 
  6.     try { 
  7.         LinkedBlockingQueue.Node<E> first = head.next;  // 获取头节点的 next 后继节点 
  8.         if (first == null)  // 如果后继节点为空,返回 null,否则返回后继节点的 item 
  9.             return null
  10.         else 
  11.             return first.item; 
  12.     } finally { 
  13.         takeLock.unlock();  // 解锁 
  14.     } 

看到这里,能够得到结论,虽然 head 节点 item 永远为 null,但是 peek 方法获取的是 head.next 节点 item

节点删除

删除操作需要获得两把锁,所以关于获取节点、节点出队、节点入队等操作都会被阻塞

  1. public boolean remove(Object o) { 
  2.     if (o == nullreturn false
  3.     fullyLock();  // 获取两把锁 
  4.     try { 
  5.         // 从头节点开始,循环遍历队列 
  6.         for (Node<E> trail = head, p = trail.next
  7.              p != null
  8.              trail = p, p = p.next) { 
  9.             if (o.equals(p.item)) {  // item == o 执行删除操作 
  10.                 unlink(p, trail);  // 删除操作 
  11.                 return true
  12.             } 
  13.         } 
  14.         return false
  15.     } finally { 
  16.         fullyUnlock();  // 释放两把锁 
  17.     } 

链表删除操作,一般而言都是循环逐条遍历,而这种的 遍历时间复杂度为 O(n),最坏情况就是遍历了链表全部节点

看一下 LBQ#remove 中 unlink 是如何取消节点关联的

  1. void unlink(Node<E> p, Node<E> trail) { 
  2.     p.item = null;  // 以第一次遍历而言,trail 是头节点,p 为头节点的后继节点 
  3.     trail.next = p.next;  // 把头节点的后继指针,设置为 p 节点的后继指针 
  4.     if (last == p)  // 如果 p == last 设置 last == trail 
  5.         last = trail; 
  6.     // 如果删除元素前队列是满的,删除后就有了空余位置,唤醒生产线程 
  7.     if (count.getAndDecrement() == capacity) 
  8.         notFull.signal(); 

remove 方法和 take 方法是有相似之处,如果 remove 方法的元素是头节点,效果和 take 一致,头节点元素出队

为了更好的理解,我们删除中间元素。画两张图理解下其中原委,代码如下:

  1. public static void main(String[] args) { 
  2.     BlockingQueue<String> blockingQueue = new LinkedBlockingQueue(); 
  3.     blockingQueue.offer("a"); 
  4.     blockingQueue.offer("b"); 
  5.     blockingQueue.offer("c"); 
  6.     // 删除队列中间元素 
  7.     blockingQueue.remove("b"); 

执行完上述代码中三个 offer 操作,队列结构图如下:

执行删除元素 b 操作后队列结构如下图:

如果 p 节点就是 last 尾节点,则把 p 的前驱节点设置为新的尾节点。删除操作大致如此

 

应用场景

上文说了阻塞队列被大量业务场景所应用,这里例举两个实际工作中的例子帮助大家理解

生产者-消费者模式

生产者-消费者模式是一个典型的多线程并发写作模式,生产者和消费者中间需要一个容器来解决强耦合关系,生产者向容器放数据,消费者消费容器数据

生产者-消费者实现有多种方式

Object 类中的 wait、notify、notifyAll

Lock 中 Condition 的 await、signal、signalAll

BlockingQueue

阻塞队列实现生产者-消费者模型代码如下:

  1. @Slf4j 
  2. public class BlockingQueueTest { 
  3.  
  4.     private static final int MAX_NUM = 10; 
  5.     private static final BlockingQueue<String> QUEUE = new LinkedBlockingQueue<>(MAX_NUM); 
  6.  
  7.     public void produce(String str) { 
  8.         try { 
  9.             QUEUE.put(str); 
  10.             log.info("  🔥🔥🔥 队列放入元素 :: {}, 队列元素数量 :: {}", str, QUEUE.size()); 
  11.         } catch (InterruptedException ie) { 
  12.             // ignore 
  13.         } 
  14.     } 
  15.  
  16.     public String consume() { 
  17.         String str = null
  18.         try { 
  19.             str = QUEUE.take(); 
  20.             log.info("  🔥🔥🔥 队列移出元素 :: {}, 队列元素数量 :: {}", str, QUEUE.size()); 
  21.         } catch (InterruptedException ie) { 
  22.             // ignore 
  23.         } 
  24.         return str; 
  25.     } 
  26.  
  27.     public static void main(String[] args) { 
  28.         BlockingQueueTest queueTest = new BlockingQueueTest(); 
  29.         for (int i = 0; i < 5; i++) { 
  30.             int finalI = i; 
  31.             new Thread(() -> { 
  32.                 String str = "元素-"
  33.                 while (true) { 
  34.                     queueTest.produce(str + finalI); 
  35.                 } 
  36.             }).start(); 
  37.         } 
  38.         for (int i = 0; i < 5; i++) { 
  39.             new Thread(() -> { 
  40.                 while (true) { 
  41.                     queueTest.consume(); 
  42.                 } 
  43.             }).start(); 
  44.         } 
  45.     } 

线程池应用

阻塞队列在线程池中的具体应用属于是生产者-消费者的实际场景

  1. 线程池在 Java 应用里的重要性不言而喻,这里简要说下线程池的运行原理
  2. 线程池线程数量小于核心线程数执行新增核心线程操作
  3. 线程池线程数量大于或等于核心线程数时,将任务存放阻塞队列

满足线程池中线程数大于或等于核心线程数并且阻塞队列已满, 线程池创建非核心线程

重点在于第二点,当线程池核心线程都在运行任务时,会把任务存放阻塞队列中。线程池源码如下:

  1. if (isRunning(c) && workQueue.offer(command)) {} 

看到使用的 offer 方法,通过上面讲述,如果阻塞队列已满返回 false。那何时进行消费队列中的元素呢。涉及线程池中线程执行过程原理,这里简单说明

线程池内线程执行任务有两种方式,一种是创建核心线程时 自带 的任务,另一种就是从阻塞队列获取

当核心线程执行一次任务后,其实和非核心线程就没什么区别了

线程池获取阻塞队列任务使用了两种 API,分别是 poll 和 take

  1. workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); 

Q:为啥要用两个 API?一个不香么?

A:take 是为了要维护线程池内核心线程的重要手段,如果获取不到任务,线程被挂起,等待下一次任务添加

至于带时间的 pool 则是为了回收非核心线程准备的

结言LBQ 阻塞队列到这里就讲解完成了,总结下文章所讲述的 LBQ 基本特征

LBQ 是基于链表实现的阻塞队列,可以进行读写并发执行

LBQ 队列容量可以自己设置,如果不设置默认 Integer 最大值,也可以称为无界队列

文章结合源码,针对 LBQ 的入队、出队、查询、删除等操作进行了详细讲解

LBQ 只是一个引子,更希望大家能够通过文章 掌握阻塞队列核心思想,继而查看其它实现类的代码,巩固知识

 

小伙伴现在已经知道 LBQ 是通过锁的机制来实现并发安全控制,思考一下 不使用锁,能否实现以及如何实现?

 

责任编辑:武晓燕 来源: 源码兴趣圈
相关推荐

2020-10-16 08:26:38

AQS通信协作

2023-12-15 09:45:21

阻塞接口

2021-01-22 17:57:31

SQL数据库函数

2021-08-11 22:17:48

负载均衡LVS机制

2019-07-23 11:01:57

Python同步异步

2020-11-19 07:41:51

ArrayBlocki

2020-11-25 14:28:56

DelayedWork

2020-11-24 09:04:55

PriorityBlo

2020-11-20 06:22:02

LinkedBlock

2021-01-13 14:42:36

GitHub代码Java

2021-07-24 11:15:19

开发技能代码

2020-10-14 11:31:41

Docker

2020-11-03 10:32:48

回调函数模块

2020-07-08 08:07:23

高并发系统消息队列

2017-04-12 10:02:21

Java阻塞队列原理分析

2020-08-25 07:47:03

Java并发队列

2012-06-14 10:34:40

Java阻塞搜索实例

2019-12-10 13:55:10

Go指针存储

2023-11-03 18:03:54

Web应用Python

2021-06-23 06:48:42

秒杀Java电商
点赞
收藏

51CTO技术栈公众号