Java并发-常见的阻塞队列详解 | 8月更文挑战

673 阅读15分钟

往期推荐

一、队列

1、队列简介

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

16.png

2、队列的核心方法

  • add():向队列的尾部加入一个元素(入队),先入队列的元素在最前边。
  • poll():删除队列头部的元素(出队)。
  • peek():取出队列头部的元素。

(1)定义队列的数据结构:

public class Queue<E> {
    private Object[] data=null;
    private int maxSize; //队列的容量
    private int front; //队列头,允许删除
    private int rear; //队列尾,允许插入
      //构造函数,默认的队列大小为 10
    public Queue(){
        this(10);
    }
    public Queue(int initialSize){
      if(initialSize >=0){
          this.maxSize = initialSize;
          data = new Object[initialSize];
          front = rear =0;
      }else{
          throw new RuntimeException("初始化大小不能小于 0:" + initialSize);
      }
    } 
}

(2)向队列插入数据:

//在队列的尾部插入数据
public boolean add(E e) {
    if (rear == maxSize) {
        throw new RuntimeException("队列已满,无法插入新的元素!");
    } else {
        data[rear++] = e;
        return true;
    }
}

(3)取走队列中的数据:

//删除队列头部的元素:出队
public E poll(){
    if(empty()){
        throw new RuntimeException("空队列异常!");
    }else{
        E value = (E) data[front]; //临时保存队列 front 端的元素的值
        data[front++] = null; //释放队列 front 端的元素
        return value;
    }
}

(4)队列数据查询:

//取出队列头部的元素,但不删除
public E peek(){
    if(empty()){
        throw new RuntimeException("空队列异常!");
    }else{
        return (E) data[front];
    }
}

二、阻塞队列

1、阻塞队列简介

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入移除方法。

  • 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
  • 支持阻塞的移除方法:当队列为空时,获取元素的线程会等待队列变为非空。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

在阻塞队列不可用时,这两个附加操作提供了4种处理方式,如下所示:

20

  • 抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalArgumentException异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。

  • 返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回null

  • 一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。

  • 超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出。

注意:如果是无界阻塞队列,队列不可能会出现满的情况,所以使用putoffer方法永远不会被阻塞,而且使用offer方法时,该方法永远返回true

2、阻塞队列常用方法

2.1 抛出异常类型:【add、remove、element】

  • add:向队列中添加一个元素。如果队列是有界队列,当队列已满时再添加则抛出异常提示,如下:
private static void addTest() {
    BlockingQueue<Integer> blockingQueue = new  ArrayBlockingQueue<Integer>(2);
    blockingQueue.add(1);
    blockingQueue.add(1);
    blockingQueue.add(1);
}

上述代码中我们创建了一个阻塞队列容量为 2,当我们使用 add 向其中添加元素,当添加到第三个时则会抛出异常如下:

Exception in thread "main" java.lang.IllegalStateException:Queue full
  • remove:是从队列中删除队列的头节点,同时会返回该元素。当队列中为空时执行 remove 方法时则会抛出异常,代码如下:
private static void removeTest() {
    ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
    blockingQueue.add(1);
    blockingQueue.add(1);
    blockingQueue.remove();
    blockingQueue.remove();
    blockingQueue.remove();
}

在这段代码中,往一个容量为 2 的 BlockingQueue 里放入 2 个元素,并且删除 3 个元素。在删除前面两个元素的时候会正常执行,因为里面依然有元素存在,但是在删除第三个元素时,由于队列里面已经空了,所以便会抛出异常:

Exception in thread "main" java.util.NoSuchElementException
  • element:是获取队列的头元素,但是并不是删除该元素,这也是与 remove 的区别,当队列中没有元素后我们再执行 element 方法时则会抛出异常,代码如下:
private static void groupElement() {
    BlockingQueue queue = new ArrayBlockingQueue(2);
    queue.add("i-code.online");
    System.out.println(queue.element());
    System.out.println(queue.element());
}
private static void groupElement2() {
    BlockingQueue queue = new ArrayBlockingQueue(2);
    System.out.println(queue.element());
}

上面两个方法分别演示了在有元素和无元素的情况 element 的使用。在第一个方法中并不会报错,因为首元素一直存在的,第二个方法中因为空的,所以抛出异常,如下结果:

