ArrayDeque

ArrayDeque 源码分析

ArrayDeque 是环形数组实现的双端队列,刷题里的双料主力:当栈用替代老古董 Stack,当队列用替代 LinkedList。两头进出都是摊还 $O(1)$,没有链表节点开销,缓存局部性好。核心就三个字段加一对循环移动的下标函数。

代码块JAVA · 85 行收起展开
// 基于本地 JDK 源码 (D:/1ForCode/JAVA_Source, java.base, 2024 版), java.util.ArrayDeque
public class ArrayDeque<E> extends AbstractCollection<E>
                           implements Deque<E>, Cloneable, Serializable
{
    // ...

    transient Object[] elements;    // 环形数组。不放元素的格子恒为 null, 且至少留一个 null 槽(在 tail 处)

    transient int head;             // 队头元素的下标; 队列为空时 head == tail

    transient int tail;             // 下一个入队元素要放的位置; elements[tail] 恒为 null

    public ArrayDeque() {
        elements = new Object[16 + 1];      // +1 就是那个永远的空槽, 用来区分"满"和"空"
    }

    public ArrayDeque(int numElements) {
        elements =
            new Object[(numElements < 1) ? 1 :
                       (numElements == Integer.MAX_VALUE) ? Integer.MAX_VALUE :
                       numElements + 1];    // 同样 +1
    }

    // 循环 +1: 走到数组尾就绕回 0。用 if 不用取模, 因为 % 是除法指令, 比一次分支慢得多
    static final int inc(int i, int modulus) {
        if (++i >= modulus) i = 0;
        return i;
    }

    // 循环 -1: 走到 0 之前就绕回数组尾
    static final int dec(int i, int modulus) {
        if (--i < 0) i = modulus - 1;
        return i;
    }

    public void addFirst(E e) {
        if (e == null)
            throw new NullPointerException();   // null 禁入, 原因见下面串讲
        final Object[] es = elements;
        es[head = dec(head, es.length)] = e;    // head 往左退一格, 放进去
        if (head == tail)
            grow(1);                            // 追尾了说明唯一的空槽被吃掉, 立刻扩容
    }

    public void addLast(E e) {
        if (e == null)
            throw new NullPointerException();
        final Object[] es = elements;
        es[tail] = e;                           // tail 本来就指着空槽, 直接放
        if (head == (tail = inc(tail, es.length)))
            grow(1);
    }

    public E pollFirst() {
        final Object[] es;
        final int h;
        E e = elementAt(es = elements, h = head);
        if (e != null) {                        // e == null 就是队列空, 直接返回 null
            es[h] = null;                       // 清引用, 帮 GC
            head = inc(h, es.length);
        }
        return e;
    }

    public E pollLast() {
        final Object[] es;
        final int t;
        E e = elementAt(es = elements, t = dec(tail, es.length));
        if (e != null)
            es[tail = t] = null;
        return e;
    }

    public E peekFirst() {
        return elementAt(elements, head);       // 空则拿到 null, 不抛异常
    }

    // 迭代器取元素用的加强版: 撞到 null 说明遍历途中队列被改了
    static final <E> E nonNullElementAt(Object[] es, int i) {
        @SuppressWarnings("unchecked") E e = (E) es[i];
        if (e == null)
            throw new ConcurrentModificationException();
        return e;
    }
}

扩容是唯一带点体操的地方,难点是环形数组的内容可能”绕圈”分成两段:

代码块JAVA · 21 行收起展开
// 基于本地 JDK 源码 (D:/1ForCode/JAVA_Source, java.base, 2024 版), java.util.ArrayDeque
    private void grow(int needed) {
        final int oldCapacity = elements.length;
        int newCapacity;
        // 小数组翻倍(+2), 大数组加 50%, 和 ArrayList 的 1.5 倍同一思路
        int jump = (oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1);
        if (jump < needed
            || (newCapacity = (oldCapacity + jump)) - MAX_ARRAY_SIZE > 0)
            newCapacity = newCapacity(needed, jump);    // 边界与溢出处理
        final Object[] es = elements = Arrays.copyOf(elements, newCapacity);
        // 特殊情况: 刚好在 grow 里 tail == head 需要靠 es[head] != null 消歧
        if (tail < head || (tail == head && es[head] != null)) {
            // 内容绕圈了: [head..旧末尾] 这一段要平移到新数组的末尾去
            int newSpace = newCapacity - oldCapacity;
            System.arraycopy(es, head,
                             es, head + newSpace,
                             oldCapacity - head);
            for (int i = head, to = (head += newSpace); i < to; i++)
                es[i] = null;                           // 旧位置清掉
        }
    }

原理串讲

环形数组的全部机关在”永远留一个空槽”。head == tail 这一个条件在朴素实现里有歧义,可能是空也可能是满。
ArrayDeque 的解法是构造时容量 +1,并保证 elements[tail] 恒为 null:只要队列非满,head 追不上 tail
addFirst/addLast 放完元素后一旦发现 head <mark> tail,说明那个空槽刚被吃掉,立刻 grow,于是对外任何时刻 head </mark> tail 都只有”空”这一种含义。判空、判满、区分环绕,全靠这一个哨兵槽。

null 禁入的根源在 pollFirst 的返回值协议:队列空时返回 null。如果允许存 null 元素,“取到 null”就分不清是”队列空了”还是”取到一个 null 元素”,if (e != null) 这类判断全部失效。
这和 HashSet 里 PRESENT 不能用 null 是同一个道理:null 已经被征用为信号,就不能再当数据
刷题时 while (!stack.isEmpty()) 或者 while ((cur = queue.poll()) != null) 两种写法都行,但往里塞 null 会直接 NPE。

代码块JAVA · 2 行收起展开
为什么当栈用比 Stack 好:Stack 继承 Vector,每个方法都挂着 synchronized,单线程刷题白付锁开销;而且 Vector 的 `get(i)`、`insertElementAt` 全部漏进 Stack 接口,栈语义根本关不住。
为什么当队列用通常比 LinkedList 好:LinkedList 每个元素一个 Node 对象(自身引用 + prev + next 三个指针的内存),遍历时内存跳跃;ArrayDeque 连续存储,一次缓存行装好几个元素。

LinkedList 唯一赢的场景是需要在中间增删或者要存 null。

inc/dec 用 if 分支代替取模也值得看一眼:% 编译成除法指令要几十个周期,分支预测命中的 if 接近免费。
JDK 8 时代 ArrayDeque 靠”容量必为 2 的幂 + (head - 1) & (elements.length - 1)”做环绕,JDK 9 之后改成现在这种任意容量 + if 的写法,省掉了容量对齐浪费的内存。

设计取舍

  • 没有 modCount。迭代中结构被改,靠 nonNullElementAt 撞到不该出现的 null 才抛 ConcurrentModificationException,属于”尽力检测”,比 ArrayList 的 fail-fast 更弱。
  • offer/poll/peek 失败返回 false/null,add/remove/element 失败抛异常,两套 API 语义等价。算法代码用前一组,少写 try 也少一次判空前置。
  • push/pop/peek 作用在队头。所以拿它当栈时,for (int x : deque) 的遍历顺序是从栈顶到栈底,和 Stack 的迭代顺序(从栈底开始)正好相反,把栈转 List 输出时容易踩。
  • 扩容时机是”塞完发现满了才扩”,且一次 grow 后 head 会整体平移,任何持有旧下标的外部逻辑都会失效。好在它根本不暴露下标,这个坑只对改 JDK 的人存在。

延伸阅读