11、队列

队列在日常工作中使用的没有集合多,但是同样特别重要,我们平时使用到的线程池、读写锁、消息队列等等技术和框架,底层原理都是队列J,队列是很多高级 API 的基础,学好队列,对自己深入 Java 学习非常重要。AVA中常用的队列有LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、DelayQueue,下面简单介绍下这四种队列各种的特别以及应用场景。本文只做队列的入门级学习,不具体研究源码。

Queue接口

Queue接口是队列最基础的接口,基本所有的队列实现类都会实现这个接口,该接口定义了队列的三大类操作:

  1. 新增操作
    • add方法,当队列满了的时候抛出异常;
    • offer方法,当队列满了的时候返回false;
  2. 查看并删除操作
    • remove方法,当队列为空的时候抛出异常;
    • poll方法,当队列为空的时候返回false;
  3. 只查看不删除操作
    • element方法,当队列为空的时候抛出异常;
    • peek方法,当队列为空的时候返回false;

BlockingQueue接口

BlockingQueue在Queue的基础上增加了阻塞的概念,可以一直阻塞或阻塞一段时间,Queue的常用方法如下:

抛异常特殊值一直阻塞阻塞一段时间
新增操作–队列满addoffer 返回 falseputoffer 过超时时间返回 false
查看并删除操作–队列空removepoll 返回 nulltakepoll 过超时时间返回 null
只查看不删除操作–队列空elementpeek 返回 null暂无暂无

PS: remove 方法,BlockingQueue 类注释中定义的是抛异常,但 LinkedBlockingQueue 中 remove 方法实际是返回 false。

LinkedBlockingQueue

LinkedBlockingQueue中文叫做链表阻塞队列,从命名上就可以知道其底层数据结构是链表,并且是可阻塞的队列,架构图如下(IDEA可以使用Ctrl+Alt+U查看类的架构图):

在这里插入图片描述

从架构图中可以得知,LinkedBlockingQueue继承了AbstractCollection,因此拥有集合的一些功能,实现了Iterable,所以可以使用迭代器遍历,并且实现了BlockingQueue接口。

从LinkedBlockingQueue的类注释上可以得知:

  1. 是基于链表的阻塞队列,其底层数据结构是链表;
  2. 链表维护先入先出队列,新元素被放到队尾,从队头获取元素;
  3. 链表的大小在初始化的时候可以设置,并且大小一旦指定就不能被修改,默认为Integer的最大值;
  4. 实现了Collection和Iterator 接口,可以使用 两个接口的所有操作;
源码解析

LinkedBlockingQueue 内部构成简单来说,分成三个部分:链表存储 + 锁 + 迭代器,源码如下:

// 链表结构 begin
//链表的元素
static class Node<E> {
    E item;

    //当前元素的下一个,为空表示当前节点是最后一个
    Node<E> next;

    Node(E x) { item = x; }
}

//链表的容量,默认 Integer.MAX_VALUE
private final int capacity;

//链表已有元素大小,使用 AtomicInteger,所以是线程安全的
private final AtomicInteger count = new AtomicInteger();

//链表头
transient Node<E> head;

//链表尾
private transient Node<E> last;
// 链表结构 end

// 锁 begin
//take 时的锁
private final ReentrantLock takeLock = new ReentrantLock();

// take 的条件队列,condition 可以简单理解为基于 ASQ 同步机制建立的条件队列
private final Condition notEmpty = takeLock.newCondition();

// put 时的锁,设计两把锁的目的,主要为了 take 和 put 可以同时进行
private final ReentrantLock putLock = new ReentrantLock();

// put 的条件队列
private final Condition notFull = putLock.newCondition();
// 锁 end

// 迭代器 
// 实现了自己的迭代器
private class Itr implements Iterator<E> {
	………………
}

从源码可以看出,结构是非常清晰的,三种结构各司其职:

  1. 链表的作用是为了保存当前节点,节点中的数据可以是任意东西,是一个泛型,比如说队列被应用到线程池时,节点就是线程,比如队列被应用到消息队列中,节点就是消息,节点的含义主要看队列被使用的场景;
  2. 锁有 take 锁和 put 锁,是为了保证队列操作时的线程安全,设计两种锁,是为了 take 和 put 两种操作可以同时进行,互不影响;
初始化

