Java 集合框架【3】Queue 和 Stack 体系

334 阅读7分钟

Stack

栈结构类型,表示对象的后进先出堆栈。Stack 继承自 Vector ,并拓展了五个允许将容器视为栈结构的操作。

包括常见的 poppush 操作、以及查看栈顶元素的方法、检查栈是否为空的方法以及从栈顶向下进行搜索某个元素,并获取该元素在栈内深度的方法。

Deque 接口及其实现提供了一组更完整的 LIFO 堆栈操作能力,应该优先考虑使用 Deque 及其实现。例如:

Deque<Integer> stack = new ArrayDeque<Integer>();

Queue

Queue 接口定义了队列的能力,它继承自 Collection ,除了基本的集合操作外,队列提供了额外的插入、获取、检查等操作。

public interface Queue<E> extends Collection<E> {
    // 在不违反容量限制的情况下立即将指定元素插入此队列,成功时返回 true,如果当前没有可用空间则抛出 IllegalStateException。
    boolean add(E e);
    // 在不违反容量限制的情况下立即将指定元素插入此队列。 在使用容量受限的队列中,一般最好使用这种方法添加,仅通过抛出异常可能无法插入元素。
    boolean offer(E e);
    // 检索并删除此队列的头部。 此方法与 poll 的不同之处仅在于如果此队列为空,它将引发异常。
    E remove();
    // 检索并移除此队列的头部,如果此队列为空,则返回 null。
    E poll();
    // 检索但不删除此队列的头部。 此方法与 peek 的不同之处仅在于如果此队列为空,它将引发异常。
    E element();
    // 检索但不删除此队列的头部,如果此队列为空,则返回 null。
    E peek();
}

可以看出,每一种操作都存在两种定义,一种是在操作失败的情况下强制抛出异常的,另一种则是返回 null 不强制抛出异常的。

Throws exceptionReturns special value
Insertadd(e)offer(e)
Removeremove()poll()
Examineelement()peek()

队列通常是先进先出的,所以对元素的排序方式也是以先进先出的顺序的,但是有特殊情况,例如,优先级队列是根据比较元素的优先级进行排序的;另一种例子是后进先出队列,即栈结构。

无论使用哪种排序,队列的头部元素都是通过调用 removepoll 删除的。在 FIFO 队列中,所有的新元素都是插入到队列的尾部的,其他类型的队列可能使用不同的插入规则,每个 Queue 的实现都必须明确指定其排序方式。

队列的另一个特性是不允许插入 null ,尽管在某些实现中,例如 LinkedList,允许存储 null。但在这种实现中,也不应该将 null 插入到队列中,因为 null 被 poll 方法作为特殊返回值,以表明队列不包含任何元素。

队列的实现通常不定义 hashCodeequals ,因为对于具有相同的元素,但具有不同的排序方式的队列,基于元素的相等并不是很好的定义方式。

Deque

Deque 继承自 Queue ,是一种支持两端元素插入和移除的顺序结构集合,简称 “双端队列” 。大多数双端队列对容量没有固定限制。

Deque 接口主要拓展了对于双端队列两端元素操作的方法,也是两种形式,一种抛出异常,一种返回特殊值 null 的形式。

image-20220514153855666.png

Deque 可以当作 FIFO 队列使用,所以它的一些拓展的方法等效于 Queue 的一些方法:

image-20220514154018814.png

Deque 作为 LIFO 队列使用时(栈结构),它也会有一些等价于 Stack 的方法:

image-20220514154101947.png

其他特性

List 接口不同,此接口不支持对元素的索引访问。

另外虽然没有严格要求 Deque 的实现禁止插入 null 元素,但在使用中的确应该尽量避免插入 null,因为 null 元素被各种操作方法作为特殊的返回值,这里和 Queue 一样。

另一个与 Queue 相同的是,它也没有定义需要实现 hashequals 方法,理由和 Queue 是一样的。

BlockingQueue

BlockingQueueQueue 接口的另一个重要子接口,它用来代表一个可阻塞的队列。支持在检索元素是,等待队列变为非空再返回数据。

BlockingQueue 有四种类型,用来解决当前不能立刻处理,需要再未来某个时间点下满足指定条件才执行的操作。

  1. 抛出异常
  2. 返回特殊值
  3. 无限期阻塞当前线程,直到操作成功
  4. 在有限时间内阻塞当前线程,超时即放弃执行

以下这张表是同一个行为在不同类型的方法表:

image-20220514155149555.png

特点

  1. 不支持空元素:BlockingQueue 不接受空元素。在尝试添加、放置或提供 null 时,实现会抛出 NullPointerExceptionnull 用作标记值以指示轮询操作失败。
  2. 容量受限:可能是容量受限的,在给定时间内,可能只有一个剩余容量,超出该容量就不能在不阻塞的情况下放置其他元素。
  3. 主要用于实现生产者-消费者队列,但还支持 Collection 接口。
  4. 线程安全需要它的实现类自身去实现。
  5. 批量操作不能保证原子性。
  6. 内存一致性:和其他并发集合一样,在一个线程中实现将一个对象存入 BlockingQueue 的操作,会发生在下一个其他线程对 BlockingQueue 中的该元素读取或移除之前。

PriorityQueue 优先级队列

PriorityQueue 是一个基于优先级堆的无界限优先级队列。它是 Queue 的直接实现类,优先级队列的元素排序有两种情况:

  1. 根据元素的自然存储顺序
  2. 队列构造时提供的 Comparator 进行比较后排序

