JDK源码学习笔记(集合篇 - ArrayDeque)

ArrayDeque跟ArrayList以及LinkedList不同点在于,它是Resizable的双向数组,既有随机访问的便捷,也有poll,offer等双向队列的方法。我们先学习下它是个什么样的集合,后续等对java.util的package下的主要集合类有大体了解了再从用途,性能等点出发进行比较总结。

构造 - Constructor

public ArrayDeque() {
    elements = new Object[16];
}
public ArrayDeque(int numElements) {
    allocateElements(numElements);
}
public ArrayDeque(Collection c) {
    allocateElements(c.size());
    addAll(c);
}

默认无参构造创建一个大小为16的数组,如果指定大小的话,会通过计算返回一个合适的大小,它是这么算出来的。

private static int calculateSize(int numElements) {
    int initialCapacity = MIN_INITIAL_CAPACITY;
    // Find the best power of two to hold elements.
    // Tests "<=" because arrays aren't kept full.
    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;
}

如果要初始化的大小大于默认的大小8,那么会通过移位的操作来达到计算大小,初看有点懵,没关系,我们一点点来看它是怎么做的,首先认识这个移位操作符>>>,往右移位>>> 1就是往右移1位,Java里还有个移位操作符>>,他俩之间又是啥关系,>>>是signed操作就是把最高位的符号位也拿来参与移位操作,而>>的最高位符号位是不参与移位的。举个例子加深理解:

int i = -2;
System.out.println(i); // -2
System.out.println(i >>> 1); // 2147483647
System.out.println(i >> 1); // -1

那么这里传入的numElements肯定是正数,因为不可能小于default大小,initialCapacity |= (initialCapacity >>> 1)就是右移一位然后和原先的值做或操作,10/01为1,11为1,00为0。
如果原来的大小为17,做一次这个操作变为25,其实右移一位然后和原先的数字做或操作其实就是把原先数字最高位后面的每一位都变成1,然后最后+1,这样就变成了最小的大于原先的且为2整数方倍数的数,比如17,一通算下来就是32,因为16(2^4) < 17 < 32(2^5)。这里再多想想,为什么是右移操作,为什么不能左移,我能想到的是因为左移有可能会把原数字的最高位丢掉。
算完了大小,接下来就可以直接申请数组空间进行创建,或者根据传入的collection进行iterator迭代遍历挨个往里添加。这里不再赘述。

添加 - Add

这个类里的headtail是两个int,意味着它们只代表内部数组的下标,并不是一个referece的引用指针。因为它本是双向队列,那么往其中添加就包括了addFirst和addLast,也包括了和ArrayList一样的add操作,默认从tail插入,更包括了像BlockingQueue一样的offerFirst和offerLast。

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

可见插入的元素不支持null,在插入时并不是简单的把e放到elements[head]中。那来看看这个中括号里(head - 1) & (elements.length - 1)是干嘛的。

head - 1,head初始化默认为0,如果减一则变为-1,elements.length - 1就是内部数组elements的整个数组最后一个元素下标,这时elements应该是调用完constructor后的大小,默认为16,或者一个指定的正整数,我们按默认来计算,那么下标就是15,-1 & 15的结果是15,但-1的二进制如果书面写出来的话应该是1000000000000000000000000000001,4字节,,但如果照这么算的话,-1 & 15的结果应该是-1,而不是15。但其实在计算机内部,-1的表示方式是11111111111111111111111111111111。如下所示:

System.out.println(Integer.toBinaryString(-1)); // 11111111111111111111111111111111
System.out.println(Integer.toBinaryString(-2)); // 11111111111111111111111111111110

所以-1的表述方式其实是0xFF,按照这个来做的话-1 & 15就是15。head就是15,elements[15] = e。所以第一次addFirst其实就是在数组的尾部添加一个元素,head就是指向这个位置,tail是0。依次类推,如果再依次调用addFirst,这是head - 1就变为14,14 & 15 = 14,因为elements.length始终是2次幂-1,所以始终除了符号位其他都是11111,任何数与上这个数字,都是原本的数字,所以这样addFirst就间接的实现了从后往前的写入操作。
同理,addLast和addFirst方向相反,通过tail来往里添加元素。只不过在head == tail的时候,需要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;
}

只有在head和tail相遇的时候才会进行doubleCapacity的操作,一样的套路,位操作符左移1位,乘以2。把新增的空间放到head和tail的中间。计算下head到数组结尾总共有多少个,先拷贝过去,然后再把0到tail的元素拷贝过去,最后tail指向原来数组的最后一个元素的下一个位置,head指向新数组里最后的下标。做个实验,比如

ArrayDeque integers = new ArrayDeque<>();

for (int i = 0; i < 16; i++) {
    integers.addFirst(i);
} // 默认16个初始大小,填满后,下个元素会doubleCapacity
integers.addFirst(17); 