Exception in thread "main" java.util.NoSuchElementException

2.2 返回结果但不抛出异常类型:【offer、poll、peek】

  • offer:用来插入一个元素,并用返回值来提示插入是否成功。如果添加成功会返回 true,而如果队列已经满了,此时继续调用 offer 方法的话,它不会抛出异常,只会返回一个错误提示:false。如下:
private static void offerTest() {
    ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
    System.out.println(blockingQueue.offer(1));
    System.out.println(blockingQueue.offer(1));
    System.out.println(blockingQueue.offer(1));
}

创建了一个容量为 2 的 ArrayBlockingQueue,并且调用了三次 offer方法尝试添加,每次都把返回值打印出来,运行结果如下:

true
true
false

可以看出,前面两次添加成功了,但是第三次添加的时候,已经超过了队列的最大容量,所以会返回 false,表明添加失败。

  • poll:对应上面 remove 方法,两者的区别就在于是否会在无元素情况下抛出异常,poll 方法在无元素时不会抛出异常而是返回 null ,如下代码:
private static void groupPoll() {
    BlockingQueue queue = new ArrayBlockingQueue(2);
    System.out.println(queue.offer("清粥为伴")); //添加元素
    System.out.println(queue.poll()); //取出头元素并且删除
    System.out.println(queue.poll());
}

上面代码中我们创建一个容量为 2 的队列,并添加一个元素,之后调用两次 poll 方法来获取并删除头节点,发现第二次调用时为 null ,因为队列中已经为空了,如下:

true
清粥为伴
null
  • peek:与前面的 element 方法是对应的 ,获取元素头节点但不删除,与其不同的在于 peek 方法在空队列下并不会抛出异常,而是返回 null,代码如下:
private static void peekTest() {
    ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
    System.out.println(blockingQueue.peek());
}

新建了一个空的 ArrayBlockingQueue,然后直接调用 peek,返回结果 null,代表此时并没有东西可以取出。

null

2.3 阻塞类型:【put、take】

  • put:是插入元素。通常在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false 也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。
  • take:是获取并移除队列的头结点。通常在队列里有数据的时候会正常取出数据并删除;但是如果执行 take 的时候队列里无数据,则阻塞,直到队列里有数据;一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。

2.4 总结

讲解了阻塞队列中常见的方法并且把它们分为了三组,每一组都有各自的特点。

  1. 第一组的特点是在无法正常执行的情况下抛出异常;
  2. 第二组的特点是在无法正常执行的情况下不抛出异常,但会用返回值提示运行失败;
  3. 第三组的特点是在遇到特殊情况时让线程陷入阻塞状态,等到可以运行再继续执行。

21.png

3、常见的7种阻塞队列

Java 中队列 Queue 类的类图

19.png

JDK提供的7种阻塞队列,分别是

22.png

  1. ArrayBlockingQueue:是一个用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。支持公平锁和非公平锁。
  • 【注:每一个线程在获取锁的时候可能都会排队等待,如果在等待时间上,先获取锁的线程的请求一定先被满足,那么这个锁就是公平的。反之,这个锁就是不公平的。公平的获取锁,也就是当前等待时间最长的线程先获取锁】

  • 在创建它的时候就需要指定它的容量,之后不可以再扩容,在构造函数中同样可以指定是否是公平的,代码如下:

//指定队列的容量,使用非公平锁
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();
}
//允许使用一个 Collection 来作为队列的默认元素
public ArrayBlockingQueue(int capacity, boolean fair,
                          Collection<? extends E> c) {
    this(capacity, fair);

    final ReentrantLock lock = this.lock;
    lock.lock(); // Lock only for visibility, not mutual exclusion
    try {
        int i = 0;
        try {
            for (E e : c) {    //遍历添加指定集合的元素
                if (e == null) throw new NullPointerException();
                items[i++] = e;
            }
        } catch (ArrayIndexOutOfBoundsException ex) {
            throw new IllegalArgumentException();
        }
        count = i;
        putIndex = (i == capacity) ? 0 : i;    //修改 putIndex 为 c 的容量 +1
    } finally {
        lock.unlock();
    }
}
  • 第一个参数是容量,第二个参数是是否公平。正如 ReentrantLock 一样,如果 ArrayBlockingQueue 被设置为非公平的,那么就存在插队的可能;如果设置为公平的,那么等待了最长时间的线程会被优先处理,其他线程不允许插队,不过这样的公平策略同时会带来一定的性能损耗,因为非公平的吞吐量通常会高于公平的情况。
  1. LinkedBlockingQueue:一个由链表结构组成的有界队列,此队列的长度为Integer.MAX_VALUE。此队列按照先进先出的顺序进行排序。
