首页 » 编写高质量代码:改善Java程序的151个建议 » 编写高质量代码:改善Java程序的151个建议全文在线阅读

《编写高质量代码:改善Java程序的151个建议》建议68:频繁插入和删除时使用LinkedList

关灯直达底部

上一个建议介绍了列表的遍历方式,也就是“读”操作,本建议将介绍列表的“写”操作:即插入、删除、修改动作。

(1)插入元素

列表中我们使用最多的是ArrayList,下面来看看它的插入(add方法)算法,源代码如下:


public void add(int index, E element){

/*检查下标是否越界,代码不再拷贝*/

//若需要扩容,则增大底层数组的长度

ensureCapacity(size+1);

//给index下标之后的元素(包括当前元素)的下标加1,空出index位置

System.arraycopy(elementData, index, elementData, index+1,size-index);

//赋值index位置元素

elementData[index]=element;

//列表长度+1

size++;

}


注意看arraycopy方法,只要是插入一个元素,其后的元素就会向后移动一位,虽然arraycopy是一个本地方法,效率非常高,但频繁的插入,每次后面的元素都要拷贝一遍,效率就变低了,特别是在头位置插入元素时。现在的问题是,开发中确实会遇到要插入元素的情况,那有什么更好的方法解决此效率问题吗?

有,使用LinkedList类即可。我们知道LinkedList是一个双向链表,它的插入只是修改相邻元素的next和previous引用,其插入算法(add方法)如下:


public void add(int index, E element){

addBefore(element,(index==size?header:entry(index)));

}


这里调用了私有addBefore方法,该方法实现了在一个元素之前插于元素的算法,代码如下:


private Entry<E>addBefore(E e, Entry<E>entry){

//组装一个新节点,previous指向原节点的前节点,next指向原节点

Entry<E>newEntry=new Entry<E>(e, entry, entry.previous);

//前节点的next指向自己

newEntry.previous.next=newEntry;

//后节点的previous指向自己

newEntry.next.previous=newEntry;

//长度+1

size++;

//修改计数器+1

modCount++;

return newEntry;

}


这是一个典型的双向链表插入算法,把自己插入到链表,然后再把前节点的next和后节点的previous指向自己。想想看,这样一个插入元素(也就是Entry对象)的过程中,没有任何元素会有拷贝过程,只是引用地址改变了,那效率当然就高了。

经过实际测试得知,LinkedList的插入效率比ArrayList快50倍以上。

(2)删除元素

插入了解清楚了,我们再来看删除动作。ArrayList提供了删除指定位置上的元素、删除指定值元素、删除一个下标范围内的元素集等删除动作,三者的实现原理基本相似,都是找到索引位置,然后删除。我们以最常用的删除指定下标的方法(remove方法)为例来看看删除动作的性能到底如何,源码如下:


public E remove(int index){

//下标校验

RangeCheck(index);

//修改计数器+1

modCount++;

//记录要删除的元素值

E oldValue=(E)elementData[index];

//有多少个元素向前移动

int numMoved=size-index-1;

if(numMoved>0)

//index后的元素向前移动一位

System.arraycopy(elementData, index+1,elementData, index, numMoved);

//列表长度减1,并且最后一位设为null

elementData[--size]=null;

//返回删除的值

return oldValue;

}


注意看,index位置后的元素都向前移动了一位,最后一个位置空出来了,这又是一次数组拷贝,和插入一样,如果数据量大,删除动作必然会暴露出性能和效率方面的问题。ArrayList其他的两个删除方法与此相似,不再赘述。

我们再来看看LinkedList的删除动作。LinkedList提供了非常多的删除操作,比如删除指定位置元素、删除头元素等,与之相关的poll方法也会执行删除动作,下面来看最基本的删除指定位置元素的方法remove,源代码如下:


private E remove(Entry<E>e){

//取得原始值

E result=e.element;

//前节点next指向当前节点的next

e.previous.next=e.next;

//后节点的previouse指向当前节点的previous

e.next.previous=e.previous;

//置空当前节点的next和previous

e.next=e.previous=null;

//当前元素置空

e.element=null;

//列表长度减1

size--;

//修改计数器+1

modCount++;

return result;

}


这也是双向链表的标准删除算法,没有任何耗时的操作,全部是引用指针的变更,效率自然高了。

在实际测试中得知,处理大批量的删除动作,LinkedList比ArrayList快40倍以上。

(3)修改元素

写操作还有一个动作:修改元素值,在这一点上LinkedList输给了ArrayList,这是因为LinkedList是顺序存取的,因此定位元素必然是一个遍历过程,效率大打折扣,我们来看set方法的代码:


public E set(int index, E element){

//定位节点

Entry<E>e=entry(index);

E oldVal=e.element;

//节点的元素替换

e.element=element;

return oldVal;

}


看似很简洁,但是这里使用了entry方法定位元素,在上一个建议中我们已经说明了LinkedList这种顺序存取列表的元素定位方式会折半遍历,这是一个极耗时的操作。而ArrayList的修改动作则是数组元素的直接替换,简单高效。

在修改动作上,LinkedList比ArrayList慢很多,特别是要进行大量的修改时,两者完全不在一个数量级上。

上面通过分析源码完成了LinkedList与ArrayList之间的PK,其中LinkedList胜两局:删除和插入效率高;ArrayList胜一局:修改元素效率高。总体上来说,在“写”方面,LinkedList占优势,而且在实际使用中,修改是一个比较少的动作。因此,如果有大量的写操作(更多的是插入和删除动作),推荐使用LinkedList。不过何为少量,何为大量呢?

这就要依赖诸位正在开发的系统了,一个实时交易的系统,即使写作操再少,使用LinkedList也比ArrayList合适,因为此类系统是争分夺秒的,多N个毫秒可能就会造成交易数据不准确;而对于一个批量系统来说,几十毫秒、几百毫秒,甚至是几千毫秒的差别意义都不大,这时是使用LinkedList还是ArrayList就看个人爱好了,当然,如果系统已经处于性能临界点了那就必须使用LinkedList。

且慢,“写”操作还有一个增加(add方法)操作,为什么这里没有PK呢?那是因为两者在增加元素时性能上基本没有什么差别,区别只是在增加时LinkedList生成了一个Entry元素,其previous指向倒数第二个Entry, next置空;而ArrayList则是把元素追加到了数组中而已,两者的性能差别非常微小,不再讨论。