天天看点

ArrayDeque源码学习

前置知识

为了更好的理解使用线性数组实现的双端队列,这里我们先来图解线性数组实现基本数据结构-队列:

ArrayDeque源码学习

如上图所示,head指向队头,入队加元素时,tail队尾向后移动,出队时从head出取出元素并移除,这样就利用了线性数组实现先进先出的队列数据结构,当head等于tail时,则表示队列为空。

但是这样存在问题:当不断出队时,head向后移动,前面空出来的空间就被浪费,导致不断入队时,需要数组扩容,出队时造成大量空间无法使用,空间利用率低下!

假设,如果能将前面空出来的空间也利用起来进行存储末尾的元素,则空间使用率将提高,这里就需要有个循环的思维,把这种线性的弯曲成一个圆环,这样就可以反复使用空出来的空间,入队时使用出队空余出来的空间,就解决以上的问题,图解如下:

ArrayDeque源码学习

同样,当head等于tail时,则表示循环队列为空。head和tail也是循环的,像钟表中的时针,具有周期性。这里head和tail需要对长度lenth取模,这样head和tail将一直在长度范围内,可以作为数组的下标。

对于如何将数据分布到相应大小的连续空间中,常用的方式就是取模运算,即position=index%len,利用整数倍的周期性,将剩余的部分作为空间索引。

基本定义

ArrayDeque是JDK容器中的一个双端队列实现,内部使用数组进行元素存储,不允许存储null值,可高效的进行元素查找和尾部插入取出,是用作队列、双端队列、栈的绝佳选择,性能比LinkedList还要好的构造方法。

  • 基本属性定义如下:
//存储元素的数组
   transient Object[] elements; // 非private访问限制,以便内部类访问
    
    // 头部节点序号,相当于头指针
    transient int head;

    // 尾部节点序号,(下一个插入的元素的位置),相当于尾指针
    transient int tail;
    
    // 双端队列的最小容量,必须是2的幂
    private static final int MIN_INITIAL_CAPACITY = 8;
           

构造方法

//构造一个初始容量为16的空队列
	public ArrayDeque() {
        elements = new Object[16];
    }
    //构造一个能容纳指定大小的空队列
    public ArrayDeque(int numElements) {
        allocateElements(numElements);
    }
    //构造一个包含指定集合所有元素的队列
    public ArrayDeque(Collection<? extends E> c) {
        allocateElements(c.size());
        addAll(c);
    }
           

核心方法

1、分配元素

在上述第二与第三个构造方法中,里面调用

allocateElements()

,让我们来看看它的实现:

public ArrayDeque(int numElements) {
        allocateElements(numElements);
    }
    
    private void allocateElements(int numElements) {
        int initialCapacity = MIN_INITIAL_CAPACITY;
        // 找到一个能满足刚好大于指定长度的2次幂数
            if (numElements >= initialCapacity) {
            initialCapacity = numElements;
            initialCapacity |= (initialCapacity >>>  1);
            initialCapacity |= (initialCapacity >>>  2);
            initialCapacity |= (initialCapacity >>>  4);
            initialCapacity |= (initialCapacity >>>  8);
            initialCapacity |= (initialCapacity >>> 16);
            initialCapacity++;
            
			//当n已经为2^31时,左移一位会造成溢出,即将数据最高位的1移动到符号位上,这样会变负
            if (initialCapacity < 0)   // 过度扩容,需要减容一次
                initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
        }
        elements = new Object[initialCapacity];
    }
           

allocateElements

方法主要用于给内部的数组分配合适大小的空间,

calculateSize

方法用于计算比numElements大的最小2的幂次方,如果指定的容量大小小于MIN_INITIAL_CAPACITY(值为8),那么将容量设置为8,否则通过多次无符号右移进行最小2次幂计算。先将initialCapacity赋值为numElements,接下来,进行5次无符号右移,下面将以一个例子介绍这样运算的妙处。

在Java中,int类型是占4字节,也就是32位。简单起见,这里以一个8位二进制数来演示前三次操作。假设这个二进制数对应的十进制为89,整个过程如下:

ArrayDeque源码学习

