张可的博客

ArrayDeque 使用与分析

ArrayDeque

ArrayDeque 是 Java 集合中双端队列数组实现,双端队列的链表实现(LinkedList)我们在前几篇文章中讲过了。

ArrayDeque 几乎没有容量限制,设计为线程不安全的禁止 null 元素

ArrayDeque 作为使用时比 Stack 类效率要高,作为队列使用时比 LinkedList 要快

ArrayDeque 大多数的额操作都在固定时间内运行,例外情况包括 removeremoveFirstOccurrenceremoveLastOccurrencecontainsiterator.remove(),和批量操作,这些将以线性时间运行。

iterator同样也被设计为 fail-fast

方法

ArrayDeque 作为 队列(FIFO) 使用时的方法:

队列方法 等效的双端队列方法
add(e) addLast(e)
offer(e) offerLast(e)
remove() removeFirst()
poll() pollFirst()
element() getFirst()
peek() peekFirst()

ArrayDeque 作为 堆栈(FILO) 使用时的方法:

堆栈方法 等效的双端队列方法
push(e) addFirst(e)
pop(e) removeFirst(e)
peek() peekFirst()

源码

我们先看看代码中最重要的三个变量:

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

我们上面也说了 ArrayDeque 是双端队列的数组实现,上面代码可看到确实定义了一个用于存储链表数据数组:elements,此外还定义了headtail用于描述链表在数组中的头尾位置。另外说明一下,elements长度始终未 2 的次方。

这里需要注意一点,用elements存储的是双端队列,headtail参数表示双端队列的头和尾的索引,但并不意味着head值永远小于tail,当tail移动至数组末尾,但队列长度小于数组时(意味着此时head大于0),再向队列末尾添加数据将会使tail移动至数组的开头。

假设下图为当前的elements数组,黄色方块表示其中有数据。

当我们向队列末尾添加一个元素时,数组变成了下面这样:

目前都是按照我们预期发展的,现在我们来调用poll方法移除队列头部的一个元素,此时elements变成了下图:

这个时候因为我们移除了队列的头部元素,数组的开头已经空下来了一个位置,这时候再向队列末尾追加一个元素。

可以看到,此时headtail的右侧,ArrayDeque为了不浪费数组空间进行了这样的设计,也不难理解,我们继续向下看。

现在看一下构造器:

public ArrayDeque() {
    elements = new Object[16];
}

public ArrayDeque(int numElements) {
    allocateElements(numElements);
}

public ArrayDeque(Collection<? extends E> c) {
    allocateElements(c.size());
    addAll(c);
}

private static int calculateSize(int numElements) {
    int initialCapacity = MIN_INITIAL_CAPACITY;
    if (numElements >= initialCapacity) {
        initialCapacity = numElements;
        initialCapacity |= (initialCapacity >>>  1);
        initialCapacity |= (initialCapacity >>>  2);
        initialCapacity |= (initialCapacity >>>  4);
        initialCapacity |= (initialCapacity >>>  8);
        initialCapacity |= (initialCapacity >>> 16);
        initialCapacity++;

        if (initialCapacity < 0)   // Too many elements, must back off
            initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
    }
    return initialCapacity;
}

private void allocateElements(int numElements) {
    elements = new Object[calculateSize(numElements)];
}

默认构造器会将elements初始化为一个长度为 16 的数组,另外两个构造器将根据参数的长度来初始化,最终会调用calculateSize方法计算初始长度。

calculateSize方法就比较有意思了,其中用了很多位运算(虽然我们日常用的很少,但 JDK 中随处可见位运算),最终结果就是控制输出的initialCapacity参数保持在 2 的次方。

下面我们看看添加元素时的源码,添加元素到双向链表最后都会进入addLast方法中:

public void addLast(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[tail] = e;
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)
        doubleCapacity();
}

上面代码的意思就是把元素添加到队列末尾,并移动tail的值,根据上面的介绍,tail移动并不会单纯的向后移动,而是会根据当前elements数组的空余长度进行移动,但这里移动tail的代码写的很巧妙:

tail = (tail + 1) & (elements.length - 1)

我们上面说过,因为elements的长度永远是 2 的次方,所以elements.length - 1的二进制位都是 1,对其进行 & 运算相当于对elements.length进行求余,所以下面两个公式是相等的,不过这种关系必须保证elements长度是 2 的次方才成立:

(tail + 1) & (elements.length - 1)
(tail + 1) % elements.length

由于位运算的效率比算数运算要高很多,所以 JDK 中很多类似的应用。

好了,回到上面的addLast方法中去,该方法回判断tail == head是否成立,相等则表示数组已满,满了就进行扩容,也就是调用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;
}

扩容操作会创建一个新的数组,长度为原数组的两倍,然后通过两次复制操作将原数组复制到新的数组中。

好了,关于 ArrayDeque 就介绍这么多了,其他的方法大体上都是类似的。