最近盯上了java collection
框架中一个类——ArrayDeque
。很多人可能没用过甚至没听说过这个类(i'm sorry,what's fu*k this?),毕竟你坐在面试官面前的时候,关于数组链表的掌握情况,99%的可能性听到问题会是:说说ArrayList和LinkedList的区别?
今天从ArrayDeque入手,换一个角度来检验下我们是否真正掌握了数组、链表。
父类和接口
不着急分析这个类的核心方法,先看下它的父类和接口,以便在Java Collection宇宙中找准它的定位,顺带从宏观角度窥探下Java Collection框架设计。
父类
add、addAll、remove、clear、iterator、size……是不是都很常见?你常用的xxList中,经常会看到这些方法吧?可以说,AbstractCollection这个抽象类,是这种结构(数组、链表等等)的骨架!
接口
那么Deque接口呢?
入眼各种xxFirst、xxLast,这种定义决定了它是双端队列的代表!
框架设计
相继看了父类和接口,楼主你到底想表达啥?嘿嘿,别急,我再反问一个经典问题——抽象类和接口有什么区别?
你可能会有各种回答,比如抽象类能自己有自己的实现之类的。不能说不对,但这种答案相当于只停留在奇技淫巧层面,未得正统。以设计角度来看,其实是is-a(抽象类)和has-a(接口)的区别!
- 抽象类相当于某一个种族的基石
比如定义汽车AbstractCar,会规定有轮子有发动机能跑的就是汽车;各家厂商生产的汽车都逃不出这个范畴,甭管你是大众宝马玛莎拉蒂。
- 接口则关注各种功能
有些汽车多了座椅加热;有些增设了天窗打开功能。但这些功能都是增强型的,并不是每种汽车都会有!
抽象类和接口合理的组合,就产生了奇妙的效果:既能保证种族(类)的结构,又能对其进行扩展(接口)。给出大家熟悉的ArrayList和LinkedList,仔细感受下:
这种设计不仅仅限于Java Collection,开源框架中也是如此,比如Spring IOC中的Context、Factory那部分……
分析
回归到本文的主角 ArrayDeque,既然它实现了Deque,自然具备双端队列的特性。类名中的 Array姓氏,无时无刻不在提醒我们,它是基于数组实现的。
类注释中,有句话引起了我的注意:
/**
* This class is likely to be faster than
* {@link Stack} when used as a stack, and faster than {@link LinkedList}
* when used as a queue.
*/
(Stack先不管)注释中后半句说,ArrayDeque作为队列时比LinkedList快,看看它是怎么办到的!
三大属性:
transient Object[] elements; //基于数组实现
transient int head; //头指针
transient int tail; //尾巴指针
技术敏感的同学已经能猜到它是怎么实现的了:数组作为基底,两个指分指头尾,插入删除操作时移动指针;如果头尾指针重合,则需要扩容……
下面看看源码实现,是否和我们猜测的一致。
构造器
private static final int MIN_INITIAL_CAPACITY = 8;
// ****** Array allocation and resizing utilities ******
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;
}
规定最小值MIN_INITIAL_CAPACITY = 8
,如果入参小于8,数组大小就定义成8;如果大于等于8,这一通右移是啥操作?假如我们传入了16,二进制10000,逐步分析下:
1.initialCapacity |= (initialCapacity >>> 1)
右移1位作|
操作,10000->01000,'或' 操作后11000
2.initialCapacity |= (initialCapacity >>> 2)
接上一步,右移2位作|
操作,11000->00110,'或' 操作后11110
3.initialCapacity |= (initialCapacity >>> 4)
接上一步,右移4位作|
操作,11110->00001,'或' 操作后 11111
……
后面就两步都是11111 | 00000,结果就是 11111
4.initialCapacity++
二进制数11111,+1之后100000,转换成十进制32
最终的负值判断(用于处理超int正向范围情况),先不考虑。
结论:这些'或' 操作,最终得到了大于入参的2的次幂中最小的一个。
底层数组始终是2的次幂,为什么如此?带着这个问题继续往下分析
// The main insertion and extraction methods are addFirst,
// addLast, pollFirst, pollLast. The other methods are defined in
// terms of these.
以上注释有云,核心方法就4个,我们从add方法入手。
插入
- addFirst
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)
,玄机就在这里。如果你对1.8的HashMap
足够了解,就会知道hashmap的数组大小同样始终是2的次幂。其中很重要的一个原因就是:当lengh是2的次幂的时候,某数字 x 的操作 x & (length - 1)
等价于 x % length
,而对二进制的计算机来说 & 操作要比 % 操作效率更好!
而且head = (head - 1) & (elements.length - 1)
,(head初始值0)第一次就将head指针定位到数组末尾了。
可见,head指针从后向前移动。
- addLast
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
elements[tail] = e;
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}
addLast和addFirst原理相同,只是addLast控制tail指针,从前向后移动!
上图中再做一次add操作,指针将会重合。比如,再一次addFirst之后:
if (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; //左移,等价乘2,依然保持2的次幂
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;
}
至于pollFirst、pollLast是addFirst、addLast的相反操作,原理相似,不多做分析。
回到那个问题:作为队列时,ArrayDeque效率为什么会比LinkedList更好?
我觉得因为LinkedList作为队列实现,新增修改要多修改节点的pre、next指针,且地址非连续,寻址时也会比array花更多时间。