Java List接口

说明:本文是阅读《Java程序性能优化》(作者:葛一明)一书中关于List接口一节的笔记。


一、基本概念

1、List接口常用的三个实现

List接口以及该接口常用的三个实现等相关类图如下:


  • ArrayList与Vector在这三种不同的实现中,ArrayList与Vector使用了数组来实现,所以可以认为它们是封装了对内部数组的操作,所以对它们的操作等价于对内部对象数据的操作。而且ArrayList和Vector几乎使用了相同的算法,它们的唯一区别在于是否对多线程的支持,ArrayList中没有对任何一个方法做线程同步,所以ArrayList不是线程安全的;而Vector中绝大部分方法都做了线程同步,是一种线程安全的实现。所以在性能方面,从理论上说,没有实现线程安全的ArrayList要稍好于实现了线程安全的Vector,但实际表现并不是那么明显,因此,ArrayList与Vector在性能方面相差不大。
  • LinkedList:LinkedList使用了循环双向链表的数据结构来实现,而且无论LinkedList中是否有元素,链表内部都有一个头结点。
二、基本操作
由于ArrayList与Vector差别不大,所以这里用ArrayList和LinkedList来说明一些基本的操作与优化。
1、添加元素到列表尾部
  • ArrayList在ArrayList中可以使用“public boolean add(E e)”方法将元素添加到列表尾部,在add方法的实现中有“ensureCapacity(size + 1)”这么一句代码,它确保了内部数组有足够的空间来存储列表的元素,而add方法的性能也主要取决于这句代码,如下图所示是ensureCapacity方法的实现,当数组容量不够时会进行扩容,然后进行数组的复制,所以只要ArrayList当前的容量足够大,其add操作的效率是非常高的;如果跟踪方法的调用,会发现在进行数组扩容并复制时,最终会调用System.arraycopy()方法来进行数组的复制,所以add()操作的效率还是很高的。

  • LinkedList在LinkedList中同样可以使用“public boolean add(E e)”方法将元素添加到列表尾部,由于LinkedList使用了带头结点的循环双向链表的数据结构,所以当添加元素到列表尾部时,将元素作为头结点的前驱结点即表示添加元素到列表尾部,而且它不需要维护容量的大小(相比ArrayList在这点上有一定的性能优势),但是每次元素的添加都会生成一个元素结点,在LinkedList的实现中就是生成了一个Entry对象(LinkedList的一个内部类),然后进行更多的赋值操作,如果是频繁调用,则对性能会产生一定的影响。
  • 性能比较:如下代码所示,使用虚拟机参数"-Xmx512M -Xms512M"来运行程序(屏蔽GC对程序执行速度测量的干扰),在我的机器上得到的运行时间大概是250ms和800ms,可见LinkedList不间断的产生新的对象(Entry)还是占用了一定的系统资源;如果不使用这些虚拟机参数而使用JVM默认的堆大小,得到的运行时间的差异会更大,可见使用LinkedList对堆内存和GC的要求更高。
List<Integer> arrayList = new ArrayList<Integer>();
List<Integer> linkedList = new LinkedList<Integer>();
		
long start = System.currentTimeMillis();
		
for (int i = 0; i < 5000000; i++) {
	arrayList.add(i);
}
		
long end = System.currentTimeMillis();
System.out.println(end - start);
		
start = System.currentTimeMillis();
		
for (int i = 0; i < 5000000; i++) {
	linkedList.add(i);
}
		
end = System.currentTimeMillis();
System.out.println(end - start);
2、添加元素到列表任意位置
列表的任意位置插入元素可以使用void add(int index, E element)方法。
  • ArrayList:由于ArrayList是基于数组来实现的,而数组是一块连续的内存空间,在列表的任意位置插入元素就可能会(非列表尾部)导致在该位置后的所有元素统一向后移动,所以其效率相对会很低。在ArrayList的实现当中该方法的实现如下:从中可以发现,每次插入操作都会进行一次数组复制,大量的数组重组操作会导致性能低下,而且插入的元素的位置越靠前其开销越大,所以尽可能将元素插入到列表的尾部附件,有助于提高该方法的性能。

  • LinkedList:对于LinkedList来说,插入元素到尾部和插入元素到任意位置都是一样的,不过修改几个指向而已,并不会因为插入的位置靠前而导致插入方法的性能低下。
  • 性能比较:如下代码所示,在我的机器上执行大概使用了1300ms和5ms左右的时间,差异不是一般的大。
List<Integer> arrayList = new ArrayList<Integer>();
List<Integer> linkedList = new LinkedList<Integer>();
		
long start = System.currentTimeMillis();
		
for (int i = 0; i < 50000; i++) {
	// 插入到列表开头,数组重组的开销最大,性能低下
	arrayList.add(0, i);
}
		
long end = System.currentTimeMillis();
System.out.println(end - start);
		
start = System.currentTimeMillis();
		
for (int i = 0; i < 50000; i++) {
	// 也是插入到列表的开头,但是仅仅修改几个指向而已,性能高
	linkedList.add(0, i);
}
		
