第十三章 阻塞队列

13.1 阻塞队列的由来

我们假设一种场景,生产者一直生产资源,消费者一直消费资源,资源存储在一个缓冲池中,生产者将生产的资源存进缓冲池中,消费者从缓冲池中拿到资源进行消费,这就是大名鼎鼎的生产者-消费者模式

该模式能够简化开发过程,一方面消除了生产者类与消费者类之间的代码依赖性,另一方面将生产数据的过程与使用数据的过程解耦简化负载。

我们自己coding实现这个模式的时候,因为需要让多个线程操作共享变量(即资源),所以很容易引发线程安全问题,造成重复消费死锁,尤其是生产者和消费者存在多个的情况。另外,当缓冲池空了,我们需要阻塞消费者,唤醒生产者;当缓冲池满了,我们需要阻塞生产者,唤醒消费者,这些个等待-唤醒逻辑都需要自己实现。(这块不明白的同学,可以看最下方结语部分的链接)

这么容易出错的事情,JDK当然帮我们做啦,这就是阻塞队列(BlockingQueue),你只管往里面存、取就行,而不用担心多线程环境下存、取共享变量的线程安全问题。

BlockingQueue是Java util.concurrent包下重要的数据结构,区别于普通的队列,BlockingQueue提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于BlockingQueue实现的。

BlockingQueue一般用于生产者-消费者模式,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。BlockingQueue就是存放元素的容器

13.2 BlockingQueue的操作方法

阻塞队列提供了四组不同的方法用于插入、移除、检查元素:

方法\处理方式 抛出异常 返回特殊值 一直阻塞 超时退出
插入方法 add(e) offer(e) put(e) offer(e,time,unit)
移除方法 remove() poll() take() poll(time,unit)
检查方法 element() peek() - -
  • 抛出异常:如果试图的操作无法立即执行,抛异常。当阻塞队列满时候,再往队列里插入元素,会抛出IllegalStateException(“Queue full”)异常。当队列为空时,从队列里获取元素时会抛出NoSuchElementException异常 。
  • 返回特殊值:如果试图的操作无法立即执行,返回一个特殊值,通常是true / false。
  • 一直阻塞:如果试图的操作无法立即执行,则一直阻塞或者响应中断。
  • 超时退出:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功,通常是 true / false。

注意之处

  • 不能往阻塞队列中插入null,会抛出空指针异常。
  • 可以访问阻塞队列中的任意元素,调用remove(o)可以将队列之中的特定对象移除,但并不高效,尽量避免使用。

13.3 BlockingQueue的实现类

13.3.1 ArrayBlockingQueue

数组结构组成的有界阻塞队列。内部结构是数组,故具有数组的特性。

  1. public ArrayBlockingQueue(int capacity, boolean fair){
  2. //..省略代码
  3. }

可以初始化队列大小, 且一旦初始化不能改变。构造方法中的fair表示控制对象的内部锁是否采用公平锁,默认是非公平锁

13.3.2 LinkedBlockingQueue

链表结构组成的有界阻塞队列。内部结构是链表,具有链表的特性。默认队列的大小是Integer.MAX_VALUE,也可以指定大小。此队列按照先进先出的原则对元素进行排序。

13.3.3 DelayQueue

  1. 该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素 。注入其中的元素必须实现 java.util.concurrent.Delayed 接口。
  2. DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

13.3.4 PriorityBlockingQueue

  1. 基于优先级的无界阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),内部控制线程同步的锁采用的是公平锁。

13.3.5 SynchronousQueue

这个队列比较特殊,没有任何内部容量,甚至连一个队列的容量都没有。并且每个 put 必须等待一个 take,反之亦然。

需要区别容量为1的ArrayBlockingQueue、LinkedBlockingQueue。

以下方法的返回值,可以帮助理解这个队列:

  • iterator() 永远返回空,因为里面没有东西
  • peek() 永远返回null
  • put() 往queue放进去一个element以后就一直wait直到有其他thread进来把这个element取走。
  • offer() 往queue里放一个element后立即返回,如果碰巧这个element被另一个thread取走了,offer方法返回true,认为offer成功;否则返回false。
  • take() 取出并且remove掉queue里的element,取不到东西他会一直等。
  • poll() 取出并且remove掉queue里的element,只有到碰巧另外一个线程正在往queue里offer数据或者put数据的时候,该方法才会取到东西。否则立即返回null。
  • isEmpty() 永远返回true
  • remove()&removeAll() 永远返回false