//使用 Integer.MAX_VALUE 作为容量
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

//指定最大容量
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

//使用 Integer.MAX_VALUE 作为容量,同时将指定集合添加到队列中
public LinkedBlockingQueue(Collection<? extends E> c) {
    this(Integer.MAX_VALUE);
    final ReentrantLock putLock = this.putLock;
    putLock.lock(); // Never contended, but necessary for visibility
    try {
        int n = 0;
        for (E e : c) {    //遍历添加到队列
            if (e == null)    //需要注意待添加集合中不能有空值
                throw new NullPointerException();
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node<E>(e));
            ++n;
        }
        count.set(n);
    } finally {
        putLock.unlock();
    }
}
  1. PriorityBlockingQueue:一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化PriorityBlockingQueue时,指定构造参数Comparator来进行排序。需要注意的是不能保证同优先级元素的顺序。
  2. DelayQueue:一个支持延时获取元素的无界阻塞队列。队列使用PriorityBlockingQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。 DelayQueue 使用场景:
  • 缓存系统的设计
    • DelayQueue 保存元素的有效期,用一个线程来循环查询 DelayQueue ,能查到元素,就说明缓存的有效期到了
  • 定时任务调度
    • DelayQueue 保存定时执行的任务和执行时间,同样有一个循环查询线程,获取到任务就执行
    • TimerQueue 就是使用 DelayQueue 实现的。 如何实现Delayed接口

DelayQueue队列的元素必须实现Delayed接口。可以参考ScheduledThreadPoolExecutorScheduledFutureTask类的实现,步骤如下:

  1. 在对象创建的对象,初始化基本数据。使用time记录当前对象延迟到什么时候可以使用,使用sequenceNumber来标识元素在队列中的先后顺序。代码如下:

    private static final AtomicLong sequencer = new AtomicLong();
    
    ScheduledFutureTask(Runnable r, V result, long ns) {
        super(r, result);
        this.time = ns;
        this.period = 0;
        this.sequenceNumber = sequencer.getAndIncrement();
    }
    
  2. 实现getDelay()方法,该方法返回当前元素还需要延长多长时间,单位是纳秒。代码如下:

     public long getDelay(TimeUnit unit) {
         return unit.convert(time - now(), NANOSECONDS);
     }
    

    通过构造函数可以看出延迟时间参数ns的单位是纳秒,自己设计的时候最好使用纳秒,实现getDelay()方法时可以指定任意单位,一旦以秒或分作为单位,而延时时间精确不到纳秒就麻烦了。使用时注意当time小于当前时间时,getDelay会返回负数。

  3. 实现compareTo()方法来指定元素的顺序。例如,让延时时间最长的放在队列的末尾。代码如下

    public int compareTo(Delayed other) {
           if (other == this) // compare zero if same object
               return 0;
           if (other instanceof ScheduledFutureTask) {
               ScheduledFutureTask<?> x = (ScheduledFutureTask<?>)other;
               long diff = time - x.time;
               if (diff < 0)
                   return -1;
               else if (diff > 0)
                   return 1;
               else if (sequenceNumber < x.sequenceNumber)
                   return -1;
               else
                   return 1;
           }
           long diff = getDelay(NANOSECONDS) - other.getDelay(NANOSECONDS);
           return (diff < 0) ? -1 : (diff > 0) ? 1 : 0;
    }
    

如何实现延时阻塞队列

延时阻塞队列的实现很简单,当消费者从队列里获取元素时,如果元素没有达到延时时间,就阻塞当前线程。

private Thread leader = null;
   
