概要
从数据结构的角度来说,这两种实现都属于线性表结构的具体实现,ArrayList属于线性表的顺序存储结构具化实现,Linkedlist属于线性表的链式存储结构具化实现。
线性表有两种物理结构
1. 顺序存储结构
指的是用一段地址连续的存储单元依次存储线性表的数据元素**
顺序存储的优缺点
优点:
1.无须为表示表中元素之间的逻辑关系额外增加存储空间
2.快速的存取表中任一位置的元素
缺点:
1.插入和删除需要移动大量元素
2.线性表长度变化较大时,难以确定存储空间
3.造成存储空间的碎片
2. 链式存储结构
指的是用一组任意的存储单元存储线性表的数据元素,这些存储单元可以是连续的,也可以不连续,这就意味着,这些数据元素可以存储在内存未被占用的任意位置
链式存储的优缺点:
链式存储结构更多是解决顺序存储结构中插入和删除时需要移动大量数据,耗费大量时间和空间的另一种方式
两种方式的对比:
java语言实现
ArrayList内部使用了数组实现,提到ArrayList就不得不提到他的兄弟Vector,这两者之间的实现基本类似,唯一的区别是前者线程不安全,后者线程安全。至于ArrayList为什么线程不安全,参见下一篇文。
LinedList内部使用了双向链表,同样也是线程不安全,需要保证线程安全请用ConcurrentLinkedQueue。
这两种数据结构的内部不同实现就决定了它们在应用场景上侧重不同。
通过代码发现,ArrayList不像广义线性表中那样需要预先分配存储空间,支持插入时动态扩容(需要多消耗当前存储空间的多一倍内存),或者初始化时指定.
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
}
//这里是新增时,判断空间大小是否足够,如果不足够,则进行扩容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
LinkedList在内部实现了一个Node类,next指向下一节点,prev指向上一节点,item维护着当前节点的值,下面的图描述了节点之间的关系,代码则是对应的实现.
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;
}
}
网上有很多这两者之间的比较,大多数只提供了结论,而且很多结论都是笼统没有区分场景的,没有分析为什么会这样,下面从线性表的定义和具体语言上的代码实现来分析为什么会这样.
先贴结论:
-
新增:两种方式没有区别
-
插入和删除:LinkedList优于ArrayList
-
随机访问:ArrayList优于LinkedList
这里的新增和插入的定义是,新增借鉴了java语言对着两个结构提供的默认新增方法,即末尾插入,插入代表的是指定 i 位置插入 x 元素
下面来对比:1.序新增:
- ArrayList的实现public boolean add(E e) { //判断内部空间是否充足,如果不充足,会进行扩容操作 ensureCapacityInternal(size + 1); // Increments modCount!! //把添加的元素执行到数组尾部 elementData[size++] = e; return true; } public void ensureCapacity(int minCapacity) { //可以看出,在没有指定容量的时候,第一次新增会去默认容量 if (elementData == EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } modCount++; //这时候默认容量肯定比初始化的数组长度大,也就是说没有指定容量,第一次新增会执行一次扩容操作 if (minCapacity - elementData.length > 0) // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); }
通过上面的代码可以发现,代码的复杂度在扩容处,也就是说如果ArrayList指定的容量足够大,新增的效率还是很高的,当容量不足时,会执行System.arraycopy 进行数组的复制操作,这样的话,ArrayList本身的元素个数越多,对象越大,对内存和性能的损耗越大。
-
LinkedList的实现
public boolean add(E e) { //复制当前最后一个节点的指针 final Node<E> l = last; //新增元素节点 final Node<E> newNode = new Node<>(l, e, null); //新增节点指向末级节点 last = newNode; if (l == null) //LinkedList刚初始化的时候,末级节点为空,所以新增节点既是末级也是首位 first = newNode; else //末级节点的下一节点指针指向新增节点 l.next = newNode; size++; modCount++; return true; } 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; } } 通过这两个类的新增方法,我们可以看出,在默认新增这一块,ArrayList在末尾新增的时候基本上没有消耗,如果不考虑扩容(ArrayList扩容时,会占用当前使用空间至少两倍的内存,消耗大量时间复制),而LinkedList则是需要初始化一个Node对象,然后把前后指针更改.相对来说, 如果知道线性表的大小,两者之间的效率差距并不明显 下面我们通过测试来验证上面的结论对不对: private static final Integer MAX_SIZE = 1000000; public static void add(List<ListTest> list) { StopWatch clock = new StopWatch(); clock.start(); for (int i = 0; i < MAX_SIZE; i++) { list.add(new ListTest()); } clock.stop(); System.out.println(list.getClass()+":当前消耗时间:" + clock.getTime()); } public static void main(String[] args) { List<ListTest> arrayList = new ArrayList<ListTest>(); add(arrayList); List<ListTest> linkedList=new LinkedList<ListTest>(); add(linkedList); } 最终输出: class java.util.ArrayList:当前消耗时间:243 class java.util.LinkedList:当前消耗时间:298
-
-
随机新增,上面提到了随机新增哪种方式最优取决于新增的位置,实际上第一种新增就等于末尾新增,这里的随机新增就仅仅包含首节点新增,和随机位置新增.下面我们先把这两种数据结构的实现方式贴出来,然后通过实现上来说明消耗点在哪
1. ArrayList
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
从这里我们看出,当指定节点位置新增的时候,ArrayList每次都会把当前指定位置的数组移动到指定位置加1的地方,也就是说会自索引后往后顺移,留出指定位置,以便新增,这就带来了大量元素的移动,
2. LinkedList:
public void add(int index, E element) {
{
checkPositionIndex(index);
//判断指定位置是否等于节点大小,等于直接追加到末尾
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
}
Node<E> node(int index) {
// assert isElementIndex(index);
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;
}
}
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
通过代码我们看,LinkedList通过先判断指定的位置是否等于当前节点大小判断执行何种逻辑,等于的情况下实际上就会执行默认的新增方法.不等于的情况下,就会去搜索指定位置的节点, 更换对应的指针.
方法复杂度主要是 node(index) 这个方法里面,这个方法里面 ,实际上是通过二分查找算法来查找当前指定位置的节点的,也就是说,指定的位置越靠近中序 ,所花费的时间越长.
贴上对比图(首位新增)
private static final Integer MAX_SIZE = 10000;
public static void add(List<ListTest> list) {
StopWatch clock = new StopWatch();
clock.start();
for (int i =1; i < MAX_SIZE; i++) {
list.add(0,new ListTest());
}
clock.stop();
System.out.println(list.getClass()+":当前消耗时间:" + clock.getTime());
}
public static void main(String[] args) {
List<ListTest> arrayList = new ArrayList<ListTest>();
add(arrayList);
List<ListTest> linkedList=new LinkedList<ListTest>();
add(linkedList);
}
class java.util.ArrayList:当前消耗时间:25
class java.util.LinkedList:当前消耗时间:2
随机新增:
private static final Integer MAX_SIZE = 10000;
public static void add(List<ListTest> list) {
StopWatch clock = new StopWatch();
clock.start();
for (int i =1; i < MAX_SIZE; i++) {
list.add(new Random().nextInt(i),new ListTest());
}
clock.stop();
System.out.println(list.getClass()+":当前消耗时间:" + clock.getTime());
}
public static void main(String[] args) {
List<ListTest> arrayList = new ArrayList<ListTest>();
add(arrayList);
List<ListTest> linkedList=new LinkedList<ListTest>();
add(linkedList);
}
class java.util.ArrayList:当前消耗时间:23
class java.util.LinkedList:当前消耗时间:112
通过上面这么多的验证,我们了解了这两种数据结构在新增场景的优劣, 但实际生产时, 我们需要考虑的不仅仅是测试的这些参数 ,上面说到,ArrayList在指定位置新增时和顺序新增扩容时,会使用System.arraycopy的方法, 这个方法会导致什么呢?如果ArrayList里面存储的对象都很大,在和LinkedList同样的条件下,考虑系统整体的性能,内存消耗,又是哪种更适合呢?
说了这么多,只是想表面,我们的系统不是空中楼阁,需要与硬件,场景挂钩,取舍的目的在于你需要什么,你的系统需要什么.在实际工作具体选择哪种,需要我们的工程师去衡量自己的场景.思考往往是进步的开始.