注意

PriorityBlockingQueue不会阻塞数据生产者(因为队列是无界的),而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。对于使用默认大小的LinkedBlockingQueue也是一样的。

13.5 阻塞队列的原理

阻塞队列的原理很简单,利用了Lock锁的多条件(Condition)阻塞控制。接下来我们分析ArrayBlockingQueue JDK 1.8 的源码。

首先是构造器,除了初始化队列的大小和是否是公平锁之外,还对同一个锁(lock)初始化了两个监视器,分别是notEmpty和notFull。这两个监视器的作用目前可以简单理解为标记分组,当该线程是put操作时,给他加上监视器notFull,标记这个线程是一个生产者;当线程是take操作时,给他加上监视器notEmpty,标记这个线程是消费者。

  1. //数据元素数组
  2. final Object[] items;
  3. //下一个待取出元素索引
  4. int takeIndex;
  5. //下一个待添加元素索引
  6. int putIndex;
  7. //元素个数
  8. int count;
  9. //内部锁
  10. final ReentrantLock lock;
  11. //消费者监视器
  12. private final Condition notEmpty;
  13. //生产者监视器
  14. private final Condition notFull;
  15. public ArrayBlockingQueue(int capacity, boolean fair) {
  16. //..省略其他代码
  17. lock = new ReentrantLock(fair);
  18. notEmpty = lock.newCondition();
  19. notFull = lock.newCondition();
  20. }

put操作的源码

  1. public void put(E e) throws InterruptedException {
  2. checkNotNull(e);
  3. final ReentrantLock lock = this.lock;
  4. // 1.自旋拿锁
  5. lock.lockInterruptibly();
  6. try {
  7. // 2.判断队列是否满了
  8. while (count == items.length)
  9. // 2.1如果满了,阻塞该线程,并标记为notFull线程,
  10. // 等待notFull的唤醒,唤醒之后继续执行while循环。
  11. notFull.await();
  12. // 3.如果没有满,则进入队列
  13. enqueue(e);
  14. } finally {
  15. lock.unlock();
  16. }
  17. }
  18. private void enqueue(E x) {
  19. // assert lock.getHoldCount() == 1;
  20. // assert items[putIndex] == null;
  21. final Object[] items = this.items;
  22. items[putIndex] = x;
  23. if (++putIndex == items.length)
  24. putIndex = 0;
  25. count++;
  26. // 4 唤醒一个等待的线程
  27. notEmpty.signal();
  28. }

总结put的流程:

  1. 所有执行put操作的线程竞争lock锁,拿到了lock锁的线程进入下一步,没有拿到lock锁的线程自旋竞争锁。
  2. 判断阻塞队列是否满了,如果满了,则调用await方法阻塞这个线程,并标记为notFull(生产者)线程,同时释放lock锁,等待被消费者线程唤醒。
  3. 如果没有满,则调用enqueue方法将元素put进阻塞队列。注意这一步的线程还有一种情况是第二步中阻塞的线程被唤醒且又拿到了lock锁的线程。
  4. 唤醒一个标记为notEmpty(消费者)的线程。

take操作的源码

  1. public E take() throws InterruptedException {
  2. final ReentrantLock lock = this.lock;
  3. lock.lockInterruptibly();
  4. try {
  5. while (count == 0)
  6. notEmpty.await();
  7. return dequeue();
  8. } finally {
  9. lock.unlock();
  10. }
  11. }
  12. private E dequeue() {
  13. // assert lock.getHoldCount() == 1;
  14. // assert items[takeIndex] != null;
  15. final Object[] items = this.items;
  16. @SuppressWarnings("unchecked")
  17. E x = (E) items[takeIndex];
  18. items[takeIndex] = null;
  19. if (++takeIndex == items.length)
  20. takeIndex = 0;
  21. count--;
  22. if (itrs != null)
  23. itrs.elementDequeued();
  24. notFull.signal();
  25. return x;
  26. }

take操作和put操作的流程是类似的,总结一下take操作的流程:

  1. 所有执行take操作的线程竞争lock锁,拿到了lock锁的线程进入下一步,没有拿到lock锁的线程自旋竞争锁。
  2. 判断阻塞队列是否为空,如果是空,则调用await方法阻塞这个线程,并标记为notEmpty(消费者)线程,同时释放lock锁,等待被生产者线程唤醒。
  3. 如果没有空,则调用dequeue方法。注意这一步的线程还有一种情况是第二步中阻塞的线程被唤醒且又拿到了lock锁的线程。
  4. 唤醒一个标记为notFull(生产者)的线程。

