ArrayList和LinkedList对比解析
ArrayList和LinkedList是两个集合类,用于存储一系列的对象引用(references)。
两者虽然皆是List这一接口的实现,但是两者之间不论是功能还是实现方式以及应用的场景都有较大的差异。现在我们通过以下几个方面对其进行对比分析。
实现方式
ArrayList的实现方式是通过维护一个动态数组的形式,来确保对象的引用能按照用户的要求进行存储。
其本质是对以下数组的操作:
private transient Object[] elementData;
而LinkedList的实现方式则是通过维护链表的形式进行,其本质是对一下对象的操作:
//代码来自1.6
private transient Entry header = new Entry(null, null, null);
注意:在1.7中不再使用Entry而是使用Node
而Entry的实现代码如下:
private static class Entry<E> {
//当前节点对应值
E element;
//下一个节点
Entry<E> next;
//上一个节点
Entry<E> previous;
Entry(E element, Entry<E> next, Entry<E> previous) {
this.element = element;
this.next = next;
this.previous = previous;
}
}
Entry通过链接的形式关联引用上一节点和下一节点的对象,而LinkedList中保存了其中的header部分,header本身并不包含任何元素,但是header的next中存储了第一个节点,previous中则存储了最后一个节点,从而形成环状链式的数据结构。
打个比方,如果我们现在有Object1-Object5总计5个对象,当我们存储在ArrayList中时,对象的存储如下图所示:
所有对象都按照顺序存放在数组当中。
而当我们将5个对象按照顺序存储到LinkedList中时,其存储方式则是这样的:
对象除了按照顺序存放外,还需要额外维护一份链表结构。
很明显,数组的存储方式要较链表的存储方式简单的多。
当然两者的实现方式除了在存储上还有其他不同的地方,具体从以下几个方面分析:
初始化
不论是ArrayList还是LinkedList都可以通过new关键字创建,他们的初始化主要还是在构造函数中进行。
在ArrayList的构造函数中可以看到以下代码:
private transient Object[] elementData;
private int size;
//代码来自1.6
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
this.elementData = new Object[initialCapacity];
}
当我们初始化一个ArrayList时,构造函数会为我们默认创建一个数组,数组的长度可以手动指定,也可以是默认值(1.6中默认为10,在1.7中默认为空数组,本文以1.6为准)。
而LinkedList中默认构造函数如下:
//代码拷贝自1.6
private transient Entry<E> header = new Entry<E>(null, null, null);
private transient int size = 0;
/**
* Constructs an empty list.
*/
public LinkedList() {
header.next = header.previous = header;
}
这里创建了一个链表的表头,并且让其next和previous的引用与自身关联。
除了默认构造函数以外,LinkedList和ArrayList还分别实现了基于Collection参数的构造函数
ArrayList实现如下:
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
size = elementData.length;
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
可以看到构造函数中Collection被转换为数组并赋值给elementData。
这里要注意一个问题:注释里提示了c.toArray might (incorrectly) not return Object[](toArray方法有可能不会返回Object数组),具体原因是java对象的向上转型引起的,子类数组转换成父类数组是允许的,但是当我们想要添加父类对象到数组时,会发生错误。
我们用以下代码进行说明:
public static void test1() {
String[] subArray = { new String("1"), new String("2") };
System.out.println(subArray.getClass());
// class [Ljava.lang.String;
// 发生向上转型
Object[] baseArray = subArray;
System.out.println(baseArray.getClass());
// 丢出错误 java.lang.ArrayStoreException
baseArray[0] = new Object();
}
public static void test2() {
List<String> list = Arrays.asList("abc");
// class java.util.Arrays$ArrayList
// 返回的类型是一个内部类,而不是真正的ArrayList
System.out.println(list.getClass());
// class [Ljava.lang.String;
// 发生向上转型
Object[] objArray = list.toArray();
System.out.println(objArray.getClass());
objArray[0] = new Object(); //丢出错误ArrayStoreException
}
public static void test3() {
List<String> dataList = new ArrayList<String>();
dataList.add("one");
dataList.add("two");
Object[] listToArray = dataList.toArray();
// class [Ljava.lang.Object;返回的是Object数组
System.out.println(listToArray.getClass());
// 没有问题
listToArray[0] = "";
// 没有问题
listToArray[0] = 123;
// 没有问题
listToArray[0] = new Object();
}
数组的可存储类型取决于其定义的实际类型,而不是引用类型。
下面是LinkList的对应实现
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
可以看到除了调用默认初始化以外,它还调用了addAll方法,那么addAll方法如下:
public boolean addAll(int index, Collection<? extends E> c) {
if (index < 0 || index > size)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+size);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew==0)
return false;
modCount++;
//默认情况下index和size都是0,取得的必定是header
Entry<E> successor = (index==size ? header : entry(index));
//获取了successor的上一个节点predecessor
Entry<E> predecessor = successor.previous;
for (int i=0; i<numNew; i++) {
//将元素作为中间节点插入到successor和predecessor之间
Entry<E> e = new Entry<E>((E)a[i], successor, predecessor);
//将原来上一节点的next由successor 改为当前新建的节点
predecessor.next = e;
//当前新建节点赋值为上一节点,以备下一节点新建时使用
predecessor = e;
}
//successor (Header)的上一节点变更为最后一个新建节点
successor.previous = predecessor;
size += numNew;
return true;
}
从内容可以看出,在使用默认构造函数时,不论ArrayList还是LinkedList其复杂度都不高,但是当使用Collection参数的构造函数时,LinkedList的复杂度要稍高于ArrayList。
新增和删除
下面我们通过新增和删除这方面来看看两者的区别
首先来看一下ArrayList中的新增和移除方法:
//新增到最后
public boolean add(E e) {
//总含量在原有基础上+1
ensureCapacity(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void ensureCapacity(int minCapacity) {
modCount++;
int oldCapacity = elementData.length;
//如果最小容量要比当前容量大,那么就要实施扩容
if (minCapacity > oldCapacity) {
Object oldData[] = elementData;
//在原有容量基础上扩容1.5倍+1
int newCapacity = (oldCapacity * 3)/2 + 1;
//如果不够就按照最小容量扩容
if (newCapacity < minCapacity)
newCapacity = minCapacity;
// minCapacity is usually close to size, so this is a win:
//按照新容量对数组进行扩容
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
//插入
public void add(int index, E element) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(
"Index: "+index+", Size: "+size);
//扩容
ensureCapacity(size+1); // Increments modCount!!
//数组数据移位,比如index为5,那么把5以后的所有数据移动到后一位,5变为6,6变为7以此类推
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
//最后对应的index中填充需要插入的对象引用
elementData[index] = element;
size++;
}
//按照index移除
public E remove(int index) {
RangeCheck(index);
modCount++;
E oldValue = (E) elementData[index];
//确定需要移动的位置,如果是最后一个,则是0
int numMoved = size - index - 1;
//移位,index+1以后所有的数据移动一个单位,最后一个单位保留
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
//将最后一个单位置空
elementData[--size] = null; // Let gc do its work
return oldValue;
}
//按照对象移除
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
//相等的情况下,移除对应index引用
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
//快速移除
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
//移位
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
//置空
elementData[--size] = null; // Let gc do its work
}
从上述代码中可以看出:
1.如果新增一个引用到末尾,要比插入一个引用到指定位置要快得多
2.插入的引用越靠后,速度越快,因为移动的数量越靠后越少
3.按照index移除要比按照对象移除的效率高,因为对象需要先循环查找对应的index,移除时,同样是越靠后效率越高。
再来看LinkedList的新增和移除方法
//新增到末尾
public boolean add(E e) {
addBefore(e, header);
return true;
}
//插入
public void add(int index, E element) {
addBefore(element, (index==size ? header : entry(index)));
}
//按照index移除
public E remove(int index) {
return remove(entry(index));
}
//根据index获取链表容器
private Entry<E> entry(int index) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+size);
Entry<E> e = header;
//在前半段,那么从0开始循环,靠近后半段,则从后面开始循环
if (index < (size >> 1)) {
for (int i = 0; i <= index; i++)
e = e.next;
} else {
for (int i = size; i > index; i--)
e = e.previous;
}
return e;
}
//按照对象移除
public boolean remove(Object o) {
if (o==null) {
for (Entry<E> e = header.next; e != header; e = e.next) {
if (e.element==null) {
remove(e);
return true;
}
}
} else {
for (Entry<E> e = header.next; e != header; e = e.next) {
if (o.equals(e.element)) {
remove(e);
return true;
}
}
}
return false;
}
//添加到对应节点的前面
private Entry<E> addBefore(E e, Entry<E> entry) {
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
size++;
modCount++;
return newEntry;
}
//移除当前节点
private E remove(Entry<E> e) {
if (e == header)
throw new NoSuchElementException();
E result = e.element;
e.previous.next = e.next;
e.next.previous = e.previous;
e.next = e.previous = null;
e.element = null;
size--;
modCount++;
return result;
}
从以上代码可以看出:
1.不论是添加到第一个还是添加到最后一个LinkedList的速度都不会慢
2.插入时,越靠近中间,LinkedList的插入速度越慢,因为要循环获取插入节点对应的下一节点信息
3.按照index移除时同样因为要循环获取下一节点信息,所以,越靠近中间越慢,但是按照对象移除时,由于是全循环,所以越靠最后越慢
下面用以下代码对得出的结论进行测试:
public class TestList {
public static void main(String[] args) {
testList(new ArrayList<String>());
testList(new LinkedList<String>());
}
private static void print(String str, long lastTime) {
System.out
.println(str + (System.currentTimeMillis() - lastTime) + "ms");
}
public static int addTest(List<String> list) {
long lastTime = System.currentTimeMillis();
int index = 0;
for (int i = 10000; i < 20000; i++) {
list.add("" + i);
index++;
}
// 9999-19999
print("新增耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 30000; i < 39999; i++) {
list.add(list.size() - 1, "" + i);
index++;
}
// 30000-39999
print("插入到尾耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 20000; i < 29999; i++) {
list.add(list.size() / 2, "" + i);
index++;
}
// 20000-29999
print("插入到中间耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
list.add(0, "" + i);
index++;
}
// 0-9999
print("插入到头耗时:", lastTime);
return index;
}
public static void removeByObjectTest(List<String> list, int index) {
long lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
list.remove("" + --index);
}
// 移除30000-39999
print("按照对象从尾移除耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
list.remove("" + i);
index--;
}
// 移除0-9999
print("按照对象从头移除耗时:", lastTime);
int first = Integer.valueOf(list.get(0));
int last = Integer.valueOf(list.get(list.size() - 1));
int middle = (last - first) / 2 + first;
boolean flag = true;
int goLeft = middle;
int goRight = middle + 1;
lastTime = System.currentTimeMillis();
for (int i = 0; i <= 10000; i++) {
if (flag) {
flag = false;
list.remove("" + goLeft--);
} else {
flag = true;
list.remove("" + goRight++);
}
index--;
}
// 移除10000-29999中间的10000个数
print("按照对象从中间移除耗时:", lastTime);
}
public static void removeByIndexTest(List<String> list, int index) {
long lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
list.remove(--index);
}
print("按照index从最后移除耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
list.remove(list.size() / 2);
index--;
}
print("按照index从中间移除耗时:", lastTime);
lastTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
index--;
list.remove(0);
}
print("按照index从头移除耗时:", lastTime);
}
public static void testList(List<String> list) {
System.out.println(list.getClass());
removeByIndexTest(list, addTest(list));
list.clear();
removeByObjectTest(list, addTest(list));
}
}
打印结果:
class java.util.ArrayList
新增耗时:9ms
插入到尾耗时:4ms
插入到中间耗时:17ms
插入到头耗时:44ms
按照index从最后移除耗时:1ms
按照index从中间移除耗时:16ms
按照index从头移除耗时:20ms
新增耗时:1ms
插入到尾耗时:2ms
插入到中间耗时:18ms
插入到头耗时:44ms
按照对象从尾移除耗时:1099ms
按照对象从头移除耗时:172ms
按照对象从中间移除耗时:105ms
class java.util.LinkedList
新增耗时:2ms
插入到尾耗时:3ms
插入到中间耗时:873ms
插入到头耗时:2ms
按照index从最后移除耗时:1ms
按照index从中间移除耗时:921ms
按照index从头移除耗时:0ms
新增耗时:1ms
插入到尾耗时:1ms
插入到中间耗时:811ms
插入到头耗时:1ms
按照对象从尾移除耗时:1994ms
按照对象从头移除耗时:225ms
按照对象从中间移除耗时:109ms
通过以上数据作为对比,可以得出以下结论:
1.如果仅仅只是需要进行单纯的新增操作,无论ArrayList和LinkedList都适合,但是如果需要对列表两端进行新增操作,那么LinkedList要不ArrayList要有效率的多。
2.如果要对列表的中间部分按照索引进行新增或移除,那么相对而言,ArrayList的效率要高(不能一概而论,LinkedList越靠近两端速度越快)
3.不管是ArrayList还是LinkedList都要尽量避免直接按照对象进行移除。
随机访问
列表的随机访问主要通过get和set两个方法反映
在ArrayList中,其实现方式如下:
public E get(int index) {
RangeCheck(index);
//直接获取对应数组中的引用
return (E) elementData[index];
}
public E set(int index, E element) {
RangeCheck(index);
//获取原来存放的引用
E oldValue = (E) elementData[index];
//放入新的引用
elementData[index] = element;
//返回旧的对象
return oldValue;
}
可以看出ArrayList中不论是get还是set方法,其操作都是针对数组的索引,所以效率非常高。
再看看LinkedList:
public E get(int index) {
//循环获取对应index的容器
return entry(index).element;
}
public E set(int index, E element) {
//循环获取对应index的容器
Entry<E> e = entry(index);
E oldVal = e.element;
e.element = element;
return oldVal;
}
private Entry<E> entry(int index) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+size);
Entry<E> e = header;
if (index < (size >> 1)) {
for (int i = 0; i <= index; i++)
e = e.next;
} else {
for (int i = size; i > index; i--)
e = e.previous;
}
return e;
}
可以看出,无论是get还是set必须按照index找到对应的容器对象才能进行操作,而其查找的方式是通过for循环从两端进行查找,相对效率要低下很多。
我们可以在上面的测试类基础上新增以下代码验证:
public static void testRandomVisit(List<String> list) {
System.out.println(list.getClass());
addTest(list);
getTest(list);
}
public static void getTest(List<String> list) {
Random ran = new Random();
long lastTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list.get(ran.nextInt(list.size() - 1));
}
print("随机访问:", lastTime);
}
打印结果:
class java.util.ArrayList
新增耗时:10ms
插入到尾耗时:4ms
插入到中间耗时:20ms
插入到头耗时:51ms
随机访问:9ms
class java.util.LinkedList
新增耗时:3ms
插入到尾耗时:4ms
插入到中间耗时:837ms
插入到头耗时:2ms
随机访问:6978ms
随机访问的差距十分明显,由此,可以得出结论:ArrayList的随机访问的效率要高于LinkedList
功能
对比了两者的实现方式之后,在来看看两者的实现功能有何不同
首先,通过源代码可以看到,LinkedList继承自AbstractSequentialList,这个抽象类又是继承自AbstractList,而ArrayList直接继承自AbstractList,两者的区别主要是AbstractSequentialList的get,set,add,remove,addAll方法都是通过listIterator方法间接实现的,而由于LinkedList是链式结构,所以相对的他的iterator迭代器相对相率要高。
在来看看两者的接口:
RandomAccess
ArrayList实现了RandomAccess接口,这个接口是一个标记接口(Marker),它没有任何方法,主要是被List的实现类(子类)使用。
如果List子类实现了RandomAccess接口,那就表示它能够快速随机访问存储的元素。RandomAccess接口的意义在于:在对列表进行随机或顺序访问的时候,访问算法能够选择性能最佳方式。
所以在使用ArrayList进行循环的时候最佳的方式应当是:
for (int i=0; i<list.size(); i++)
list.get(i);
Deque
LinkedList实现了Deque接口,所以它其实也是一个双端队列,在拥有List的特性的同时,也具有队列的特性,所以如果LinkedList作为队列使用相对较为灵活。
Cloneable
不论是ArrayList还是LinkedList都实现了克隆接口,所以他们都支持对象的copy,但是其copy的内容仅限于存储的对象引用,也就是只能进行浅克隆。
Serializable
两者都实现了序列化接口,但是实际上要序列化要是需要内部对象实现Serializable 接口
应用场景
在对比了两者的实现以及功能后得出的结论,那么再来看看两者分别适合在哪些场景中使用:
循环
ArrayList在循环时更适合按照索引循环,而不是按照迭代器循环
与之相反LinkedList更适合迭代循环
新增
当我们需要新增数据时,理论上两者都是可用的,但是如果需要新增到头部,则推荐LinkedList,如果是随机位置还是推荐使用ArrayList,因为虽然ArrayList需要进行数据位置的调整和扩容,并且越是考前,效率越差,但是相对于LinkedList的循环查找后新增的效率还是相对更高一些
修改
如果要替换中间的数据,那么总体而言ArrayList效率要高一些,虽然LinkedList越靠近两端,效率越高,但是在实际生产环境中一般并不能保证总是修改靠近两端的数据。
移除
如果通过index移除,LinkedList中index越是靠近两端其效率越高,ArrayList中index越是靠近后端效率越高,但是如果通过对象引用操作,那么基本上差距不大
获取
通过index获取对象引用时,ArrayList效率远高于LinkedList。
小结
通过以上结论可以知道,ArrayList和LinkedList虽然都是List接口的实现,但是事实上两者存在相当大的差别,在具体的生产环境中,还是应当根据特定的环境去使用。