初始化有三种方式:

  1. 指定容量初始化;
  2. 不指定容量初始化,容量大小为Integer的最大值;
  3. 指定集合初始化,容量大小为集合的大小;

源码如下:

// 不指定容量,默认 Integer 的最大值
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}
// 指定链表容量大小,链表头尾相等,节点值(item)都是 null
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

// 已有集合数据进行初始化
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();
            // capacity 代表链表的大小,在这里是 Integer 的最大值
            // 如果集合类的大小大于 Integer 的最大值,就会报错
            // 其实这个判断完全可以放在 for 循环外面,这样可以减少 Integer 的最大值次循环(最坏情况)
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node<E>(e));
            ++n;
        }
        count.set(n);
    } finally {
        putLock.unlock();
    }
}

从源码可以得知:

  1. 初始化时会创建一个值为null的节点,队头head和队尾last都指向这个节点;
  2. 初始化时,容量大小是不会影响性能的,只影响在后面的使用,因为初始化队列太小,容易导致没有放多少就会报队列已满的错误;
阻塞新增

LinkedBlockingQueue新增元素的方法有add、offer、put,三者的区别上文有说,主要看下put方法的逻辑,源码如下:

// 把e新增到队列的尾部。
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
    // e 为空,抛出异常
    if (e == null) throw new NullPointerException();
    // 预先设置 c 为 -1,约定负数为新增失败
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    // 设置可中断锁
    putLock.lockInterruptibly();
    try {
        // 队列满了
        // 当前线程阻塞,等待其他线程的唤醒(其他线程 take 成功后就会唤醒此处被阻塞的线程)
        while (count.get() == capacity) {
            // await 无限等待
            notFull.await();
        }

        // 队列没有满,直接新增到队列的尾部
        enqueue(node);

        // 新增计数赋值,注意这里 getAndIncrement 返回的是旧值
        // 这里的 c 是比真实的 count 小 1 的
        c = count.getAndIncrement();

        // 如果链表现在的大小 小于链表的容量,说明队列未满
        // 可以尝试唤醒一个 put 的等待线程
        if (c + 1 < capacity)
            notFull.signal();

    } finally {
        // 释放锁
        putLock.unlock();
    }
    // c==0,代表队列里面有一个元素
    // 会尝试唤醒一个take的等待线程
    if (c == 0)
        signalNotEmpty();
}
// 入队,把新元素放到队尾
private void enqueue(Node<E> node) {
    last = last.next = node;
}

从源码中可以总结以下几点:

  1. put的数据不能是null,否则会抛出异常;
  2. 队列新增数据,第一步是上锁,所以新增数据是线程安全的;
  3. 新增时,如果队列满了,当前线程是会被阻塞,阻塞的底层实现使用的是锁,并使用Condition来实现锁的同步和通信;
  4. 新增的数据放入到队尾;
  5. 新增数据成功后,在适当时机,会唤起 put 的等待线程(队列不满时),或者 take 的等待线程(队列不为空时),这样保证队列一旦满足 put 或者 take 条件时,立马就能唤起阻塞线程,继续运行,保证了唤起的时机不被浪费;
阻塞删除

LinkedBlockingQueue删除元素的方法有remove、poll、take,主要看下是如何实现阻塞删除的,源码如下:

// 阻塞拿数据
public E take() throws InterruptedException {
    E x;
    // 默认负数,代表失败
    int c = -1;
    // count 代表当前链表数据的真实大小
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        // 空队列时,阻塞,等待其他线程唤醒
        while (count.get() == 0) {
            notEmpty.await();
        }
        // 非空队列,从队列的头部拿一个出来
        x = dequeue();
        // 减一计算,注意 getAndDecrement 返回的值是旧值
        // c 比真实的 count 大1
        c = count.getAndDecrement();
        
        // 如果队列里面有值,从 take 的等待线程里面唤醒一个。
        // 意思是队列里面有值啦,唤醒之前被阻塞的线程
        if (c > 1)
            notEmpty.signal();
    } finally {
        // 释放锁
        takeLock.unlock();
    }
    // 如果队列空闲还剩下一个,尝试从 put 的等待线程中唤醒一个
    if (c == capacity)
        signalNotFull();
    return x;
}
// 队头中取数据
private E dequeue() {
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;// 头节点指向 null,删除
    return x;
}

