建議65:避開基本類型數組轉換列表陷阱
我們在開發中經常會使用Arrays和Collections這兩個工具類和列表之間轉換,非常方便,但也有時候會出現一些奇怪的問題,來看如下代碼:
1 public class Client65 {2 public static void main(String[] args) {3 int data = {1,2,3,4,5};4 List list= Arrays.asList(data);5 System.out.println("列表中的元素數量是:"+list.size);6 }7 }
也許你會說,這很簡單,list變量的元素數量當然是5了。但是運行後列印出來的列表數量為1。
事實上data確實是一個有5個元素的int類型數組,只是通過asList轉換成列表後就只有一個元素了,這是為什麼呢?其他4個元素到什麼地方去了呢?
我們仔細看一下Arrays.asList的方法說明:輸入一個變長參數,返回一個固定長度的列表。注意這裡是一個變長參數,看源碼:
public static <T> List<T> asList(T... a) { return new ArrayList<>(a); }
asList方法輸入的是一個泛型變長參數,我們知道基本類型是不能泛型化的,也就是說8個基本類型不能作為泛型參數,要想作為泛型參數就必須使用其所對應的包裝類型,那前面的例子傳遞了一個int類型的數組,為何程序沒有報編譯錯誤呢?
在Java中,數組是一個對象,它是可以泛型化的,也就是說我們的例子是把一個int類型的數組作為了T的類型,所以在轉換後在List中就只有一個類型為int數組的元素了,我們列印出來看看,代碼如下:
1 public class Client65 {2 public static void main(String[] args) {3 int data = {1,2,3,4,5};4 List list= Arrays.asList(data);5 System.out.println("元素類型是:"+list.get(0).getClass);6 System.out.println("前後是否相等:"+data.equals(list.get(0))); 7 }8 }
輸出的結果是: 元素類型是:class [I 前後是否相等:true
很明顯,放在列表中的元素時一個int數組,可能有人要問了,為什麼"元素類型:"後的class是"[I"?我們並沒有指明是數組(Array)類型呀!這是因為JVM不可能輸出Array類型,因為Array是屬於java.lang.reflect包的,它是通過反射訪問數組元素的工具類。在Java中任何一個一維數組的類型都是 " [I " ,究其原因就是Java並沒有定義數組這一個類,它是在編譯器編譯的時候生成的,是一個特殊的類,在JDK的幫助中也沒有任何數組類的信息。
弄清楚了問題,修改也很easy,直接使用包裝類即可,部分代碼如下:
Integer data = {1,2,3,4,5};
把int替換為Integer即可讓輸出元素數量為5.需要說明的是,不僅僅是int類型的數組有這個問題,其它7個基本類型的數組也存在相似的問題,這就需要大家注意了,在把基本類型數組轉換為列表時,要特別小心asList方法的陷阱,避免出現程序邏輯混亂的情況。
注意:原始類型數組不能作為asList的輸入參數,否則會引起程序邏輯混亂。
建議66:asList方法產生的List的對象不可更改
上一個建議指出了asList方法在轉換基本類型數組時存在的問題,接著我們看一下asList方法返回的列表有何特殊的地方,代碼如下:
1 public class Client66 { 2 public static void main(String[] args) { 3 // 五天工作制 4 Week days = { Week.Mon, Week.Tue, Week.Wed, Week.Thu, Week.Fri }; 5 // 轉換為列表 6 List<Week> list = Arrays.asList(days); 7 // 增加周六為工作日 8 list.add(Week.Sat); 9 /* do something */10 }11 }12 enum Week {13 Sun, Mon, Tue, Wed, Thu, Fri, Sat14 }
很簡單的程序呀,默認聲明的工作日(workDays)是從周一到周五,偶爾周六也會算作工作日加入到工作日列表中,不過,這段程序執行時會不會有什麼問題呢?編譯沒有任何問題,但是一運行,卻出現了如下結果:
UnsupportedOperationException,不支持的操作,居然不支持list的add方法,這是什麼原因呢?我們看看asList方法的原始碼:
public static <T> List<T> asList(T... a) { return new ArrayList<>(a); }
直接new了一個ArrayList對象返回,難道ArrayList不支持add方法,不可能呀!可能,問題就出現在這個ArrayList類上,此ArrayList非java.util.ArrayList,而是Arrays工具類的一個內部類,其構造函數如下所示:
1 private static class ArrayList<E> extends AbstractList<E> 2 implements RandomAccess, java.io.Serializable 3 { 4 private static final long serialVersionUID = -2764017481108945198L; 5 private final E a; 6 7 ArrayList(E[] array) { 8 if (array==null) 9 throw new NullPointerException;10 a = array;11 }12 /*其它方法略*/ 13 }
這個ArrayList是一個靜態私有內部類,除了Arrays能訪問外,其它類都不能訪問,仔細看這個類,它沒有提供add方法,那肯定是父類AbstractList提供了,來看代碼:
1 public boolean add(E e) {2 add(size, e);3 return true;4 }5 6 public void add(int index, E element) {7 throw new UnsupportedOperationException;8 }
父類確實提供了,但沒有提供具體的實現,所以每個子類都需要自己覆寫add方法,而Arrays的內部類ArrayList沒有覆寫,因此add一個元素就報錯了。
我們深入地看看這個ArrayList靜態內部類,它僅僅實現了5個方法:
- size:元素數量
- get:獲得制定元素
- set:重置某一元素值
- contains:是否包含某元素
- toArray:轉化為數組,實現了數組的淺拷貝
把這幾個方法的原始碼展示一下:
1 //元素數量 2 public int size { 3 return a.length; 4 } 5 6 public Object toArray { 7 return a.clone; 8 } 9 //轉化為數組,實現了數組的淺拷貝10 public <T> T toArray(T[] a) {11 int size = size;12 if (a.length < size)13 return Arrays.copyOf(this.a, size,14 (Class<? extends T[]>) a.getClass);15 System.arraycopy(this.a, 0, a, 0, size);16 if (a.length > size)17 a[size] = null;18 return a;19 }20 //獲得指定元素21 public E get(int index) {22 return a[index];23 }24 //重置某一元素25 public E set(int index, E element) {26 E oldValue = a[index];27 a[index] = element;28 return oldValue;29 }30 31 public int indexOf(Object o) {32 if (o==null) {33 for (int i=0; i<a.length; i++)34 if (a[i]==null)35 return i;36 } else {37 for (int i=0; i<a.length; i++)38 if (o.equals(a[i]))39 return i;40 }41 return -1;42 }43 //是否包含元素44 public boolean contains(Object o) {45 return indexOf(o) != -1;46 }
對於我們經常使用list.add和list.remove方法它都沒有實現,也就是說asList返回的是一個長度不可變的列表,數組是多長,轉換成的列表也就是多長,換句話說此處的列表只是數組的一個外殼,不再保持列表的動態變長的特性,這才是我們關注的重點。有些開發人員喜歡這樣定義個初始化列表:
List<String> names= Arrays.asList("張三","李四","王五");
一句話完成了列表的定義和初始化,看似很便捷,卻隱藏著重大隱患---列表長度無法修改。想想看,如果這樣一個List傳遞到一個允許添加的add操作的方法中,那將會產生何種結果,如果有這種習慣的javaer,請慎之戒之,除非非常自信該List只用於只讀操作。
建議67:不同的列表選擇不同的遍歷算法
我們思考這樣一個案例:統計一個省的各科高考平均值,比如數學平均分是多少,語文平均分是多少等,這是每年招生辦都會公布的數據,我們來想想看該算法應如何實現。當然使用資料庫中的一個SQL語句就可能求出平均值,不過這不再我們的考慮之列,這裡還是使用純Java的算法來解決之,看代碼:
1 public class Client67 { 2 public static void main(String[] args) { 3 // 學生數量 80萬 4 int stuNum = 80 * 10000; 5 // List集合,記錄所有學生的分數 6 List<Integer> scores = new ArrayList<Integer>(stuNum); 7 // 寫入分數 8 for (int i = 0; i < stuNum; i++) { 9 scores.add(new Random.nextInt(150));10 }11 // 記錄開始計算 時間12 long start = System.currentTimeMillis;13 System.out.println("平均分是:" + average(scores));14 long end = System.currentTimeMillis;15 System.out.println("執行時間:" + (end - start) + "ms");16 }17 18 public static int average(List<Integer> scores) {19 int sum = 0;20 // 遍歷求和21 for (int i : scores) {22 sum += i;23 }24 return sum / scores.size;25 }26 }
把80萬名學生的成績放到一個ArrayList數組中,然後通過foreach方法遍歷求和,再計算平均值,程序很簡單,輸出結果:
平均分是:74
執行時間:11ms
僅僅計算一個算術平均值就花了11ms,不要說什麼其它諸如加權平均值,補充平均值等算法,那花的時間肯定更長。我們仔細分析一下average方法,加號操作是最基本的,沒有什麼可以優化的,剩下的就是一個遍歷了,問題是List的遍歷可以優化嗎?
我們嘗試一下,List的遍歷還有另外一種方式,即通過下標方式來訪問,代碼如下:
1 public static int average(List<Integer> scores) {2 int sum = 0;3 // 遍歷求和4 for (int i = 0; i < scores.size; i++) {5 sum += scores.get(i);6 }7 return sum / scores.size;8 }
不再使用foreach遍歷,而是採用下標方式遍歷,我們看看輸出結果:
平均分是:74
執行時間:8ms
執行時間已經下降,如果數據量更大,會更明顯。那為什麼我們使用下標方式遍歷數組可以提高的性能呢?
這是因為ArrayList數組實現了RandomAccess接口(隨機存取接口),這樣標誌著ArrayList是一個可以隨機存取的列表。在Java中,RandomAccess和Cloneable、Serializable一樣,都是標誌性接口,不需要任何實現,只是用來表明其實現類具有某種特質的,實現了Cloneable表明可以被拷貝,實現了Serializable接口表明被序列化了,實現了RandomAccess接口則表明這個類可以隨機存取,對我們的ArrayList來說也就標誌著其數據元素之間沒有關聯,即兩個位置相鄰的元素之間沒有相互依賴和索引關係,可以隨機訪問和存取。我們知道,Java的foreach語法時iterator(疊代器)的變形用法,也就是說上面的foreach與下面的代碼等價:
for (Iterator<Integer> i = scores.iterator; i.hasNext;) { sum += i.next; }
那我們再想想什麼是疊代器,疊代器是23中設計模式中的一種,"提供一種方法訪問一個容器對象中的各個元素,同時又無須暴露該對象的內部細節",也就是說對於ArrayList,需要先創建一個疊代器容器,然後屏蔽內部遍歷細節,對外提供hasNext、next等方法。問題是ArrayList實現RandomAccess接口,表明元素之間本來沒有關係,可是,為了使用疊代器就需要強制建立一種相互"知曉"的關係,比如上一個元素可以判斷是否有下一個元素,以及下一個元素時什麼等關係,這也就是foreach遍歷耗時的原因。
Java的ArrayList類加上了RandomAccess接口,就是在告訴我們,「ArrayList是隨機存取的,採用下標方式遍歷列錶速度回更快」,接著又有一個問題,為什麼不把RandomAccess接口加到所有List的實現類上呢?
那是因為有些List的實現類不是隨機存取的,而是有序存取的,比如LinkedList類,LinkedList類也是一個列表,但它實現了雙向鍊表,每個數據節點都有三個數據項:前節點的引用(Previous Node)、本節點元素(Node Element)、後繼結點的引用(Next Node),這是數據結構的基本知識,不多講了,也就是說在LinkedList中的兩個元素本來就是有關聯的,我知道你的存在,你也知道我的存在。那大家想想看,元素之間已經有關聯了,使用foreach也就是疊代器方式是不是效率更高呢?我們修改一下例子,代碼如下:
1 public static void main(String[] args) { 2 // 學生數量 80萬 3 int stuNum = 80 * 10000; 4 // List集合,記錄所有學生的分數 5 List<Integer> scores = new LinkedList<Integer>; 6 // 寫入分數 7 for (int i = 0; i < stuNum; i++) { 8 scores.add(new Random.nextInt(150)); 9 }10 // 記錄開始計算 時間11 long start = System.currentTimeMillis;12 System.out.println("平均分是:" + average(scores));13 long end = System.currentTimeMillis;14 System.out.println("執行時間:" + (end - start) + "ms");15 }16 17 public static int average(List<Integer> scores) {18 int sum = 0;19 // 遍歷求和20 for (int i : scores) {21 sum += i;22 }23 return sum / scores.size;24 }
輸出結果為: 平均分是:74 執行時間:12ms 。執行效率還好。但是比ArrayList就慢了,但如果LinkedList採用下標方式遍歷:效率會如何呢?我告訴你會很慢。直接分析一下源碼:
1 public E get(int index) {2 checkElementIndex(index);3 return node(index).item;4 }
由node方法查找指定下標的節點,然後返回其包含的元素,看node方法:
1 Node<E> node(int index) { 2 // assert isElementIndex(index); 3 4 if (index < (size >> 1)) { 5 Node<E> x = first; 6 for (int i = 0; i < index; i++) 7 x = x.next; 8 return x; 9 } else {10 Node<E> x = last;11 for (int i = size - 1; i > index; i--)12 x = x.prev;13 return x;14 }15 }
看懂了嗎?程序會先判斷輸入的下標與中間值(size右移一位,也就是除以2了)的關係,小於中間值則從頭開始正向搜索,大於中間值則從尾節點反向搜索,想想看,每一次的get方法都是一個遍歷,"性能"兩字從何說去呢?
明白了隨機存取列表和有序存取列表的分別,我們的average方法就必須重構了,以便實現不同的列表採用不同的遍歷方式,代碼如下:
1 public static int average(List<Integer> scores) { 2 int sum = 0; 3 4 if (scores instanceof RandomAccess) { 5 // 可以隨機存取,則使用下標遍歷 6 for (int i = 0; i < scores.size; i++) { 7 sum += scores.get(i); 8 } 9 } else {10 // 有序存取,使用foreach方式11 for (int i : scores) {12 sum += i;13 }14 }15 return sum / scores.size;16 }
如此一來,列表的遍歷就可以"以不變應萬變"了,無論是隨機存取列表還是有序列表,它都可以提供快速的遍歷。
注意:列表遍歷不是那麼簡單的,適時選擇最優的遍歷方式,不要固化為一種。
建議68:頻繁插入和刪除時使用LinkList
上一個建議介紹了列表的遍歷方式,也就是「讀」 操作,本建議將介紹列表的"寫"操作:即插入、刪除、修改動作。
(1)、插入元素:列表中我們使用最多的是ArrayList,下面來看看它的插入(add方法)算法,原始碼如下:
1 public void add(int index, E element) { 2 //檢查下標是否越界 3 rangeCheckForAdd(index); 4 //若需要擴容,則增大底層數組的長度 5 ensureCapacityInternal(size + 1); // Increments modCount!! 6 //給index下標之後的元素(包括當前元素)的下標加1,空出index位置 7 System.arraycopy(elementData, index, elementData, index + 1, 8 size - index); 9 //賦值index位置元素10 elementData[index] = element;11 //列表長度加112 size++;13 }
注意看arrayCopy方法,只要插入一個元素,其後的元素就會向後移動一位,雖然arrayCopy是一個本地方法,效率非常高,但頻繁的插入,每次後面的元素都要拷貝一遍,效率就更低了,特別是在頭位置插入元素時,現在的問題是,開發中確實會遇到要插入的元素的情況,哪有什麼更好的方法解決此效率問題嗎?
有,使用LinkedList即可。我麼知道LinkedList是一個雙向列表,它的插入只是修改了相鄰元素的next和previous引用,其插入算法(add方法)如下:
1 public void add(int index, E element) {2 checkPositionIndex(index);3 4 if (index == size)5 linkLast(element);6 else7 linkBefore(element, node(index));8 }
1 void linkLast(E e) { 2 final Node<E> l = last; 3 final Node<E> newNode = new Node<>(l, e, null); 4 last = newNode; 5 if (l == null) 6 first = newNode; 7 else 8 l.next = newNode; 9 size++;10 modCount++;11 }
1 void linkBefore(E e, Node<E> succ) { 2 // assert succ != null; 3 final Node<E> pred = succ.prev; 4 final Node<E> newNode = new Node<>(pred, e, succ); 5 succ.prev = newNode; 6 if (pred == null) 7 first = newNode; 8 else 9 pred.next = newNode;10 size++;11 modCount++;12 }
這個方法,第一步檢查是否越界,下來判斷插入元素的位置與列表的長度比較,如果相等,調用linkLast,否則調用linkBefore方法。但這兩個方法的共同點都是雙向鍊表插入算法,把自己插入到鍊表,然後再把前節點的next和後節點的previous指向自己。想想看,這樣插入一個元素的過程中,沒有任何元素會有拷貝過程,只是引用地址變了,那效率自然就高了。
(2)、刪除元素:插入了解清楚了,我們再來看看刪除動作。ArrayList提供了刪除指定位置上的元素,刪除指定元素,刪除一個下標範圍內的元素集等刪除動作。三者的實現原理基本相似,都是找索引位置,然後刪除。我們以最常用的刪除指定下標的方法(remove方法)為例來看看刪除動作的性能到底如何,源碼如下:
1 public E remove(int index) { 2 //下標校驗 3 rangeCheck(index); 4 //修改計數器加1 5 modCount++; 6 //記錄要刪除的元素 7 E oldValue = elementData(index); 8 //有多少個元素向前移動 9 int numMoved = size - index - 1;10 if (numMoved > 0)11 //index後的元素向前移動一位12 System.arraycopy(elementData, index+1, elementData, index,13 numMoved);14 //列表長度減115 elementData[--size] = null; // Let gc do its work16 //返回刪除的值17 return oldValue;18 }
注意看,index位置後的元素都向前移動了一位,最後一個位置空出來了,這又是一個數組拷貝,和插入一樣,如果數據量大,刪除動作必然會暴露出性能和效率方面的問題。ArrayList其它的兩個刪除方法與此類似,不再贅述。
我麼再來看LinkedList的刪除動作。LinkedList提供了非常多的刪除操作,比如刪除指定位置元素,刪除頭元素等,與之相關的poll方法也會執行刪除動作,下面來看最基本的刪除指定位置元素的方法remove,原始碼如下:
public E remove(int index) { checkElementIndex(index); return unlink(node(index)); }
1 E unlink(Node<E> x) { 2 // assert x != null; 3 final E element = x.item; 4 final Node<E> next = x.next; 5 final Node<E> prev = x.prev; 6 7 if (prev == null) { 8 first = next; 9 } else {10 prev.next = next;11 x.prev = null;12 }13 14 if (next == null) {15 last = prev;16 } else {17 next.prev = prev;18 x.next = null;19 }20 21 x.item = null;22 size--;23 modCount++;24 return element;25 }
1 private static class Node<E> { 2 E item; 3 Node<E> next; 4 Node<E> prev; 5 6 Node(Node<E> prev, E element, Node<E> next) { 7 this.item = element; 8 this.next = next; 9 this.prev = prev;10 }11 }
這也是雙向鍊表標準刪除算法,沒有任何耗時的操作,全部都是引用指針的變更,效率自然高了。
(3)、修改元素:寫操作還有一個動作,修改元素值,在這一點上LinkedList輸給了ArrayList,這是因為LinkedList是按順序存儲的,因此定位元素必然是一個遍歷過程,效率大打折扣,我們來看Set方法的代碼:
1 public E set(int index, E element) {2 checkElementIndex(index);3 //定位節點4 Node<E> x = node(index);5 E oldVal = x.item;6 //節點元素替換7 x.item = element;8 return oldVal;9 }
看似很簡潔,但是這裡使用了node方法定位元素,上一個建議中我們已經說明了LinkedList這種順序存取列表的元素定位方式會折半遍歷,這是一個極耗時的操作,而ArrayList的修改動作則是數組元素的直接替換,簡單高效。
在修改動作上,LinkedList比ArrayList慢很多,特別是要進行大量的修改時,兩者完全不在一個數量級上。
上面通過分析源碼完成了LinkedList與ArrayList之間的PK,其中LinkedList勝兩局:刪除和插入效率高;ArrayList勝一局:修改元素效率高。總體來說,在寫方面LinkedList占優勢,而且在實際使用中,修改是一個比較少的動作。因此有大量寫的操作(更多的是插入和刪除),推薦使用LinkedList。不過何為少量?何為大量呢?
這就依賴於咱們在開發中系統了,具體情況具體分析了。
建議69:列表相等只關心元素數據
我們來看一個比較列表相等的例子,代碼如下:
1 public class Client69 { 2 public static void main(String[] args) { 3 ArrayList<String> strs = new ArrayList<String>; 4 strs.add("A"); 5 6 Vector<String> strs2 = new Vector<String>; 7 strs2.add("A"); 8 9 System.out.println(strs.equals(strs2));10 }11 }
兩個類都不同,一個是ArrayList,一個是Vector,那結果肯定不相等了。真是這樣嗎?但其實結果為true,也就是兩者相等。
我們分析一下,兩者為何相等,兩者都是列表(List),都實現了List接口,也都繼承了AbstractList抽象類,其equals方法是在AbstractList中定義的,我們來看原始碼:
1 public boolean equals(Object o) { 2 if (o == this) 3 return true; 4 //是否是列表,注意這裡:只要實現List接口即可 5 if (!(o instanceof List)) 6 return false; 7 //通過疊代器訪問List的所有元素 8 ListIterator<E> e1 = listIterator; 9 ListIterator e2 = ((List) o).listIterator;10 //遍歷兩個List的元素11 while (e1.hasNext && e2.hasNext) {12 E o1 = e1.next;13 Object o2 = e2.next;14 //只要存在著不相等就退出15 if (!(o1==null ? o2==null : o1.equals(o2)))16 return false;17 }18 //長度是否也相等19 return !(e1.hasNext || e2.hasNext);20 }
看到沒,這裡只要實現了List接口就成,它不關心List的具體實現類,只要所有元素相等,並且長度也相等就表明兩個List是相等的,與具體的容量類型無關。也就是說,上面的例子雖然一個是Arraylist,一個是Vector,只要裡面的元素相等,那結果也就相等。
Java如此處理也確實是在為開發者考慮,列表只是一個容器,只要是同一種類型的容器(如List),不用關心,容器的細節差別,只要確定所有的元素數據相等,那這兩個列表就是相等的,如此一來,我們在開發中就不用太關注容器的細節了,可以把注意力更多地放在數據元素上,而且即使中途重構容器類型,也不會對相等的判斷產生太大的影響。
其它的集合類型,如Set、Map等於此相同,也是只關心集合元素,不用考慮集合類型。
注意:判斷集合是否相等時只須關注元素是否相等即可。