为方便开发人员进行程序开发,JDK提供了一组主要的数据结构实现,如List,Map,Set。网上有许多优秀的源码解析,就不再做多余分析。本节主要讨论List结构的使用方法和优化技巧。
List是最重要的数据结构之一。常见又是最重要的三种List实现:ArrayList,Vector,LinkedList。三种List均来自AbstratList的实现,而AbstratList直接实现List接口,并扩展自AbstratCollection。
其中ArrayList和Vector使用了shuzu实现。可以认为ArrayList或者Vector封装了对内部数组的操作。比如向数组中添加、删除、插入新的元素或者数组的扩展和重定义。对ArrayList或者Vector的操作等价对内部对象数据的操作。
ArrayList和Vector几乎使用了相同的算法,他们唯一区别可以认为是对多线程的支持。ArrayList没有仁和一个方法做线程同步,因此不是线程安全。Vector中绝大部分方法都做了线程同步,
是一种线程安全的实现。因为ArrayList和Vector的性能相差无几。从理论上将没有实现线程同步的ArrayList要稍好于Vector。但实际表现并不明显。
LinkedList使用了循环双向链表数据结构。与基于数组的List相比,这是两种截然不同的实现技术,这也决定了他们将适用于完全不同的工作场景。以下是ArrayList和LinkedList的不同比较。
1.增加元素到列表尾端
在ArrayList中增加元素到队列尾端的代码如下:
public boolean add(E e) { ensureCapacityInternal(size + 1); // 确保内部数组有足够的空间 elementData[size++] = e; // 将元素加入到数组的末尾,完成添加 return true; }
ArrayList中的add()方法的性能取决于grow(),实现如下(基于JDK1.8)
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容到原来的1.5倍 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的当前容量足够大,add()操作的效率是非常高的。
当ArrayList对容量的需求超过当前数组的大小时,才需要进行扩容。扩容过程中会进行大量的数组复制操作。而数组复制时,最终将调用System.arraycopy()方法,因此add()操作的效率还是相当高的。
LinkedList的add()操作实现如下,他也将任意元素增加到队列的尾端:
public boolean add(E e) { linkLast(e); // 将元素添加直尾端 return true; }
我们再深入linkLast()方法:
void linkLast(E e) { final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }
可见,LinkedList由于使用了链表的结构,因为不需要维护容量的大小。从这点上说,它比ArrayList有一定的性能优势,然而每次元素增加都需要新建一个Node对象,并进行更多的赋值操作。在频繁的系统调用中,对性能产生一定的影响。
分别使用ArrayList和LinkedList运行一下代码(-Xmx512M-Xms512M):
Object obj = new Object(); for(int i = 0; i < 500000; i++) { // 循环50万次 list.add(obj); }
使用-Xmx512M Xms512M的目的是屏蔽GC对程序执行 速度测量的干扰(稳定的堆空间会减少GC次数,但也会增加每次GC时长)。ArrayList相对耗时16ms,而LinkedList相对耗时31ms。可见,不间断地生成新的对象还是占用了一定的系统资源。
2.增加元素到列表任意位置
除了提供了增加元素到List的尾端,List接口还提供了在任意位置插入元素的方法:
void add(int index, E element);
由于实现上的不同,ArrayList和LinkedList在这个方法上存在一定的性能差异。由于ArrayList是基于数组实现,而数组是一块连续的内存空间,如果在数组的任意位置插入元素,必然导致在该位置后的所有元素需要重新排列,因为效率相对比较低。代码实现如下:
public void add(int index, E element) { rangeCheckForAdd(index); // 检测是否存在越界风险 ensureCapacityInternal(size + 1); // 增加长度 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }
可以看到,每次插入操作,都会进行一次数组复制。而这个操作在增加元素到List尾端的时候是不存在的。大量的数组重组操作会导致系统性能底下。并且插入的元素在List中的位置越靠前,数组的重组开销也越大。
而LinkedList此时就显示出了优势:
public void add(int index, E element) { checkPositionIndex(index); if (index == size) linkLast(element); else linkBefore(element, node(index)); }
可见,对LinkedList来说,在List尾端插入数据与在任意位置插入数据是一样的。并不会因为插入的位置靠前而导致插入方法的性能降低。如果在系统应用中,List对象需要经常在任意位置插入元素,则可以考虑用LinkedList代替ArrayList以提高系统性能。
3.删除任意位置元素
对于元素删除,List接口提供了在任意位置删除元素的方法:
E remove(int index);
对于ArrayList来说,remove()方法和ad()是雷同的,在任意位置移除元素后,都要进行数组的重组,ArrayList的实现如下:
public E remove(int index) { rangeCheck(index); // 是否存在越界风险 modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved > 0) // 将删除位置后面的元素向前移动以为 System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // 最后一个位置设置为null return oldValue; // 返回删除元素 }
可以看到,在ArrayList的每一次有效元素删除操作后,都要进行数组的重组。并且删除的位置越靠前开销越大。
public E remove(int index) { checkElementIndex(index); return unlink(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; } }
在LinkedList的实现中,首先要通过循环找到要删除的位置处于List的前半段,则从前往后找;如其处于后半段,则从后往前找。因此,无论删除靠前还是靠后的元素都非常高效。但要删除较中间的元素几乎要遍历完半个List,在List拥有大量元素的情况下,效率很低。下面是测试代码,所有数据均以一个拥有10万个元素的List上进行,分别对ArrayList和LinkedList从LIst的头部、中间和尾部进行删除。
从头部删除元素代码:
while(list.size() > 0) { list.remove(0); }
从List中间删除元素代码:
while(list.size() > 0) { list.remove(list.size() >> 1); }
从List尾部删除元素:
while(list.size() > 0) { list.remove(list.size() - 1); }
从如下表中可以看到,对于ArrayList从尾部删除元素是效率很高,符合上文分析,从头部删除元素时相当费时;而LinkedList从头尾删除元素时效率相差无几,但是从List中间删除元素时性能非常糟糕。
List类型/删除位置 | 头部 | 中间 | 尾部 |
ArrayList | 6203 | 3125 | 16 |
LinkedList | 15 | 8781 | 16 |
4.容量参数
容量参数是ArrayList和Vector等基于数组的List的特有性能参数,它表示初始化的数组大小。由上文分析可知,当ArrayList所存储的元素数量超过其已有大小时,它便会进行扩容,数组的扩容会导致整个数组进行一次内存复制。因此合理的数组大小有助于减少数组扩容的次数,从而提高系统性能。
ArrayList默认大小为10,每次扩容后数组大小是原来大小的1.5倍。ArrayList也提供了一个可以指定初始数组大小的构造函数。此处未贴上源码,如有需要可自行去看两处构造函数。现以构造一个拥有100万元素的List为例。当使用默认初始大小时,消耗的相对时间为125ms左右,当直接指定其数组大小为100万时,构造相同的ArrayList仅相对耗时16ms。
若指定JVM参数-Xms512M-Xms512M,再进行相同的测试。使用默认大小耗时47ms,指定ArrayList初始容量为100万时,相对耗时16ms。可见通过提升堆内存大小,减少使用初始容量大小时的GC次数,ArrayList扩容时的数组复制,依然占用了较多的CPU时间。
因为,能有效的评估ArrayList的数组大小初始值的情况下,指定容量大小对其性能有较大的提升。
5.遍历列表
遍历列表操作也是常用的列表操作之一。在JDK1.5之后,至少有三种方式:ForEach操作、迭代器和for循环。以下代码实现了三种遍历方式:
String tmp; long start = System.currentTimeMillis(); // foreach遍历 for (String s : list) { tmp = s; } System.out.println("foreach spend:" + (System.currentTimeMillis() - start)); // iterator遍历 start = System.currentTimeMillis(); Iterator<String> it = list.iterator(); while(it.hasNext()) { tmp = it.next(); } System.out.println("iterator spend:" + (System.currentTimeMillis() - start)); // for循环 start = System.currentTimeMillis(); int size = list.size(); for(int i = 0; i < size; i++) { tmp = list.get(i); } System.out.println("for spend:" + (System.currentTimeMillis() - start));
构造一个拥有100万数据的ArrayList和等价的LinkedList,使用以上代码测试,测试结果相对耗时如下:
List类型 | ForEach操作 | 迭代器 | for循环 |
ArrayList | 63ms | 47ms | 31ms |
LinkedList | 63ms | 47ms | ∞ |
可以看到,最简单的ForEach循环并没有很好的性能表现,综合性能不如普通的迭代器,而使用for循环通过随机访问遍历列表时,ArrayList表现很好,但是LinkedList的表现却无法让人接受。因为LinkedList在进行随机访问时,总会进行一次列表的遍历操作。
对ArrayList这些基于数组的实现来说,随机访问速度是很快的,在遍历这些List对象时,可以优先考虑随机访问。但对于基于LinkedList的链表实现,随机访问非常差,应尽量避免使用。