整体流程和 put 很相似,都是先上锁,然后从队列的头部拿出数据,如果队列为空,会一直阻塞到队列有值为止。dequeue方法的逻辑稍微有点绕,可以在纸上画一下head和last的指向。

查看不删除元素

LinkedBlockingQueue查看不删除元素的方法有element、peek,以peek方法为例,源码如下:

// 查看并不删除元素,如果队列为空,返回 null
public E peek() {
    // count 代表队列实际大小,队列为空,直接返回 null
    if (count.get() == 0)
        return null;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        // 拿到队列头
        Node<E> first = head.next;
        // 判断队列头是否为空,并返回
        if (first == null)
            return null;
        else
            return first.item;
    } finally {
        takeLock.unlock();
    }
}

可以看到,peek方法中没有阻塞,并且不会改变队头head和队尾last的指向。

ArrayBlockingQueue

ArrayBlockingQueue同LinkedBlockingQueue一样,也是阻塞队列,因此也具有阻塞的功能,下面具体来了解下ArrayBlockingQueue的底层架构以及特性。

ArrayBlockingQueue中文叫做数组阻塞队列,从命名上就可以知道其底层数据结构是数组,并且是可阻塞的队列,架构图如下:

在这里插入图片描述

ArrayBlockingQueue的架构跟LinkedBlockingQueue是一样的,都继承了AbstractQueue,实现了BlockingQueue和Collection接口,功能跟LinkedBlockingQueue差不多。

源码解析

部分源码如下:

// 队列存放在 object 的数组里面
// 数组大小必须在初始化的时候手动设置,没有默认大小
final Object[] items;

// 下次拿数据的时候的索引位置
int takeIndex;

// 下次放数据的索引位置
int putIndex;

// 当前已有元素的大小
int count;

// 可重入的锁
final ReentrantLock lock;

// take的队列
private final Condition notEmpty;

// put的队列
private final Condition notFull;

ArrayBlockingQueue有以下特性:

  1. 元素是有顺序的,按照先入先出进行排序,从队尾插入数据数据,从队头拿数据;
  2. 队列满时,往队列中 put 数据会被阻塞,队列空时,往队列中拿数据也会被阻塞;
  3. 由于put和take共用一个锁;
  4. takeIndex 和 putIndex,分别表示下次拿数据和放数据的索引位置,所以在新增数据和拿数据时,都无需计算,就能知道应该新增到什么位置,应该从什么位置拿数据;
初始化

初始化时,有两个重要的参数,数组的大小、是否是公平,源码如下:

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);
    // 队列不为空 Condition,在 put 成功时使用
    notEmpty = lock.newCondition();
    // 队列不满 Condition,在 take 成功时使用
    notFull =  lock.newCondition();
}

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) {
        checkNotNull(e);
        items[i++] = e;
      }
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException();
    }
    count = i;
    putIndex = (i == capacity) ? 0 : i;
  } finally {
    lock.unlock();
  }
}

从源码中可以看出:

  1. 第二个参数是否公平,主要用于读写锁是否公平,如果是公平锁,那么在锁竞争时,就会按照先来先到的顺序,如果是非公平锁,锁竞争时随机的。对于锁公平和非公平,我们举个例子:比如说现在队列是满的,还有很多线程执行put操作,必然会有很多线程阻塞等待,当有其它线程执行take时,会唤醒等待的线程,如果是公平锁,会按照阻塞等待的先后顺序,依次唤醒阻塞的线程,如果是非公平锁,会随机唤醒沉睡的线程。所以说队列满很多线程执行put操作时,如果是公平锁,数组元素新增的顺序就是阻塞线程被释放的先后顺序,是有顺序的,而非公平锁,由于阻塞线程被释放的顺序是随机的,所以元素插入到数组的顺序也就不会按照插入的顺序了。ArrayBlockingQueue 通过锁的公平和非公平,轻松实现了数组元素的插入顺序的问题
  2. 默认是使用的非公平锁,因为非公平锁的性能更高;
  3. 初始化时,如果指定了容量和初始化集合,集合中元素不能为null,并且当集合的大小大于指定容量时会抛出异常,比如capacity等于10,而集合中的元素数量为15,那么只会将集合中的前十个元素放入到队列中,并且会抛出异常;
新增数据

数据新增都会按照 putIndex 的位置进行新增,源码如下:

// 新增,如果队列满,无限阻塞
public void put(E e) throws InterruptedException {
    // 元素不能为空
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 队列如果是满的,就无限等待
        // 一直等待队列中有数据被拿走时,自己被唤醒
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

private void enqueue(E x) {
    // assert lock.getHoldCount() == 1; 同一时刻只能一个线程进行操作此方法
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    // putIndex 为本次插入的位置
    items[putIndex] = x;
    // ++ putIndex 计算下次插入的位置
    // 如果下次插入的位置,正好等于队尾,下次插入就从 0 开始
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    // 唤醒因为队列空导致的等待线程
    notEmpty.signal();
}

从源码中,我们可以看出,其实新增就两种情况:

  1. 次新增的位置居中,直接新增,下图演示的是 putIndex 在数组下标为 5 的位置,还不到队尾,那么可以直接新增,计算下次新增的位置应该是 6;

在这里插入图片描述

  1. 新增的位置到队尾了,那么下次新增时就要从头开始了,示意图如下:

在这里插入图片描述

上面这张图演示的就是这行代码:if (++putIndex == items.length) putIndex = 0;

可以看到当新增到队尾时,下次新增会重新从队头重新开始。

拿数据

拿数据都是从队头开始拿数据,源码如下:

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 如果队列为空,无限等待
        // 直到队列中有数据被 put 后,自己被唤醒
        while (count == 0)
            notEmpty.await();
        // 从队列中拿数据
        return dequeue();
    } finally {
        lock.unlock();
    }
}

private E dequeue() {
    final Object[] items = this.items;
    // takeIndex 代表本次拿数据的位置,是上一次拿数据时计算好的
    E x = (E) items[takeIndex];
    // 帮助 gc
    items[takeIndex] = null;
    // ++ takeIndex 计算下次拿数据的位置
    // 如果正好等于队尾的话,下次就从 0 开始拿数据
    if (++takeIndex == items.length)
        takeIndex = 0;
    // 队列实际大小减 1
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    // 唤醒被队列满所阻塞的线程
    notFull.signal();
    return x;
}

从源码中可以看出,每次拿数据的位置就是 takeIndex 的位置,在找到本次该拿的数据之后,会把 takeIndex 加 1,计算下次拿数据时的索引位置,有个特殊情况是,如果本次拿数据的位置已经是队尾了,那么下次拿数据的位置就要从头开始,就是从 0 开始了。

删除数据

删除数据的源码如下:

// 一共有两种情况:
// 1:删除位置和 takeIndex 的关系:删除位置和 takeIndex 一样,比如 takeIndex 是 2, 而要删除的位置正好也是 2,那么就把位置 2 的数据置为 null ,并重新计算 takeIndex 为 3。
// 2:找到要删除元素的下一个,计算删除元素和 putIndex 的关系
// 如果下一个元素不是 putIndex,就把下一个元素往前移动一位
// 如果下一个元素是 putIndex,把 putIndex 的值修改成删除的位置
void removeAt(final int removeIndex) {
    final Object[] items = this.items;
    // 情况1 如果删除位置正好等于下次要拿数据的位置
    if (removeIndex == takeIndex) {
        // 下次要拿数据的位置直接置空
        items[takeIndex] = null;
        // 要拿数据的位置往后移动一位
        if (++takeIndex == items.length)
            takeIndex = 0;
        // 当前数组的大小减一
        count--;
        if (itrs != null)
            itrs.elementDequeued();
    // 情况 2
    } else {
        final int putIndex = this.putIndex;
        for (int i = removeIndex;;) {
            // 找到要删除元素的下一个
            int next = i + 1;
            if (next == items.length)
                next = 0;
            // 下一个元素不是 putIndex
            if (next != putIndex) {
                // 下一个元素往前移动一位
                items[i] = items[next];
                i = next;
            // 下一个元素是 putIndex
            } else {
                // 删除元素
                items[i] = null;
                // 下次放元素时,应该从本次删除的元素放
                this.putIndex = i;
                break;
            }
        }
        count--;
        if (itrs != null)
            itrs.removedAt(removeIndex);
    }
    notFull.signal();
}