注意

  1. put和tack操作都需要先获取锁,没有获取到锁的线程会被挡在第一道大门之外自旋拿锁,直到获取到锁。
  2. 就算拿到锁了之后,也不一定会顺利进行put/take操作,需要判断队列是否可用(是否满/空),如果不可用,则会被阻塞,并释放锁
  3. 在第2点被阻塞的线程会被唤醒,但是在唤醒之后,依然需要拿到锁才能继续往下执行,否则,自旋拿锁,拿到锁了再while判断队列是否可用(这也是为什么不用if判断,而使用while判断的原因)。

13.6 示例和使用场景

13.6.1 生产者-消费者模型

  1. public class Test {
  2. private int queueSize = 10;
  3. private ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(queueSize);
  4. public static void main(String[] args) {
  5. Test test = new Test();
  6. Producer producer = test.new Producer();
  7. Consumer consumer = test.new Consumer();
  8. producer.start();
  9. consumer.start();
  10. }
  11. class Consumer extends Thread{
  12. @Override
  13. public void run() {
  14. consume();
  15. }
  16. private void consume() {
  17. while(true){
  18. try {
  19. queue.take();
  20. System.out.println("从队列取走一个元素,队列剩余"+queue.size()+"个元素");
  21. } catch (InterruptedException e) {
  22. e.printStackTrace();
  23. }
  24. }
  25. }
  26. }
  27. class Producer extends Thread{
  28. @Override
  29. public void run() {
  30. produce();
  31. }
  32. private void produce() {
  33. while(true){
  34. try {
  35. queue.put(1);
  36. System.out.println("向队列取中插入一个元素,队列剩余空间:"+(queueSize-queue.size()));
  37. } catch (InterruptedException e) {
  38. e.printStackTrace();
  39. }
  40. }
  41. }
  42. }
  43. }

下面是这个例子的输出片段:

  1. 从队列取走一个元素,队列剩余0个元素
  2. 从队列取走一个元素,队列剩余0个元素
  3. 向队列取中插入一个元素,队列剩余空间:9
  4. 向队列取中插入一个元素,队列剩余空间:9
  5. 向队列取中插入一个元素,队列剩余空间:9
  6. 向队列取中插入一个元素,队列剩余空间:8
  7. 向队列取中插入一个元素,队列剩余空间:7
  8. 向队列取中插入一个元素,队列剩余空间:6
  9. 向队列取中插入一个元素,队列剩余空间:5
  10. 向队列取中插入一个元素,队列剩余空间:4
  11. 向队列取中插入一个元素,队列剩余空间:3
  12. 向队列取中插入一个元素,队列剩余空间:2
  13. 向队列取中插入一个元素,队列剩余空间:1
  14. 向队列取中插入一个元素,队列剩余空间:0
  15. 从队列取走一个元素,队列剩余1个元素
  16. 从队列取走一个元素,队列剩余9个元素

注意,这个例子中的输出结果看起来可能有问题,比如有几行在插入一个元素之后,队列的剩余空间不变。这是由于System.out.println语句没有锁。考虑到这样的情况:线程1在执行完put/take操作后立即失去CPU时间片,然后切换到线程2执行put/take操作,执行完毕后回到线程1的System.out.println语句并输出,发现这个时候阻塞队列的size已经被线程2改变了,所以这个时候输出的size并不是当时线程1执行完put/take操作之后阻塞队列的size,但可以确保的是size不会超过10个。实际上使用阻塞队列是没有问题的。

13.6.2 线程池中使用阻塞队列

  1. public ThreadPoolExecutor(int corePoolSize,
  2. int maximumPoolSize,
  3. long keepAliveTime,
  4. TimeUnit unit,
  5. BlockingQueue<Runnable> workQueue) {
  6. this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
  7. Executors.defaultThreadFactory(), defaultHandler);
  8. }

Java中的线程池就是使用阻塞队列实现的,我们在了解阻塞队列之后,无论是使用Exectors类中已经提供的线程池,还是自己通过ThreadPoolExecutor实现线程池,都会更加得心应手,想要了解线程池的同学,可以看第十二站:线程池原理。

注:上面提到了生产者-消费者模式,大家可以参考生产者-消费者模型,可以更好的理解阻塞队列。


参考资料