end = System.currentTimeMillis();
System.out.println(end - start);
3、删除列表任意位置的元素
可以使用remove(int index)方法来删除任意位置的元素。
  • ArrayList:对于ArrayList来说,在任意位置删除元素和插入元素到任意位置是类似的,都需要进行数组的重组。ArrayList的该方法实现如下,只要不是删除最后一个元素,每一次有效的删除操作都会进行数组的重组,且删除的元素位置越靠前,则重组时的开销就越大;删除的元素位置越靠后,重组的开销就越小。

  • LinkedList:对于LinkedList来说,在它的实现中会直接调用remove(entry(index))方法来执行删除任意位置的元素,而entry方法的实现如下,从实现中来看,LinkedList在删除任意位置的元素时会进行循环来找到要删除的元素,但是它不会一定是从头开始循环,它会根据要删除的元素的位置是位于列表的前半段还是后半段来决定循环的起始,如果是位于前半段,则从前往后循环;如果是位于后半段,则从后往前循环。所以对于要删除比较靠前或者比较靠后的元素是非常高效的,但是如果要删除中间部分附件位置的元素,则几乎都要遍历半个列表,此种情况下,如果列表有大量的元素,则效率会很低下。但是相比ArrayList来说,它没有数组重组的开销,这是它的优势。

  • 性能比较:如下代码所示,ArrayList与LinkedList均拥有100000个元素,分别从头部、中部和尾巴来进行元素的删除,直到列表为空。在我的机器上,如果是ArrayList,用时分别大概是5000ms、2500ms、5ms;而如果是LinkedList,用时分别大概是5ms、25000ms(这太耗时了)、5ms。从数值上可以看出,对于ArrayList从尾部删除元素时效率很高,而从头部删除很费时;对于LinkedList,从头、尾删除元素时效率很高,相差无几,但是从中间删除元素时,效率实在是太低了。
List<Integer> list = new ArrayList<Integer>();
// List<Integer> list = new LinkedList<Integer>();

for (int i = 0; i < 100000; i++) {
	list.add(i);
}

long start = System.currentTimeMillis();
// 在头部删除元素
while (0 < list.size()) {
	list.remove(0);
}
long end = System.currentTimeMillis();
System.out.println(end - start);

for (int i = 0; i < 100000; i++) {
	list.add(i);
}

start = System.currentTimeMillis();
// 在中部删除元素
while (0 < list.size()) {
	list.remove(list.size() >> 1);
}
end = System.currentTimeMillis();
System.out.println(end - start);

for (int i = 0; i < 100000; i++) {
	list.add(i);
}

start = System.currentTimeMillis();
// 在尾部删除元素
while (0 < list.size()) {
	list.remove(list.size() - 1);
}
end = System.currentTimeMillis();
System.out.println(end - start);
4、容量参数
容量参数是ArrayList与Vector等基于数组实现方式的列表的特有参数,表示初始化时数组大小,当ArrayList等容量不足以容纳新的元素时则会进行数组的扩容,导致整个数组进行一次内存复制,所以设置合理的初始大小,有助于避免更多次数的数组扩容,从而提高使用性能。默认情况下,ArrayList数组的初始大小为10,每次扩容会将大小设置为原来数组大小的1.5倍。
如下代码所示,使用默认的初始大小来构造一个拥有1000000个元素的列表时,用时大概是200ms左右,而如果直接指定初始大小为1000000后,用时大概是140ms左右;如果再使用"-Xmx512M -Xms512M"的虚拟机参数来执行时,使用默认初始大小时,用时大概60ms,而如果指定初始大小为1000000后,用时大概是35ms,可见,即使通过提高堆内存大小,减少使用初始容量大小时的GC次数,ArrayList扩容时的数组复制,依然占用了较多的CPU时间。
List<Integer> list = new ArrayList<Integer>(1000000);

long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
	list.add(i);
}
long end = System.currentTimeMillis();
System.out.println(end - start);
5、遍历列表
当要遍历一个List时,至少可以使用三种方式:foreach、Iterator迭代、for循环,如下代码所示,分别使用了这三种方式来遍历一个拥有1000000元素的列表,对于ArrayList来说,分别用时大概50ms、45ms、15ms;而对于LinkedList来说,分别用时大概30ms、25ms、没能等待到最后的结果;通过比较发现,两种列表的foreach遍历方式性能都不如Iterator遍历方式,而对于for循环通过随机访问遍历ArrayList时,性能是三种方式中最好的,但是对于LinkedList来说,实在是太糟糕了,这是因为对LinkedList进行随机访问时都会进行一次列表的遍历操作。所以对基于数组实现的列表来说,随机访问是很快的,在遍历时可以优先考虑随机访问,对于基于链表来实现的列表,千万不要使用随机访问方式来进行遍历。
List<Integer> list = new ArrayList<Integer>();
// List<Integer> list = new LinkedList<Integer>();
for (int i = 0; i < 1000000; i++) {
	list.add(i);
}
Integer tmp = null;

long start = System.currentTimeMillis();
// foreach
for (Integer i : list) {
	tmp = i;
}
long end = System.currentTimeMillis();
System.out.println(end - start);

start = System.currentTimeMillis();
// Iterator迭代
for (Iterator<Integer> iter = list.iterator(); iter.hasNext(); ) {
	tmp = iter.next();
}
end = System.currentTimeMillis();
System.out.println(end - start);

start = System.currentTimeMillis();
// for循环
int size = list.size();
for (int i = 0; i < size; i++) {
	tmp = list.get(i);
}
end = System.currentTimeMillis();
System.out.println(end - start);
最后,书上说通过反编译工具得到的结果是反编译后会发现foreach遍历方式会被解析成Iterator迭代的方式,只不过会增加多于的一步,把得到的元素赋值给一个局部变量,所以foreach遍历方式与Iterator迭代方式是等价的,只不过在foreach中存在一步多于的操作,从而导致foreach循环的性能比直接使用Iterator迭代方式要略差一点。但是我通过反编译工具JD-GUI还是未能得到这种结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值