建議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, Sat 14 }
很簡單的程式呀,預設宣告的工作日(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 //列表長度加1 12 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 else 7 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 //列表長度減1 15 elementData[--size] = null; // Let gc do its work 16 //返回刪除的值 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等於此相同,也是隻關心集合元素,不用考慮集合型別。
注意:判斷集合是否相等時只須關注元素是否相等即可。