事实上,在这系列操作中,其他位只是配角,我们只需要关注第一个不为0的位即可,假设其为第n位,先右移一位然后进行或操作,得到的结果,第n位和第n-1位肯定为1,这样就有两个位为1了,然后进行右移两位,再进行或操作,那么第n位到第n-3位一定都为1,然后右移4位,依次类推。int为32位,因此,最后只需要移动16位即可。1+2+4+8+16 = 31,所以经过这一波操作,原数字对应的二进制,操作得到的结果将是从其第一个不为0的位开始,往后的位均为1。

2、扩容

当头指针和尾指针指向同一位置,即该双向队列满的时候,调用

doubleCapacity()

方法。

private void doubleCapacity() {
		//断言头指针和尾指针指向同一位置时,继续执行该方法
        assert head == tail;
        int p = head;
        int n = elements.length;
        //获取到头指针右部剩余的元素个数
        int r = n - p; 
        //将该队列做无符号左移一位,容量扩大一倍
        int newCapacity = n << 1;
        //当n已经为2^31时,左移一位会造成溢出,即将数据最高位的1移动到符号位上,这样会变负
        if (newCapacity < 0)
            throw new IllegalStateException("Sorry, deque too big");
        //创建一个临时数组对象,长度为newCapacity
        Object[] a = new Object[newCapacity];
        /* 
        1、将数组elements的头指针开始的元素,快速拷贝到数组a中下标为0的位置,长度为r 
        2、将数组elements的0下标开始的元素,快速拷贝到数组a中下标为r的位置,长度为p
        这样使得头指针前的元素(实际上是尾部的元素)移回到该有数组尾部的位置,相当于恢复到头指针指向尾指针,长度为原本容量
        */
        System.arraycopy(elements, p, a, 0, r);
        System.arraycopy(elements, 0, a, r, p);
        elements = a;
        //将头指针指向数组的首部
        head = 0;
         //将尾指针指向扩容后数据的最尾端
        tail = n;
    }
           

3、 添加

public boolean add(E e) {
        addLast(e);
        return true;
    }

	public void addLast(E e) {
		//ArrayDeque不允许元素为null值
        if (e == null)
            throw new NullPointerException();
        //将添加元素存入队列下标为tail的位置中
        elements[tail] = e;
        //更新尾指针的节点序号,如果尾指针与头指针相同,则说明需要扩容
        if ( (tail = (tail + 1) & (elements.length - 1)) == head)
            doubleCapacity();
    }
	
	public void addFirst(E e) {
		//ArrayDeque不允许元素为null值
        if (e == null)
            throw new NullPointerException();
        //更新头指针的节点序号
        elements[head = (head - 1) & (elements.length - 1)] = e;
        //如果尾指针与头指针相同,则说明需要扩容
        if (head == tail)
            doubleCapacity();
    }
           

(tail = (tail + 1) & (elements.length - 1)) == head -> 在 ArrayDeque 中数组是当作环形来使用的,索引0看作是紧挨着索引(length-1)之后的。参考下面的图片:

ArrayDeque源码学习

那么为什么(tail + 1) & (elements.length - 1)就能保证按照环形取得正确的下一个索引值呢?这就和前面说到的 ArrayDeque 对容量的特殊要求有关了。下面对其正确性加以验证:

length     = 2^n     ,二进制表示为: 第 n 位为1,低位 (n-1位)全为0 
length - 1 = 2^n-1,二进制表示为:第 n 位为0 ,低位(n-1位)全为1              (始终保持)
    
若 tail + 1 <= length - 1 ,则位与后,低 (n-1) 位保持不变,高位全为0
若 tail + 1  = length     ,则位与后,低 n 全为0,高位也全为0,结果为0
           

可见,在容量保证为 2^n 的情况下,仅仅通过位与操作就可以完成环形索引的计算,而不需要进行边界的判断,在实现上更为高效。

引用资料

1、https://www.cnblogs.com/mfrank/p/9600137.html

2、https://www.cnblogs.com/wxd0108/p/7366234.html

3、https://www.cnblogs.com/lxyit/p/9080590.html

继续阅读