删除数据的情况比较复杂,从源码可以得知,删除一共分为两种情况:

  1. 第一种情况是 takeIndex == removeIndex,此时会将位置 takeIndex 的数据置为 null ,并重新计算 takeIndex 为 takeIndex + 1;
  2. 第二中情况又分两种:
    • 如果 removeIndex + 1 != putIndex 的话,就依次把removeIndex到putIndex之间的元素往前移动一位,然后把putIndex前一位的值设置为null,putIndex为putIndex-1,注意原来putIndex位置的值就为null,所以不需要再设置;
    • 如果 removeIndex + 1 == putIndex 的话,就把 putIndex 的值修改成removeIndex,并且把removeIndex 处的值设置成null;

LinkedBlockingQueue与ArrayBlockingQueue的对比

  1. LinkedBlockingQueue底层数据结构是链表,而ArrayBlockingQueue底层数据结构是数组;
  2. ArrayBlockingQueue可以指定使用公平锁还是非公平锁,而LinkedBlockingQueue没提供对应的构造方法;
  3. LinkedBlockingQueue通过头尾节点的指向来新增或获取队列中的元素,而ArrayBlockingQueue是通过数组的下标来实现的;
  4. 性能方法,暂无比较数据;

SynchronousQueue

SynchronousQueue 是比较独特的队列,其本身是没有容量大小,比如我放一个数据到队列中,我是不能够立马返回的,我必须等待别人把我放进去的数据消费掉了,才能够返回。SynchronousQueue 在消息队列技术中间件中被大量使用。

SynchronousQueue的特性如下:

  1. 队列不存储数据,所以没有大小,无法迭代;
  2. 插入操作的返回必须等待另一个线程完成对应的删除操作,反之亦然;
  3. 队列由两种数据结构组成,分别是后入先出的堆栈和先入先出的队列,堆栈是非公平的,队列是公平的;

SynchronousQueue的架构图如下:

在这里插入图片描述

从架构图可以得知,SynchronousQueue也实现了BlockingQueue接口,所以也具有阻塞功能,虽然SynchronousQueue实现了Collection、Iterable接口,但因为其不储存数据结构,有一些方法是没有实现的,比如说 isEmpty、size、contains、remove 和迭代等方法,这些方法都是默认实现,如下图所示:

在这里插入图片描述

源码解析

SynchronousQueue部分源码如下:

 // 堆栈和队列共同的接口
    // 负责执行 put or take
    abstract static class Transferer<E> {
        // e 为空的,会直接返回特殊值,不为空会传递给消费者
        // timed 为 true,说明会有超时时间
        abstract E transfer(E e, boolean timed, long nanos);
    }

    // 堆栈 后入先出 非公平
    // Scherer-Scott 算法
    static final class TransferStack<E> extends Transferer<E> {
    }

    // 队列 先入先出 公平
    static final class TransferQueue<E> extends Transferer<E> {
    }

    private transient volatile Transferer<E> transferer;

    // 无参构造器默认为非公平的
    public SynchronousQueue(boolean fair) {
        transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
    }

从源码中可以得知已下几点:

  1. 堆栈和队列都有一个共同的接口,叫做 Transferer,该接口有个方法:transfer,该方法很神奇,会承担 take 和 put 的双重功能;
  2. 在初始化的时候,是可以选择是使用堆栈还是队列的,如果不选择,默认的就是堆栈,堆栈的效率比队列更高;
非公平的堆栈

首先来看下堆栈的整体结构,如下:

在这里插入图片描述

从上图中我们可以看到,我们有一个大的堆栈池,池的开口叫做堆栈头,put 的时候,就往堆栈池中放数据。take 的时候,就从堆栈池中拿数据,两者操作都是在堆栈头上操作数据,从图中可以看到,越靠近堆栈头,数据越新,所以每次 take 的时候,都会拿到堆栈头的最新数据,这就是我们说的后入先出,也就是非公平的。

公平的队列

公平主要体现在,每次 put 数据的时候,都 put 到队尾上,而每次拿数据时,并不是直接从队头拿数据,而是从队尾往前寻找第一个被阻塞的线程,这样就会按照顺序释放被阻塞的线程。

SynchronousQueue的源码比较复杂,暂不做底层讨论。

DelayQueue

前面的三种队列都是阻塞队列,在资源足够时都是立马执行,而DelayQueue队列比较特殊,是一种延迟队列,意思是延迟执行,并且可以设置延迟多久之后执行,比如设置过 5 秒钟之后再执行,在一些延迟执行的场景被大量使用,比如说延迟对账等等。