排序方式取决于具体的构造函数的参数 Comparator

    public PriorityQueue(Comparator<? super E> comparator) {
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }

特点

  • 优先级队列不允许 null 元素,依赖于自然排序的优先级队列也不允许插入不可比较的对象,否则会导致 ClassCastException
  • 底层实现是通过数组来保存数据的:

    transient Object[] queue;
    
  • 不是线程安全的。

扩容机制

优先级队列的容量是没有限制的,但是内部存储元素的结构实际上是一个数组,它的扩容机制是有规律的。

初始化默认容量 11 ,后续扩容总是扩容到与队列元素数量相同大小,这一点和 ArrayList 基本一致。

    public boolean offer(E e) {
        if (e == null)
            throw new NullPointerException(); // 不允许 null 元素
        modCount++;
        int i = size;
        if (i >= queue.length)
            grow(i + 1); // 真正的扩容方法
        size = i + 1;
        if (i == 0)
            queue[0] = e;
        else
            siftUp(i, e);
        return true;
    }
    private void grow(int minCapacity) {
        int oldCapacity = queue.length;
        // Double size if small; else grow by 50% 默认扩容量
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                         (oldCapacity + 2) :
                                         (oldCapacity >> 1));
        // overflow-conscious code
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        queue = Arrays.copyOf(queue, newCapacity);
    }
  • 如果旧的队列容量 < 64 ,每次扩容 100% 并加 2 。超过 64,每次扩容旧容量的 50 %。
  • 如果新容量超出最大数组容量。则通过 hugeCapacity() 计算容量:
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }
  • 如果需要的容量超过数组最大容量,则限制队列容量为 Int 的最大值。
  • 如果需要的容量没超过数组最大容量,则限制队列容量为数组最大容量 MAX_ARRAY_SIZE

ArrayDeque

数组双端队列,是一种可调整大小的数组实现,没有容量限制。它会根据需要自动扩容。

继承关系

public class ArrayDeque<E> extends AbstractCollection<E> implements Deque<E>, Cloneable, Serializable
  • AbstractCollection:提供了一些集合接口能力的基本封装。
  • 双端队列
  • 可通过 Object.clone() 方法快速复制
  • 支持序列化

底层实现

底层数据结构是一个数组:

transient Object[] elements;
transient int head;
transient int tail;

通过 headtail 作为索引来支持双端队列的特性。

扩容机制

    public void addFirst(E e) {
        if (e == null)
            throw new NullPointerException(); // 不允许 null 
        elements[head = (head - 1) & (elements.length - 1)] = e; // 防止下标越界处理
        if (head == tail) // 检查空间是否够用, == 说明空间不够了
            doubleCapacity(); // 扩容
    }

这里先插入,后扩容。tail 总是指向下一个可插入的空位,这个意思就是 数组中至少留有一个空位置,所以元素可以先插入。

head = (head - 1) & (elements.length - 1) 这行代码比较难以理解。语义是:取余操作,同时解决了head 为负值的情况。 elements.length 必定是 2 的指数倍。 elements.length - 1 的二进制低位必为 1 , 与head - 1 进行与操作后,起到了取模的作用(取余数)。如果 head - 1 为负数(只可能是 -1),则相当于对其取相对于 elements.length 的补码。

真正的扩容机制在 doubleCapacity()

    private void doubleCapacity() {
        assert head == tail;
        int p = head;
        int n = elements.length;
        int r = n - p; // number of elements to the right of p
        int newCapacity = n << 1;
        if (newCapacity < 0)
            throw new IllegalStateException("Sorry, deque too big");
        Object[] a = new Object[newCapacity];
        System.arraycopy(elements, p, a, 0, r);
        System.arraycopy(elements, 0, a, r, p);
        elements = a;
        head = 0;
        tail = n;
    }

这个函数的作用相当于扩容 100% 后,将原数组,分段复制进去:

ArrayDeque_doubleCapacity.png

首先复制 head 右侧的元素,然后再把左边的复制过来。即虽然是往队列头部插值,但实际还是在尾部插完值后,分段移动进行排序,最后组成了新数组。

addLast 则是直接把新元素存入tail 位置上,然后在重新计算 tail ,检查是否需要扩容。

public void addLast(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[tail] = e;   //赋值
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)   //下标越界处理
        doubleCapacity();   //扩容
}

特点

  • 不是线程安全的,在没有外部处理同步的情况下,不支持多线程并发访问。
  • 禁止保存 null 元素。
  • 作为栈使用时,可能比 Stack 快;作为队列使用时, 比 LinkedList 快。
  • 时间复杂度时常数级别的。

总结

常见的队列除了 ArrayQueuePriorityQuueueBlockingQueue,还有一个可以当作队列的 LinkedList,关于它在上一节的 List 体系中有详细的讲解。

  • ArrayQueue,更适用于当作双端队列或栈结构。
  • Stack 已不推荐使用,建议使用 LinkedListArrayQueue 替代。
  • PriorityQueue 用来处理优先级,多了一个可自定义优先级条件的能力。
  • BlockingQueue 常用于实现 生产者/消费者队列,但线程安全需要外部自己去实现。
  • 上面的几种队列,除了 LinkedList,底层的数据结构都是数组。
  • Queue 的有些实现没有强制要求不允许存 null ,但最好不要存 null