private final Condition available = lock.newCondition();

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            E first = q.peek();
            if (first == null)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0)
                    return q.poll();
                first = null; // don't retain ref while waiting
                if (leader != null)
                    available.await();
                else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}
  1. SynchronousQueue: 一个不存储元素的阻塞队列,每一个put操作必须等待take操作,否则不能添加元素。支持公平锁和非公平锁。SynchronousQueue的一个使用场景是在线程池里。Executors.newCachedThreadPool()就使用了SynchronousQueue,这个线程池根据需要(新任务到来时)创建新的线程,如果有空闲线程则会重复使用,线程空闲了60秒后会被回收。
public SynchronousQueue() {
    this(false);
}

public SynchronousQueue(boolean fair) {
    transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}

SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身不存储任何元素,非常适合传递性场景。SynchronousQueue的吞吐量高于LinkedBlockingQueueArrayBlockingQueue

  1. LinkedTransferQueue: 一个由链表结构组成的无界阻塞队列,相当于其它队列,LinkedTransferQueue队列多了transfer和tryTransfer方法。
  • transfer方法

    如果当前有消费者正在等待接收元素(消费者使用take()方法或带时间限制的poll()方法时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者了才返回。

  • tryTransfer方法

    tryTransfer方法时用来试探生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回fasle。和transfer方法的区别是tryTransfer方法无论消费者是否接收,方法立即返回,而transfer方法是必须等到消费者消费了才返回。

7.LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。相比其他的阻塞队列,LinkedBlockingDeque多了addFirstaddLastofferFirstofferLastpeekFirstpeekLast等方法,以First单词结尾的方法,表示插入、获取(peek)或移除双端队列的第一个元素。以Last单词结尾的方法,表示插入、获取或移除双向队列的最后一个元素。

4、7种阻塞队列的特点

1.ArrayBlockingQueue

  • 环形数组实现的、有界的队列,一旦创建后,容量不可变
  • 基于数组,在添加删除上性能还是不如链表 2.LinkedBlockingQueue:
  • 基于链表、有界阻塞队列
  • 添加和获取是两个不同的锁,所以并发添加/获取效率更高些
  • Executors.newFixedThreadPool() 使用了这个队列 3.PriorityBlockingQueue
  • 基于数组的、支持优先级的、无界阻塞队列
  • 使用自然排序或者定制排序指定排序规则
  • 添加元素时,当数组中元素大于等于容量时,会扩容(当前队列中元素个数小于 64 个,数组容量就乘 3;否则就乘 2 加 2),拷贝数组 4.DelayQueue
  • 支持延时获取元素的、无界阻塞队列
  • 添加元素时如果超出限制也会扩容
  • Leader-Follower 模型 5.SynchronousQueue
  • 容量为 0
  • 一个添加操作后必须等待一个获取操作才可以继续添加
  • 吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue 6.LinkedTransferQueue
  • 由链表组成的、无界阻塞队列
  • 实现了 TransferQueue 接口
  • CPU 自旋等待消费者取走元素,自旋一定次数后结束 7.LinkedBlockingDeque
  • 由双向链表组成的、双向阻塞队列
  • 可以从队列两端插入和移除元素
  • 多了一个操作队列的方向,在多线程同时入队时,可以减少一半的竞争

5、阻塞队列的基本使用

使用阻塞队列实现生产者-消费者模式:

/**
 * Created by qzwb on 2021/7/31.
 */
public class BlockingQueueTest {

    public static void main (String[] args) {
        ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(10);

        Consumer consumer = new Consumer(queue);
        Producer producer = new Producer(queue);

        producer.start();
        consumer.start();
    }
}

class Consumer extends Thread {
    private ArrayBlockingQueue<Integer> queue;
    public Consumer(ArrayBlockingQueue<Integer> queue){
        this.queue = queue;
    }
    @Override
    public void run() {
        while(true) {
            try {
                Integer i = queue.take();
                System.out.println("消费者从队列取出元素:" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class Producer extends Thread {
    private ArrayBlockingQueue<Integer> queue;
    public Producer(ArrayBlockingQueue<Integer> queue){
        this.queue = queue;
    }
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                queue.put(i);
                System.out.println("生产者向队列插入元素:" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

如果不使用阻塞队列,使用Object.wait()和Object.notify()、非阻塞队列实现生产者-消费者模式,考虑线程间的通讯,会非常麻烦。