DelayQueue 延迟队列底层使用的是锁的能力,比如说要在当前时间往后延迟 5 秒执行,那么当前线程就会沉睡 5 秒,等 5 秒后线程被唤醒时,如果能获取到资源的话,线程即可立马执行。

DelayQueue的特性如下:

  1. 队列中的元素将在过期时被执行,越靠近队头的元素越早过期;
  2. 未过期的元素不能被take;
  3. 不允许空元素;

DelayQueue的架构图如下:

在这里插入图片描述

可以看到DelayQueue的架构跟其他的三种队列是差不多的,只不过是多实现了Delayed接口,DelayQueue类上是有泛型的,类定义源码如下:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
    implements BlockingQueue<E> {

从泛型中可以看出,DelayQueue 中的元素必须是 Delayed 的子类,Delayed 是表达延迟能力的关键接口,其继承了 Comparable 接口,并定义了还剩多久过期的方法,如下:

public interface Delayed extends Comparable<Delayed> {
    long getDelay(TimeUnit unit);
}

也就是说 DelayQueue 队列中的元素必须是实现 Delayed 接口和 Comparable 接口的,并覆写了 getDelay 方法和 compareTo 的方法才行,不然在编译时,编译器就会提醒我们元素必须强制实现 Delayed 接口。

使用方式

DelayQueue 的使用方式案例如下:

public class DelayQueueDemo {
	// 队列消息的生产者
  static class Product implements Runnable {
    private final BlockingQueue queue;
    public Product(BlockingQueue queue) {
      this.queue = queue;
    }
    
    @Override
    public void run() {
      try {
        log.info("begin put");
        long beginTime = System.currentTimeMillis();
        queue.put(new DelayedDTO(System.currentTimeMillis() + 2000L,beginTime));//延迟 2 秒执行
        queue.put(new DelayedDTO(System.currentTimeMillis() + 5000L,beginTime));//延迟 5 秒执行
        queue.put(new DelayedDTO(System.currentTimeMillis() + 1000L * 10,beginTime));//延迟 10 秒执行
        log.info("end put");
      } catch (InterruptedException e) {
        log.error("" + e);
      }
    }
  }
	// 队列的消费者
  static class Consumer implements Runnable {
    private final BlockingQueue queue;
    public Consumer(BlockingQueue queue) {
      this.queue = queue;
    }

    @Override
    public void run() {
      try {
        log.info("Consumer begin");
        ((DelayedDTO) queue.take()).run();
        ((DelayedDTO) queue.take()).run();
        ((DelayedDTO) queue.take()).run();
        log.info("Consumer end");
      } catch (InterruptedException e) {
        log.error("" + e);
      }
    }
  }

  @Data
  // 队列元素,实现了 Delayed 接口
  static class DelayedDTO implements Delayed {
    Long s;
    Long beginTime;
    public DelayedDTO(Long s,Long beginTime) {
      this.s = s;
      this.beginTime =beginTime;
    }

    // 元素的过期策略
    @Override
    public long getDelay(TimeUnit unit) {
      return unit.convert(s - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    // 队列中的元素的排序策略,快过期的元素越靠近队头
    @Override
    public int compareTo(Delayed o) {
      return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
    }

    public void run(){
      log.info("现在已经过了{}秒钟",(System.currentTimeMillis() - beginTime)/1000);
    }
  }
	// demo 运行入口
  public static void main(String[] args) throws InterruptedException {
    BlockingQueue q = new DelayQueue();
    DelayQueueDemo.Product p = new DelayQueueDemo.Product(q);
    DelayQueueDemo.Consumer c = new DelayQueueDemo.Consumer(q);
    new Thread(c).start();
    new Thread(p).start();
  }
}
打印出来的结果如下:
06:57:50.544 [Thread-0] Consumer begin
06:57:50.544 [Thread-1] begin put
06:57:50.551 [Thread-1] end put
06:57:52.554 [Thread-0] 延迟了2秒钟才执行
06:57:55.555 [Thread-0] 延迟了5秒钟才执行
06:58:00.555 [Thread-0] 延迟了10秒钟才执行
06:58:00.556 [Thread-0] Consumer end

队列应用的场景

我们学习了 LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、DelayQueue 四种队列,四种队列底层数据结构各不相同,使用场景也不相同,下面对各种四种队列的使用场景进行分析。

LinkedBlockingQueue

适合对生产的数据大小不定(时高时低),数据量较大的场景,比如说我们在淘宝上买东西,点击下单按钮时,对应着后台的系统叫做下单系统,下单系统会把下单请求都放到一个线程池里面,这时候我们初始化线程池时,一般会选择 LinkedBlockingQueue,并且设置一个合适的大小,此时选择 LinkedBlockingQueue 主要原因在于:在不高于我们设定的阈值内,队列里面的大小可大可小,不会有任何性能损耗,正好符合下单流量的特点,时大时小。

一般工作中,大多数都会选择 LinkedBlockingQueue 队列,但会设置 LinkedBlockingQueue 的最大容量,如果初始化时直接使用默认的 Integer 的最大值,当流量很大,而消费者处理能力很差时,大量请求都会在队列中堆积,会大量消耗机器的内存,就会降低机器整体性能甚至引起宕机,一旦宕机,在队列中的数据都会消失,因为队列的数据是保存在内存中的,一旦机器宕机,内存中的数据都会消失的,所以使用 LinkedBlockingQueue 队列时,建议还是要根据日常的流量设置合适的队列的大小。

ArrayBlockingQueue

一般用于生产数据固定的场景,比如说系统每天会进行对账,对账完成之后,会固定的产生 100 条对账结果,因为对账结果固定,我们就可以使用 ArrayBlockingQueue 队列,大小可以设置成 100。

DelayQueue

主要用于任务不想立马执行,想等待一段时间才执行的场景,比如延迟对账。

我们在工作中曾经遇到过这样的场景:我们在淘宝上买东西,弹出支付宝付款页面,在我们输入指纹的瞬间,流程主要是前端 -》交易后端 -》支付后端,交易后端调用支付后端主要是为了把我们支付宝的钱划给商家,而交易调用支付的过程中,有小概率的情况,因为网络抖动会发生超时的情况,这时候就需要通过及时的对账来解决这个事情(对账只是解决这个问题的手段之一),流程图如下:

在这里插入图片描述

面试题

1、说说对队列的理解,队列与集合的区别

对队列的理解:

  1. 队列本身也是个容器,部分队列也可以存储数据,底层也是通过使用不同的数据结构实现各自的功能,比如LinkedBlockingQueue底层的数据结构是链表,通过链表维持先入先出的顺序;
  2. 队列把数据生产者和数据消费者进行了解耦,生产者只管将生产的数据放入队列中,消费者只需要从队列中获取数据,两者之间没有必然的联系,队列就像生产者和消费者之间的数据通道一样
  3. 队列还可以对消费者和生产者进行管理,比如队列满了,有生产者还在不停投递数据时,队列可以使生产者阻塞住,让其不再能投递,比如队列空时,有消费者过来拿数据时,队列可以让消费者 hodler 住,等有数据时,唤醒消费者,让消费者拿数据返回;

队列和集合的区别:

  1. 和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层使用的也都是基本的数据结构,比如说 LinkedBlockingQueue 和 LinkedHashMap 底层都使用的是链表,ArrayBlockingQueue 和 ArrayList 底层使用的都是数组。

  2. 和集合的区别:

    • 应用场景不同,集合只是用于存储数据,不会对生产者和消费者进行管理;而队列主要的功能是将生产者与消费者进行解耦,并且提供了阻塞的功能,能对消费者和生产者进行简单的管理,队列空时,会阻塞消费者,有其他线程进行 put 操作后,会唤醒阻塞的消费者,让消费者拿数据进行消费,队列满时亦然;

    • 解耦了生产者和消费者,队列就像是生产者和消费者之间的管道一样,生产者只管往里面丢,消费者只管不断消费,两者之间互不关心

2、队列底层是如何实现阻塞的

是通过锁+Condition实现的,利用了Condition的等待唤醒机制,当队列满了,往队列中put数据时会沉睡,当有其他线程消费了队列中的数据,会唤醒之前的put线程,反之亦然。

3、往队列里面 put 数据是线程安全的么?为什么?

是线程安全的,在 put 之前,队列会自动加锁,put 完成之后,锁会自动释放,保证了同一时刻只会有一个线程能操作队列的数据,以 LinkedBlockingQueue 为例子,put 时,会加 put 锁,并只对队尾 tail 进行操作,take 时,会加 take 锁,并只对队头 head 进行操作,remove 时,会同时加 put 和 take 锁,所以各种操作都是线程安全的。

4、队列在take 的时候也会加锁么?既然 put 和 take 都会加锁,是不是同一时间只能运行其中一个方法
  1. 是的,take 时也会加锁的,像 LinkedBlockingQueue 在执行 take 方法时,在拿数据的同时,会把当前数据删除掉,就改变了链表的数据结构,所以需要加锁来保证线程安全。
  2. 这个需要看情况而言,对于 LinkedBlockingQueue 来说,队列的 put 和 take 都会加锁,但两者的锁是不一样的,所以两者互不影响,可以同时进行的,对于 ArrayBlockingQueue 而言,put 和 take 是同一个锁,所以同一时刻只能运行一个方法,线程沉睡时会释放CPU,所以不会造成死锁。
5、工作中经常使用队列的 put、take 方法有什么危害,如何避免

队列满时,使用 put 方法,会一直阻塞到队列不满为止。当队列空时,使用 take 方法,会一直阻塞到队列有数据为止。

两个方法都是无限(永远、没有超时时间的意思)阻塞的方法,容易使得线程全部都阻塞住,大流量时,导致机器无线程可用,所以建议在流量大时,使用 offer 和 poll 方法来代替两者,我们只需要设置好超时阻塞时间,这两个方法如果在超时时间外,还没有得到数据的话,就会返回默认值(LinkedBlockingQueue 为例),这样就不会导致流量大时,所有的线程都阻塞住了。

6、把数据放入队列中后,有木有办法让队列过一会儿再执行?

可以的,DelayQueue 提供了这种机制,可以设置一段时间之后再执行,该队列有个唯一的缺点,就是数据保存在内存中,在重启和断电的时候,数据容易丢失,所以定时的时间我们都不会设置很久,一般都是几秒内,如果定时的时间需要设置很久的话,可以考虑采取延迟队列中间件(这种中间件对数据会进行持久化,不怕断电的发生)进行实现。

7、DelayQueue 对元素有什么要求么,我把 String 放到队列中去可以么?

DelayQueue 要求元素必须实现 Delayed 接口,Delayed 本身又实现了 Comparable 接口,Delayed 接口的作用是定义还剩下多久就会超时,给使用者定制超时时间的,Comparable 接口主要用于对元素之间的超时时间进行排序的,两者结合,就可以让越快过期的元素能够排在前面。

所以把 String 放到 DelayQueue 中是不行的,编译都无法通过,DelayQueue 类在定义的时候,是有泛型定义的,泛型类型必须是 Delayed 接口的子类才行。

8、DelayQueue 如何让快过期的元素先执行的?

DelayQueue 中的元素都实现 Delayed 和 Comparable 接口的,其内部会使用 Comparable 的 compareTo 方法进行排序,我们可以利用这个功能,在 compareTo 方法中实现过期时间和当前时间的差,这样越快过期的元素,计算出来的差值就会越小,就会越先被执行。

9、如果想使用固定大小的队列,有几种队列可以选择,有何不同?

可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 两种队列。

前者是链表,后者是数组,链表新增时,只要建立起新增数据和链尾数据之间的关联即可,数组新增时,需要考虑到索引的位置(takeIndex 和 putIndex 分别记录着下次拿数据、放数据的索引位置),如果增加到了数组最后一个位置,下次就要重头开始新增。

10、ArrayBlockingQueue 可以动态扩容么?用到数组最后一个位置时怎么办?

不可以的,虽然 ArrayBlockingQueue 底层是数组,但不能够动态扩容的。

假设 put 操作用到了数组的最后一个位置,那么下次 put 就需要从数组 0 的位置重新开始了。假设 take 操作用到数组的最后一个位置,那么下次 take 的时候也会从数组 0 的位置重新开始。

11、ArrayBlockingQueue take 和 put 都是怎么找到索引位置的?是利用 hash 算法计算得到的么?

ArrayBlockingQueue 有两个属性,为 takeIndex 和 putIndex,分别标识下次 take 和 put 的位置,每次 take 和 put 完成之后,都会往后加一,虽然底层是数组,但和 HashMap 不同,并不是通过 hash 算法计算得到的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值