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還是未能得到這種結果。

發佈了60 篇原創文章 · 獲贊 10 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章