在addFirst(17)的时候会先doubleCapacity,然后做ArrayCopy,copy的方法是这样的,先把head到结尾(右边)的元素放到新数组的开头,以0开始依次拷贝过去,再把之前0到tail的下标元素接着拷贝过去。效果如下。

拷贝前

tail = 0, head = 0
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

拷贝后

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, null, null, ...] ,一共32个元素,其余为null
在添加新元素后变为:
tail = 16, head = 31
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, null, null, ..., 17] ,一共32个元素,其余为null

另一种情况head和tail在中途相遇

拷贝前

tail = 5, head = 5
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

拷贝后

tail = 16, head = 31,
[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 2, 3, 4, 5, null, null, ....] ,一共32个元素,其余为null
在添加新元素后变为:
tail = 16, head = 31
[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 2, 3, 4, 5, null, null, ..., 17] ,一共32个元素,其余为null

所以可以看到,head和tail它只可以访问头和尾,数组的中间极有可能是无序的,因为这种拷贝机制是没有保证有序的。

offerFirst和offerLast本质上也是invoke上面的两个方法,只不过他们返回的是boolean,而上面这两个返回的是void。

删 - remove

本质上和addFirst/addLast是个反向操作

public E pollFirst() {
    int h = head;
    @SuppressWarnings("unchecked")
    E result = (E) elements[h];
    // Element is null if deque empty
    if (result == null)
        return null;
    elements[h] = null;     // Must null out slot
    head = (h + 1) & (elements.length - 1);
    return result;
}

先把head对应的元素找出来,最后返回,然后head所在位置置空,head移位,最关键的还是这句:
head = (h + 1) & (elements.length - 1),head的下标+1,与上元素大小的mask,即2^n - 1,这是一种快速的取模方法,类似于70 % 60 = 10,但性能比其好太多,目的就是不超过数组的大小,这样可以做循环赋值等操作。所以如果不停的pollFirst,head不断+1,它会回到0,然后从0再逐次增加,知道遇到tail,整个deque变空。这个设计真的非常巧妙。对于tail的操作和head类似,就是相反的方向,这里不再赘述。

再来看remove指定object的情况,

public boolean remove(Object o) {
    return removeFirstOccurrence(o);
}   

删除第一个出现的元素,那么就看下removeFirstOccurrence,可以想到,无非按照head或者tail,循环遍历整个集合,找到满足equals返回true的元素,把匹配的元素置null,同时调整head tail以及各元素。这里要涉及到元素的移位操作,因为很有可能删除的元素是tail之前或head之后的节点。这里出于性能考虑,用了很多技巧。

private boolean delete(int i) {
    checkInvariants();
    final Object[] elements = this.elements;
    final int mask = elements.length - 1;
    final int h = head;
    final int t = tail;
    final int front = (i - h) & mask;
    final int back  = (t - i) & mask;

    // Invariant: head <= i < tail mod circularity
    if (front >= ((t - h) & mask))
        throw new ConcurrentModificationException();

    // Optimize for least element motion
    if (front < back) {
        if (h <= i) {
            System.arraycopy(elements, h, elements, h + 1, front);
        } else { // Wrap around
            System.arraycopy(elements, 0, elements, 1, i);
            elements[0] = elements[mask];
            System.arraycopy(elements, h, elements, h + 1, mask - h);
        }
        elements[h] = null;
        head = (h + 1) & mask;
        return false;
    } else {
        if (i < t) { // Copy the null tail as well
            System.arraycopy(elements, i + 1, elements, i, back);
            tail = t - 1;
        } else { // Wrap around
            System.arraycopy(elements, i + 1, elements, i, mask - i);
            elements[mask] = elements[0];
            System.arraycopy(elements, 1, elements, 0, t);
            tail = (t - 1) & mask;
        }
        return true;
    }
}

比如先通过i和head,tail相减取模,判断i是离tail近还是离head近,它这里做了个fail-fast的判断,避免删除操作的时候集合被修改,导致移位操作结果不正确,如果这个下标i在head和tail之间的话,就说明不正常,因为这两个之间理论上是不会有任何元素的,注释也说明了。接下来,如果离head更近,那么判断下这个i在head的左边还是右边,如果是右边,很简单除去删除的元素把head到i这段长度为front的元素整体右移一个单位,完成,如果是左边,则把0到i之间的元素整体右移一个单位,再把当前数组最后一个元素补到第0个元素的位置,再把head到最右这段整体右移一个单位。如果里tail更近操作也是类似,这里不做讨论了。画个简单易懂的图吧。

ArrayDeque_delete.png

改 - update

对于Deque结构来说,好像只允许从head和tail查询元素,删除,再put/offer。

查 - get/find

只有findFirst和findLast,没有见到可以取任意index的方法,这也不符合deque的定义,本质就是一个双向队列,只是内部采用了数组结构而已。

你可能感兴趣的:(JDK源码学习笔记(集合篇 - ArrayDeque))