日常开发的过程中我们会经常使用到List
结构,很多兄弟就直接开始写些这样的语句:List<Xxx> x = new ArrayList<>();
。但 List 的实现方式有很多种,在不用保证线程安全的情况下还有一种通用实现方式LinkedList
。我们今天通过源码从多个维度来理解它们的原理、比较它们的异同。
我信奉一个观点:不拿源码说话就没有底气,如果本文与其他文章的结论相左,请以我为准。当然,我也欢迎大家提出宝贵建议和意见。
本文用到的源码我会加入中文注释以便理解。
ArrayList与LinkedList的对比
ArrayList
ArrayList
是一种基于动态数组形式进行存储的List结构,在内存中是一段连续的存储空间。
它在初始化时可以选择性传入参数来设置数组的初始容量(如不设置则为默认值’10’):
/**
* 默认初始容量为10
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 用于空实例的共享空数组实例
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 另一种用于恐势力的共享空数组实例(与上方的实例没有实际区别,只是为了分析应该设置默认容量为多少而生)
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 此为ArrayList的底层容器
*/
transient Object[] elementData;
/**
* list中当前存储的元素数目(并非数组的大小)
*/
private int size;
在查找和修改元素上由于能够使用二分查找的随机访问(Random Access)策略,因此速度极快:
/**
* get方法获得数组中的元素,先检查index是否在允许的范围内,然后直接去拿下标为index的元素
*/
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
/**
* set方法修改数组中的元素,先检查index是否在允许的范围内,然后直接替换下标为index的元素
*/
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
E elementData(int index) {
return (E) elementData[index];
}
在增删过程中因为需要移动元素,因此效率大打折扣。
- 当移除元素时,数组会跳过需要移除的元素并复制,然后重新将新的list赋值给自己:
/**
* remove方法移除数组中的元素
*/
public E remove(int index) {
rangeCheck(index);
modCount++; // 此数组被修改次数+1
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
// 跳过需要移除的元素并复制之后的所有元素,然后给到自己的需要移除的元素的位置进行覆盖
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // 将数组最后一位置空,等待GC回收
return oldValue;
}
提一下arraycopy(Object src, int srcPos, Object dest, int destPos, int length) 方法:此方法为java.lang.System类下的一个native方法,第一个参数是源数组;第二个参数为源数组复制的起始位置;第三个参数为目标数组;第思个参数为被复制的部分放置到目标数组的起始位置;最后一个参数为复制的长度。
- 当增加元素时,若总元素数目超出了当前list的最大容量,会进行扩容操作。每次扩容会达到当前容量的1.5倍:
/**
* 添加元素到数组末尾
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 判断是否应该扩容,如果需要,进行扩容
elementData[size++] = e;
return true;
}
/**
* 扩容方法,传入参数为需要装载的最小容量
*/
private void grow(int minCapacity) {
// 之前的数组容量
int oldCapacity = elementData.length;
// 新的容量 = 旧的容量 + 1/2 的旧容量
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果容量足够装所有元素则确定新的容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果容量不够装所有元素则使用 hugeCapacity(minCapacity) 方法处理
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 复制数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
/**
* MAX_ARRAY_SIZE 为 Integer.MAX_VALUE - 8,如果只比array的最大限制大一点点,可以接受;否则只能扩容到 Integer.MAX_VALUE
*/
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
我们总说“如果我们能够知道list的最终大小范围时,合理的设置其初始容量将有利于性能。”在这里得到了合理解释。试想在数组扩容到原容量的1.5倍大小时,会出现一些空置的数组空间。这就意味着通常最后我们获得的这个list所占的空间总是比实际需要的要大。如果你有一个包含大量元素的ArrayList对象, 那么最终将有很大的空间会被浪费。虽然我们有trimToSize()
方法能够在ArrayList分配完毕后干掉数组最后方浪费掉的空间,但由于每次扩容都会对数组进行重新分配,而重新分配的过程比较耗资源,从而导致性能的下降。好的初值设定能够尽可能的避免此问题产生。
LinkedList
LinkedList
是一种基于双向链表形式进行存储的List结构,正和数组相反,增删元素速度快,而查询元素速度较慢。
transient int size = 0;
/**
* 指向链表头部节点
*/
transient Node<E> first;
/**
* 指向链表尾部节点
*/
transient Node<E> last;
/**
* 构造方法为空,初始化后size=0,头尾节点都为空
*/
public LinkedList() {
}
在LinkedList
的类中定义了一个私有静态内部类Node
,每个Node
对象即为LinkedList
中的一个元素。而Node
类中有3个变量:范型的item
变量保存元素的值(地址),prev
和next
变量分别存有此节点的上一个/下一个元素对象。而在LinkedList
中只存有list中的第一项(transient Node<E> first;
)和最后一项(transient Node<E> last;
)。因此LinkedList的存储方式造成了每个元素上的额外开销。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
在查找和修改元素上由于需要对链表进行遍历,因此速度较慢:
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
public E set(int index, E element) {
checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
/**
* 查找index对应节点的底层方法,通过遍历的方式寻找对应下标的节点
*/
Node<E> node(int index) {
// assert isElementIndex(index);
// 这里有一个有意思的点,通过下标是否小于size的一半来选择使用从头到尾遍历还是从尾到头遍历来增加效率
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
在增删元素时,无需移动元素位置,只需改变个别节点的指针指向即可,效率极高。
add(E e)
添加元素到链表尾
public void addLast(E e) {
linkLast(e);
}
/**
* 默认加到链表尾,只要还能分配空间,就会返回true,否则抛出OOM异常
*/
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* 把新元素链到链表末尾
*/
void linkLast(E e) {
// 旧的尾节点
final Node<E> l = last;
// 构造新的尾节点Node对象
final Node<E> newNode = new Node<>(l, e, null);
// 将新的尾节点赋值给LinkedList中的last参数
last = newNode;
// 如果这个链表一开始没有任何元素,旧的尾节点则为空,那么将新的尾节点也赋值给LinkedList中的first参数;否则将旧的尾节点的next节点设置为新加的节点
if (l == null)
first = newNode;
else
l.next = newNode;
// 链表长度+1
size++;
// 链表修改次数+1
modCount++;
}
addFirst(E e)
添加元素到链表头(和添加元素到表尾实现相同)
public void addFirst(E e) {
linkFirst(e);
}
/**
* 把新元素链到链表头部
*/
private void linkFirst(E e) {
// 旧的头节点
final Node<E> f = first;
// 构造新的头节点Node对象
final Node<E> newNode = new Node<>(null, e, f);
// 将新的头节点赋值给LinkedList中的first参数
first = newNode;
// 如果这个链表一开始没有任何元素,旧的头节点则为空,那么将新的头节点也赋值给LinkedList中的last参数;否则将旧的头节点的prev节点设置为新加的节点
if (f == null)
last = newNode;
else
f.prev = newNode;
// 链表长度+1
size++;
// 链表修改次数+1
modCount++;
}
add(int index, E element)
添加元素到指定位置
public void add(int index, E element) {
checkPositionIndex(index); // 检查index是否越界
if (index == size)
linkLast(element); //和 add(E e) 添加到链表末尾相同
else
linkBefore(element, node(index)); // node(index)即前处遍历获得对应节点的方法
}
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
/**
* 把新元素链到指定节点之前
*/
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
// 获得index处节点的前一节点
final Node<E> pred = succ.prev;
// 构造新的节点Node对象,前节点为原index处节点的前一节点,后节点为index处节点
final Node<E> newNode = new Node<>(pred, e, succ);
// 设置index处节点的前一节点为新节点
succ.prev = newNode;
// 若index处原节点的前一节点为空,则index处为头节点,需要把头节点标志位设置成新节点,否则将index处原节点的前一节点的后节点设置为新的节点
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
删除元素,即将当前节点的前后节点的prev/next参数相互关联,并修改LinkedList中的 first/next 节点(可能)。
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
总结
ArrayList和LinkedList各有所长,在细节上,
1.在列表末尾增加一个元素所花的开销都是固定的。对 ArrayList而言,偶尔可能会引发数组的重新分配;而对LinkedList而言,开销一直是分配一个内部Node对象。
2.在ArrayList的 中间增删元素意味着这个列表中剩余的元素都会被移动;而在LinkedList的中间增删节点的开销是固定的。
3.LinkedList不支持高效的随机元素访问。
4.ArrayList的空间浪费主要体现在在list列表的结尾可能会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗更多的空间。
综上, 若经常在一系列数据中间处进行增删操作,使用LinkedList性能更佳,而若查询次数较多且增删总是出现在末尾处,使用ArrayList更强。