基于JDK1.8对Java中的ArrayDeque集合的源码进行了深度解析,包括各种方法的底层实现,在最后给出了ArrayDeque和LinkedList的对比案例以及使用建议。
文章目录
1 ArrayDeque的概述
public class ArrayDeque< E >
extends AbstractCollection< E >
implements Deque< E >, Cloneable, Serializable
ArrayDeque,来自于JDK1.6,底层是采用可变容量的环形数组实现的一个双端队列,内部元素有序(存放顺序),不支持null元素(LinkedList是支持的)。
继承了AbstractCollection,属于Java集合体系中的一员,具有Collection体系集合的通用方法,比如通过iterator()获取iterator迭代器方法。
没有实现List接口,不具有通过listiterator()获取更高级的listiterator迭代器的方法,同时不具有通过索引操作元素的一系列方法,例如add(int index, E element),get(int index),remove(int index),set(int index, E element)等一系列方法都没有!
实现了Deque接口,即“double ended queue,双端队列”,因此具有在双端队列两端访问元素的方法,同时可以模拟“先进先出FIFO”的队列,也可以模拟“先进后出FILO”的栈。
实现了Cloneable、Serializable标志性接口,支持克隆、序列化操作。
ArrayDeque的实现不是同步的,但可以使用Collections.synchronizedCollection()来转换成线程安全的Collection!
ArrayDequed的学习通常伴随着和LinkedList的对比,关于LinkedList可以看这篇文章:Java的LinkedList集合源码深度解析以及应用介绍。
2 ArrayDeque的API方法
和LinkedList一样,ArrayDeque由于实现了Deque接口,拥有几套操作链表头尾的方法,比如插入、获取、移除,同样有一些方法会抛出异常,有些是返回一个特殊的值比如null:
行为 | 第一个元素(头部) | 最后一个元素(尾部) | ||
结果 | 抛出异常 | 特殊值 | 抛出异常 | 特殊值 |
插入 | addFirst(e) | offerFirst(e) | addLast(e) | offerLast(e) |
移除 | removeFirst() | pollFirst() | removeLast() | pollLast() |
获取 | getFirst() | peekFirst() | getLast() | peekLast() |
由于Deque接口继承了 Queue 接口。在将双端队列用作队列时,将得到 FIFO(先进先出)行为。将元素添加到双端队列的末尾,从双端队列的开头移除元素。
从 Queue 接口继承的方法完全等效于 Deque 方法,如下表所示:
Queue 方法 | 等效 Deque 方法 |
add(e) | addLast(e) |
offer(e) | offerLast(e) |
remove() | removeFirst() |
poll() | pollFirst() |
element() | getFirst() |
peek() | peekFirst() |
双端队列Deque也可用作 LIFO(后进先出)栈,应优先使用此接口而不是遗留 Stack 类(关于Stack 的解析可以看:Java的Stack集合源码深度解析以及应用介绍)。 在将双端队列用作栈时,元素被推入双端队列的开头并从双端队列开头弹出。栈方法完全等效于 Deque 方法,如下表所示:
堆栈方法 | 等效 Deque 方法 |
push(e) | addFirst(e) |
pop() | removeFirst() |
peek() | peekFirst() |
无论是队列或者栈,都从双端队列的开头抽取元素。
3 ArrayDeque的源码解析
3.1 主要类属性
/**
* 内部存储元素的数组,即ArrayDeque采用数组实现的双端队列
*/
transient Object[] elements;
/**
* 双端队列的头节点索引
*/
transient int head;
/**
* 双端队列的尾节点的下一位的索引,即下一个将要被加入到尾部的元素的索引。
*/
transient int tail;
/**
* 新创建的集合使用的最小容量。 容量,一定是2的幂
*/
private static final int MIN_INITIAL_CAPACITY = 8;
从这几个主要属性能够看出来,ArrayDeque使用数组来实现双端队列,并且将数组的索引当作队头或者队尾的下一位,并且最小容量为8,并且容量必须是2的幂次方。
3.2 构造器与初始容量
3.2.1 ArrayDeque()
public ArrayDeque()
构造一个初始容量为16的空数组双端队列。
public ArrayDeque() {
//很简单,创建一个长度16的空数组
elements = new Object[16];
}
3.2.2 ArrayDeque(int numElements)
public ArrayDeque(int 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++;
if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
}
//如果指定容量小于固定的最小容量,那么初始容量就是固定的最小容量8
//根据最终的容量创建数组
elements = new Object[initialCapacity];
}
我们重点看看寻找真正初始化容量的算法!
第一行是initialCapacity = numElements
,将指定容量的值赋值给初始容量;
然后是接下来六行:
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
这是一个很巧妙的算法,对于传入的任何一个小于230(大于等于8)的int类型正整数值initialCapacity,经过五次无符号右移和位或操作后,将会得到一个2k-1的值,最后再自增1,得到2k,该2k就是大于initialCapacity的最小的2的幂次方。
|=
是“位或等”的的连写,a|=b
,即表示a=a|b
。这里的|
是位或运算符,在计算时先将十进制数转换为二进制补码,位数对齐,两个位数只要有一个为1,结果就为1,否则结果为0。
>>>
表示无符号右移,a>>>n
,表示数a右移n位,包括符号位。即先将十进制数a转换为二进制补码,然后带着符号位向右边移动n位,左边的空位都以0补齐,这里的无符号右移a>>>n
可以表示为a=a/2^n
这里可能比较可抽象,我们来看案例,假如最开始的initialCapacity是8。
8的二进制补码:0000 0000 0000 0000 0000 0000 0000 1000
>>>1之后变成: 0000 0000 0000 0000 0000 0000 0000 0100
|运算之后变成:0000 0000 0000 0000 0000 0000 0000 1100
>>>2之后变成: 0000 0000 0000 0000 0000 0000 0000 0011
|运算之后变成:0000 0000 0000 0000 0000 0000 0000 1111
>>>4之后变成: 0000 0000 0000 0000 0000 0000 0000 0000
|运算之后变成:0000 0000 0000 0000 0000 0000 0000 1111
>>>8之后变成: 0000 0000 0000 0000 0000 0000 0000 0000
|运算之后变成:0000 0000 0000 0000 0000 0000 0000 1111
>>>16之后变成: 0000 0000 0000 0000 0000 0000 0000 0000
|运算之后变成:0000 0000 0000 0000 0000 0000 0000 1111
自增1之后变成:0000 0000 0000 0000 0000 0000 0001 0000
右移运算和位或运算之后的最终结果转换为十进制就是15,然后再自增1,变成16,即计算出大于8的最小2次幂为16。
实际上该算法的核心就是通过右移和或运算把这个数的最高位1的所有低位全部转换为1。由于在java中整型是固定的32位,我们可以看到5次右移操作一共右移了32位数,这样对于所以int类型范围内的正整数的最高位1的所有低位全部都可以变为1。
然后再加上1,二进制向前进位1,后面的所有低位变成0,这样就获取了大于n的最小2次幂。
后面的代码则是考虑到的特殊情况:
if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
如果指定初始值是一个特别大的(大于等于230)int类型的值,那么它的二进制补码为 “01 ……”,这样在经过上面的算法之后,得到的值肯定是0111 1111 1111 1111 1111 1111 1111 1111。后面再自增1,这样就变成了1000 0000 0000 0000 0000 0000 0000 0000。
我们可以看到,最后的结果最高位符号位变成了1,这就变成了一个负数,出现错误,实际上这是由于超过了int值的最大限制导致的。对于这种情况,ArrayDeque的做法是将该initialCapacity无符号右移一位,这样它就变成了0100 0000 0000 0000 0000 0000 0000 0000,转换为10进制就是230,这实际上就是int类型范围内的2次幂的最大值(Java的int类型范围是-231 ~ 231-1)。
总结:
对于指定容量的构造器,它的实际容量并不一定是指定容量,分为三种情况:
- 如果指定容量numElements小于MIN_INITIAL_CAPACITY,那么实际初始化容量为MIN_INITIAL_CAPACITY;
- 如果指定容量numElements大于等于MIN_INITIAL_CAPACITY且小于230,那么实际初始化容量为大于numElements的最小二次幂;
- 如果指定容量numElements大于等于230,那么实际初始化容量为230;
补充: 实际上Hashmap的源码中对于指定初始容量的计算也用到了类似的算法。另外,为什么初始容量一定是2的幂次方呢?下面会有解答!
3.2.3 ArrayDeque(Collection<? extends E> c)
public ArrayDeque(Collection<? extends E> c)
构造一个包含指定 collection 的元素的双端队列,这些元素按 collection 的迭代器返回的顺序排列。
public ArrayDeque(Collection<? extends E> c) {
//分配足够容量的空数组
allocateElements(c.size());
//批量添加
addAll(c);
}
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
//内部是循环调用的add方法
for (E e : c)
if (add(e))
modified = true;
return modified;
}
3.3 添加的方法
3.3.1 添加到尾部的方法
public void addLast(E e)
将指定元素插入此双端队列的末尾。
/**
* 将指定元素添加到双端队列的末尾。
*
* @param e 需要被添加的元素
*/
public void addLast(E e) {
//如果被添加的元素为null,则抛出NullPointerException异常
if (e == null)
throw new NullPointerException();
//将元素存放在tail位置,即原尾节点的下一个空节点位置
elements[tail] = e;
/*计算新的下一个尾节点索引*/
if ((tail = (tail + 1) & (elements.length - 1)) == head)
//扩容的方法
doubleCapacity();
}
首先,如果被添加的元素为null,则抛出NullPointerException异常;然后插入元素;接着计算新的下一个尾节点索引,并且判断是否需要扩容。
主要看计算新的下一个尾节点索引的算法和扩容机制!
3.3.1.1 计算新索引
首先,说明一下使用数组实现队列的传统方法一个缺点,即“假溢出”。对于传统数组实现队列的方式,一般来说头节点和尾节点位于数组的头尾部分,如果尾节点达到了数组末尾,直接增加尾节点索引将会导致数组长度溢出,因此常常考虑扩容。但是此时该数组的前半部分还有剩余空间,例如在插入尾节点时删除了一些头节点),这种现象称为"假溢出",因为真实的数组空间并没有用完,造成了空间的浪费。关于此在Java中的队列数据结构详解以及实现案例演示一文中有更加详细的解释!
为了优化这种“假溢出”的缺点。我们可以取消头、尾节点的相对先后位置,例如,如果尾节点达到了数组末尾,先不急着扩容数组,此时将下一个尾节点的索引置为0索引,即数组的开头,然后判断此时头索引和下一个尾节点索引是否重合,如果没有重合,那么说明该数组还有可以利用的空间,如果重合那么则可以扩容,这样就能利用前半部分的空闲空间了。
这种解决方法得到的队列的头、尾节点索引,与数组索引的头、尾没有联系,头节点索引可能比尾节点索引值更大,该数组被“循环利用”!
在ArrayDeque中,寻找下一个尾节点的操作就实现了上面的解决算法。并且它的实现也非常的巧妙,少量的代码就能够计算出新的下一个尾节点的索引的同时还能处理头、尾索引达到数组两端时的情况,最终得到的tail值是将会被固定在[0 , elements.length-1]之间。它的算法实现如下:
tail = (tail + 1) & (elements.length - 1)
&
表示“位与”运算,它会将十进制数转换为二进制补码,然后如果对位都是1,结果为1,否则结果为0,例如当elements.length =8时,tail=7时,下一个尾节点索引将会是8,此时计算出的值应该变成0,8&7:
0000 1000 -->8
0000 0111 -->7
—————————
0000 0000 -->0
这里还需要解释:为什么数组的初始容量一定是2的幂次方?
一个正整数如果2的幂次方,那么该数的二进制一定是 “最高位是1,低位全是0”的形式;如果该数减去1之后,它的二进制变成“最高位降低一位,并且低位全部变成了1”的形式,上面的8和7,的二进制是1000,7的二进制是0111。
如果我们的elements.length是2的幂次方,那么elements.length-1的二进制就会变成上面所说的低位全部变成1的情况。例如,如果elements.length为8,某一时刻tail为3,那么下一个尾部应该为4,经过&计算之后:
0000 0100 -->4
0000 0111 -->7
————————
0000 0100 -->4
还是得到4,我们可以发现,对于第一个操作数tail+1(1~ elements.length),(tail + 1) & (elements.length - 1)的计算结果能够得到0~ elements.length-1的全部结果。
如果我们的elements.length不是2的幂次方,那么elements.length-1的二进制就会变成“最高位降低一位,并且低位全部变成了1”的形式。
例如,如果elements.length为9,某一时刻tail为3,那么下一个尾部应该为4,经过&计算之后:
0000 0100 -->4
0000 1000 -->8
——————————
0000 0000 -->0
变成了0,我们可以发现,对于第一个操作数tail+1(1~ elements.length),(tail + 1) & (elements.length - 1)的计算结果不能够得到0~ elements.length-1的全部结果,这样的话计算出来的下一个尾节点索引就会有问题。
实际上这里有一个计算规律。对于正整数m,n:
- 如果n=m,那么m&n=n;
- 如果n为2的幂次方,且m<n,那么m&n=0;
- 如果n不为2的幂次方,且m<n,那么m&n=m;
- 如果n为2的幂次方,那么n+1&n=n;
- 如果n不为2的幂次方,那么n+1&n=0;
如果elements.length为2的幂次方,那么elements.length-1自然就不是2的幂次方。根据上面的计算规律,“数组容量(长度)必须是2的幂次方”这一要求是为了保证计算下一个尾节点索引和头节点索引的值的正确性,即为了保证计算出的索引能够取到[0~elements.length-1]之间的全部值!
3.3.1.2 扩容机制
在通过上面的算法计算出新尾节点的下一个索引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
//新容量等于原容量左移一位,转换为十进制计算就是:newCapacity=n*(2^1)
int newCapacity = n << 1;
//如果新容量小于0,这里肯定又是超过int值上限之后变成负数的情况了,从这里能够看出来原容量大于等于2^30之后,扩容后的容量就会小于0
if (newCapacity < 0)
//直接抛出异常
throw new IllegalStateException("Sorry, deque too big");
//newCapacity大于0,新建一个数组
Object[] a = new Object[newCapacity];
/*拷贝原数组的数据到新数组*/
//拷贝原头节点右侧的数据,索引为[p,elements.length-1],到新数组索引为[0,r-1]
System.arraycopy(elements, p, a, 0, r);
//拷贝原头节点左侧的数据,索引为[0,p-1],到新数组索引为[r,p-1]
System.arraycopy(elements, 0, a, r, p);
//通过上面的拷贝,将头节点索引重新变成了0,下一个尾节点索引变成了n(即原数组的容量,因为此时数组能够容纳该索引值了)
/*改变引用*/
elements = a;
//头节点索引重新赋值
head = 0;
//下一个尾节点索引重新赋值
tail = n;
}
我们来总结一下扩容的主要步骤:
- 首先计算出新容量,使用
<<
运算计算出理论新容量应该为原容量的两倍或者为负数,然后进入步骤2;实际上原容量大于等于230之后,扩容后的容量值就会小于0,即ArrayDeque最大容量为230。 - 然后判断新容量是否小于0,如果小于0那说明新容量超过了int值上限,抛出异常程序结束,如果大于0,那么说明可以扩容,进入步骤3;
- 新建新容量大小的空数组,拷贝原数组的数据到新数组,通过两次拷贝,将头节点索引重新变成了0,下一个尾节点索引变成了原数组长度。然后改变数组引用和相关值,扩容结束!
3.3.1.3 其他方法
public boolean add(E e)
将指定元素添加到双端队列的末尾。
public boolean add(E e) {
//内部调用addLast的方法
addLast(e);
return true;
}
public boolean offerLast(E e)
将指定元素添加到双端队列的末尾。
public boolean offerLast(E e) {
//内部调用addLast的方法
addLast(e);
return true;
}
public boolean offer(E e)
将指定元素添加到双端队列的末尾。
public boolean offer(E e) {
//内部调用offerLast的方法
return offerLast(e);
}
3.3.2 添加到头部的方法
public void addFirst(E e)
将指定元素插入此双端队列的开头。
public void addFirst(E e) {
//判空
if (e == null)
throw new NullPointerException();
//计算新的头节点索引并且插入元素
//这里的计算方法和下一个尾节点索引计算方法差不多
elements[head = (head - 1) & (elements.length - 1)] = e;
//如果头节点索引等于下一个尾节点索引,那么尝试扩容
if (head == tail)
doubleCapacity();
}
我们看看头节索引的计算方法:
head = (head - 1) & (elements.length - 1)
该方法与下一个尾节点索引的计算方法差不多,不同之处在于这里的第一个操作数是head-1,由于初始head是0,这样当添加第一个数到头节点时,就是-1&(elements.length - 1),该结果固定返回elements.length – 1,即头节点索引是从数组尾部开始的,然后依次递减;而前面的下一个尾节点索引的计算方法中,我们还记得第一个操作数是tail+1,即尾节点索引是从数组头部开始的,依次递增,这也是一个非常有趣的结果!
3.3.2.1 其他方法
public boolean offerFirst(E e)
将指定元素插入此双端队列的开头。
public boolean offerFirst(E e) {
//内部调用addFirst方法
addFirst(e);
return true;
}
public void push(E e)
将元素推入此双端队列所表示的堆栈。换句话说,将元素插入此双端队列的开头。
public void push(E e) {
//内部调用addFirst方法
addFirst(e);
}
3.4 移除的方法
3.4.1 移除尾部的方法
public E pollLast()
获取并移除此双端队列的最后一个元素;如果此双端队列为空,则返回 null。
public E pollLast() {
//由于tail表示尾节点的下一个节点索引,因此这里获取真正尾节点的索引
int t = (tail - 1) & (elements.length - 1);
@SuppressWarnings("unchecked")
//获取该索引处的节点元素result
E result = (E) elements[t];
//如果result为null,则返回null
if (result == null)
return null;
//将该索引处空间置空
elements[t] = null;
//tail赋值为该索引
tail = t;
//返回元素值
return result;
}
3.4.1.1 其他方法
public E removeLast()
获取并移除此双端队列的最后一个元素;如果此双端队列为空,它将抛出NoSuchElementException异常。
public E removeLast() {
//内部调用pollLast方法
E x = pollLast();
//如果返回值为null,则抛出异常
if (x == null)
throw new NoSuchElementException();
return x;
}
3.4.2 移除头部的方法
public E pollFirst()
获取并移除此双端队列的第一个元素;如果此双端队列为空,则返回 null。
public E pollFirst() {
//获取头结节点索引
int h = head;
@SuppressWarnings("unchecked")
//获取该索引处的元素
E result = (E) elements[h];
// 如果该元素为null,则返回null
if (result == null)
return null;
//该索引处空间置空
elements[h] = null;
//计算下一个新的头节点索引
head = (h + 1) & (elements.length - 1);
//返回该元素节点
return result;
}
3.4.2.1 其他方法
public E removeFirst()
获取并移除此双端队列第一个元素。如果此双端队列为空,它将抛出NoSuchElementException异常。
public E removeFirst() {
//内部调用pollFirst方法
E x = pollFirst();
//如果返回null.则抛出异常
if (x == null)
throw new NoSuchElementException();
return x;
}
public E remove()
获取并移除此双端队列第一个元素。如果此双端队列为空,它将抛出NoSuchElementException异常。
public E remove() {
//内部调用removeFirst方法
return removeFirst();
}
public E poll()
获取并移除此双端队列所表示的队列的头部(换句话说,此双端队列的第一个元素);如果此双端队列为空,则返回 null。
public E poll() {
//内部调用pollFirst方法
return pollFirst();
}
3.4.3 移除指定元素
3.4.3.1 移除第一次出现的元素
public boolean removeFirstOccurrence(Object o)
从此双端队列移除第一次出现的指定元素。如果此双端队列不包含该元素,则不作更改。更确切地讲,移除第一个满足 (o == null ? e == null : o.equals(e)) 的元素 e(如果存在这样的元素)。如果此双端队列包含指定的元素(或者此双端队列由于调用而发生了更改),则返回 true。
public boolean removeFirstOccurrence(Object o) {
//null检查
if (o == null)
//如果o为null,则返回false
return false;
//获取最大索引
int mask = elements.length - 1;
//获取头节点索引
int i = head;
Object x;
//从头节点开始循环查找
while ( (x = elements[i]) != null) {
//如果节点不为null
//如果节点等于o,这里使用equals进行比较的
if (o.equals(x)) {
/*调用delete删除该位置的元素,并且调整后续元素的位置*/
delete(i);
//返回true
return true;
}
//索引i的值,类似于添加节点时的获取下一个节点索引的处理方法,即循环查找
i = (i + 1) & mask;
}
//走到这一步,说明循环完毕,还是没有找到相等的元素,返回false
return false;
}
我们来看delete方法源码:
/**
* 删除指定索引的元素,并且调节其他元素的位置
*
* @param i 索引
* @return 如果i到头节点比较近则返回false ;如果i到尾节点比较近则返回true,该返回值在迭代器中会用到
*/
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;
//获取i到头节点索引的距离
final int front = (i - h) & mask;
//获取i到尾节点索引的距离
final int back = (t - i) & mask;
// 检测并发修改,如果front 大于等于尾节点到头节点的距离,那么说明出现了"并发修改",抛出异常
if (front >= ((t - h) & mask))
throw new ConcurrentModificationException();
/*需要移动元素数量优化
如果i到头节点比较近,则将该位置到头部之间的元素进行移动,如果i到尾节点比较近,则将该位置到尾部之间的元素进行移动
尽量减少移动的元素数量*/
/*1 如果i到头节点比较近*/
if (front < back) {
if (h <= i) {
//如果i大于等于头节点索引h,那么直接arraycopy一次就行了,将[h,i-1]的元素移动至[h+1,i]的位置,如果h=i那么说明不需要移动
System.arraycopy(elements, h, elements, h + 1, front);
} else {
//如果i小于头节点索引h,那么稍微复杂点,因为出现了不连续的两段元素,即[h,elements.length - 1]和[0,i-1],需要arraycopy两次
//首先将[0,i-1]的元素移动至[1,i]的位置
System.arraycopy(elements, 0, elements, 1, i);
//然后将数组最后一个元素(elements.length - 1的索引位置处)移动至开头(0索引位置处)
elements[0] = elements[mask];
//最后将[h,elements.length - 1- 1]的元素移动至[h+1,elements.length - 1]的位置
System.arraycopy(elements, h, elements, h + 1, mask - h);
}
//将原头节点位置 置空
elements[h] = null;
//计算新的head索引
head = (h + 1) & mask;
//返回false
return false;
}
/*2 如果i到尾节点比较近*/
else {
if (i < t) {
//如果i小于尾节点的下一个节点索引t,那么直接arraycopy一次就行了,将[i+1,t]的元素移动至[i,t-1]的位置
System.arraycopy(elements, i + 1, elements, i, back);
//计算新的尾节点的下一个节点索引,直接减少1就可以了,因此此时t肯定大于0,减去1不会是负数;同时不需要手动置空t的位置,因为在上面移动时,最后一个元素为null
tail = t - 1;
} else {
//如果i大于等于尾节点的下一个节点索引t,那么稍微复杂点,因为出现了不连续的两段元素,即[i+1,elements.length - 1]和[0,t],需要arraycopy两次
//首先将[i+1,elements.length - 1]的元素移动至[i,elements.length - 1 - 1]的位置
System.arraycopy(elements, i + 1, elements, i, mask - i);
//然后将数组第一个元素(0索引位置处)移动至末尾(elements.length - 1的索引位置处)
elements[mask] = elements[0];
//最后将[1,t]的元素移动至[0,t - 1]的位置
System.arraycopy(elements, 1, elements, 0, t);
//计算新的尾节点的下一个节点索引;同时不需要手动置空t的位置,因为在上面移动时,最后一个元素为null
tail = (t - 1) & mask;
}
//返回true
return true;
}
}
看起来很多,实际上也不是很难,主要注意的其中一个优化方案是:先计算出需要删除的索引到头节点和尾节点的距离,然后取较小的哪一段距离进行元素移动,这样能减少需要移动的元素的个数。
public boolean remove(Object o)
从此双端队列中移除第一次出现的指定元素。
public boolean remove(Object o) {
//内部调用removeFirstOccurrence方法
return removeFirstOccurrence(o);
}
3.4.3.1 移除最后一次出现的元素
public boolean removeFirstOccurrence(Object o)
从此双端队列移除最后一次出现的指定元素。如果此双端队列不包含该元素,则不作更改。更确切地讲,移除最后一个满足 (o == null ? e == null : o.equals(e)) 的元素 e(如果存在这样的元素)。如果此双端队列包含指定的元素(或者此双端队列由于调用而发生了更改),则返回 true。
public boolean removeLastOccurrence(Object o) {
//null检查
if (o == null)
//如果o为null,则返回false
return false;
//获取最大索引
int mask = elements.length - 1;
//获取尾节点的索引
int i = (tail - 1) & mask;
Object x;
//从尾节点开始循环查找
while ( (x = elements[i]) != null) {
//如果节点不为null
//如果节点等于o,这里使用equals进行比较的
if (o.equals(x)) {
/*调用delete删除该位置的元素,并且调整后续元素的位置*/
delete(i);
return true;
}
//索引i的值,类似于添加节点时的获取下一个节点索引的处理方法,即循环查找,这里是递减循环
i = (i - 1) & mask;
}
//走到这一步,说明循环完毕,还是没有找到相等的元素,返回false
return false;
}
3.5 获取的方法
3.5.1 获取头部的方法
public E getFirst()
获取,但不移除此双端队列的最后一个元素。如果此双端队列为空,它将抛出NoSuchElementException异常。
public E getFirst() {
//获取头节点
@SuppressWarnings("unchecked")
E result = (E) elements[head];
//如果头节点为null,则抛出异常
if (result == null)
throw new NoSuchElementException();
return result;
}
public E peekFirst()
获取,但不移除此双端队列的最后一个元素;如果此双端队列为空,则返回 null。
public E peekFirst() {
// elements[head] is null if deque empty
return (E) elements[head];
}
pubilc E element()
获取,但是不移除此队列的头。如果此队列为空,它将抛出NoSuchElementException异常。
public E element() {
//内部调用getFirst的方法
return getFirst();
}
public E peek()
获取,但不移除此双端队列的第一个元素;如果此双端队列为空,则返回 null。
public E peek() {
//内部调用peekFirst的方法
return peekFirst();
}
3.5.2 获取尾部的方法
public E getLast()
获取,但不移除此双端队列的最后一个元素。如果此双端队列为空,它将抛出NoSuchElementException异常。
public E getLast() {
//获取尾部元素
@SuppressWarnings("unchecked")
E result = (E) elements[(tail - 1) & (elements.length - 1)];
//如果尾节点为null,则抛出异常
if (result == null)
throw new NoSuchElementException();
return result;
}
public E peekLast()
获取,但不移除此双端队列的最后一个元素;如果此双端队列为空,则返回 null。
public E peekLast() {
return (E) elements[(tail - 1) & (elements.length - 1)];
}
3.6 其他方法
public boolean contains(Object o)
如果此双端队列包含指定元素,则返回 true。更确切地讲,当且仅当此双端队列至少包含一个满足 o.equals(e) 的元素 e 时,返回 true。
public boolean contains(Object o) {
//判空
if (o == null)
return false;
int mask = elements.length - 1;
int i = head;
Object x;
//循环查找,内部使用equals判断是是否相等
while ( (x = elements[i]) != null) {
if (o.equals(x))
return true;
i = (i + 1) & mask;
}
return false;
}
public void clear()
从此双端队列中移除所有元素。在此调用返回之后,该双端队列将为空。
public void clear() {
int h = head;
int t = tail;
if (h != t) { // clear all cells
head = tail = 0;
int i = h;
int mask = elements.length - 1;
do {
//循环将每一个节点置空
elements[i] = null;
i = (i + 1) & mask;
} while (i != t);
}
}
4 ArrayDeque和LinkedList的区别
相同点:
- ArrayDeque和LinkedList都实现了Deque接口,都是双端队列的实现,都具有操作队列头尾的一系列方法。
- 都是非线程安全的集合。
不同点:
ArrayDeque来自JDK1.6,底层是采用数组实现的双端队列,而LinkedList来自JDK1.2,底层则是采用链表实现的双端队列。
- ArrayDeque不允许null元素,而LinkedList则允许null元素。
- 如果仅仅使用Deque的方法,即从队列两端操作元素,用作队列或者栈,并且如果数据量比较大,一般来说ArrayDeque的效率要高于LinkedList,其效率更高的原因可能是ArrayDeque不需要创建节点对象,添加的元素直接作为节点对象,LinkedList则需要对添加的元素进行包装为Node节点,并且还具有其他引用的赋值操作。
- LinkedList还同时实现了List接口,具有通过“索引”操作队列数据的方法,虽然这里的“索引”只是自己维护的索引,并非数组的索引,但该功能这是ArrayDeque所不具备的。如果需要对 队列中间的进行元素的增、删、改操作,那么你只能使用LinkedList,因此LinkedList的应用(或者说可用方法)更加广泛。
5 性能对比
附,使用Deque的方法时的ArrayDeque和LinkedList的性能对比:
/**
* @author lx
*/
public class ArrayDequeTest2 {
static ArrayDeque<Integer> arrayDeque = new ArrayDeque<Integer>();
static LinkedList<Integer> linkedList = new LinkedList<Integer>();
public static long arrayDequeAdd() {
//开始时间
long startTime = System.currentTimeMillis();
for (int i = 1; i <= 5000000; i++) {
arrayDeque.addLast(i);
arrayDeque.addFirst(i);
}
//结束时间
long endTime = System.currentTimeMillis();
//返回所用时间
return endTime - startTime;
}
public static long arrayDequeDel() {
//开始时间
long startTime = System.currentTimeMillis();
for (int i = 1; i <= 5000000; i++) {
arrayDeque.pollFirst();
arrayDeque.pollLast();
}
//结束时间
long endTime = System.currentTimeMillis();
//返回所用时间
return endTime - startTime;
}
public static long linkedListAdd() {
//开始时间
long startTime = System.currentTimeMillis();
for (int i = 1; i <= 5000000; i++) {
linkedList.addLast(i);
linkedList.addFirst(i);
}
//结束时间
long endTime = System.currentTimeMillis();
//返回所用时间
return endTime - startTime;
}
public static long linkedListDel() {
//开始时间
long startTime = System.currentTimeMillis();
for (int i = 1; i <= 5000000; i++) {
linkedList.pollFirst();
linkedList.pollLast();
}
//结束时间
long endTime = System.currentTimeMillis();
//返回所用时间
return endTime - startTime;
}
public static void main(String[] args) throws InterruptedException {
Thread.sleep(100);
long time1 = arrayDequeAdd();
long time3 = arrayDequeDel();
System.out.println("arrayDeque添加元素所用时间====>" + time1);
System.out.println("arrayDeque删除元素所用时间====>" + time3);
System.gc();
Thread.sleep(100);
long time2 = linkedListAdd();
long time4 = linkedListDel();
System.out.println("linkedList添加元素所用时间====>" + time2);
System.out.println("linkedList删除元素所用时间====>" + time4);
}
}
如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!