從早些時候的那幅示意圖可以看出,實際上只有三個集合組件:Map,List和Set。而且每個接口只有兩種或三種實施方案。若需使用由一個特定的接口提供的功能,如何才能決定到底采取哪一種方案呢?
為理解這個問題,必須認識到每種不同的實施方案都有自己的特點、優點和缺點。比如在那張示意圖中,可以看到Hashtable,Vector和Stack的“特點”是它們都屬於“傳統”類,所以不會干擾原有的代碼。但在另一方面,應盡量避免為新的(Java 1.2)代碼使用它們。
其他集合間的差異通常都可歸納為它們具體是由什麼“後推”的。換言之,取決於物理意義上用於實施目標接口的數據結構是什麼。例如,ArrayList,LinkedList以及Vector(大致等價於ArrayList)都實現了List接口,所以無論選用哪一個,我們的程序都會得到類似的結果。然而,ArrayList(以及Vector)是由一個數組後推得到的;而LinkedList是根據常規的雙重鏈接列表方式實現的,因為每個單獨的對象都包含了數據以及指向列表內前後元素的句柄。正是由於這個原因,假如想在一個列表中部進行大量插入和刪除操作,那麼LinkedList無疑是最恰當的選擇(LinkedList還有一些額外的功能,建立於AbstractSequentialList中)。若非如此,就情願選擇ArrayList,它的速度可能要快一些。
作為另一個例子,Set既可作為一個ArraySet實現,亦可作為HashSet實現。ArraySet是由一個ArrayList後推得到的,設計成只支持少量元素,特別適合要求創建和刪除大量Set對象的場合使用。然而,一旦需要在自己的Set中容納大量元素,ArraySet的性能就會大打折扣。寫一個需要Set的程序時,應默認選擇HashSet。而且只有在某些特殊情況下(對性能的提升有迫切的需求),才應切換到ArraySet。
1. 決定使用何種List
為體會各種List實施方案間的差異,最簡便的方法就是進行一次性能測驗。下述代碼的作用是建立一個內部基礎類,將其作為一個測試床使用。然後為每次測驗都創建一個匿名內部類。每個這樣的內部類都由一個test()方法調用。利用這種方法,可以方便添加和刪除測試項目。
//: ListPerformance.java // Demonstrates performance differences in Lists package c08.newcollections; import java.util.*; public class ListPerformance { private static final int REPS = 100; private abstract static class Tester { String name; int size; // Test quantity Tester(String name, int size) { this.name = name; this.size = size; } abstract void test(List a); } private static Tester[] tests = { new Tester("get", 300) { void test(List a) { for(int i = 0; i < REPS; i++) { for(int j = 0; j < a.size(); j++) a.get(j); } } }, new Tester("iteration", 300) { void test(List a) { for(int i = 0; i < REPS; i++) { Iterator it = a.iterator(); while(it.hasNext()) it.next(); } } }, new Tester("insert", 1000) { void test(List a) { int half = a.size()/2; String s = "test"; ListIterator it = a.listIterator(half); for(int i = 0; i < size * 10; i++) it.add(s); } }, new Tester("remove", 5000) { void test(List a) { ListIterator it = a.listIterator(3); while(it.hasNext()) { it.next(); it.remove(); } } }, }; public static void test(List a) { // A trick to print out the class name: System.out.println("Testing " + a.getClass().getName()); for(int i = 0; i < tests.length; i++) { Collection1.fill(a, tests[i].size); System.out.print(tests[i].name); long t1 = System.currentTimeMillis(); tests[i].test(a); long t2 = System.currentTimeMillis(); System.out.println(": " + (t2 - t1)); } } public static void main(String[] args) { test(new ArrayList()); test(new LinkedList()); } } ///:~
內部類Tester是一個抽象類,用於為特定的測試提供一個基礎類。它包含了一個要在測試開始時打印的字串、一個用於計算測試次數或元素數量的size參數、用於初始化字段的一個構建器以及一個抽象方法test()。test()做的是最實際的測試工作。各種類型的測試都集中到一個地方:tests數組。我們用繼承於Tester的不同匿名內部類來初始化該數組。為添加或刪除一個測試項目,只需在數組裡簡單地添加或移去一個內部類定義即可,其他所有工作都是自動進行的。
首先用元素填充傳遞給test()的List,然後對tests數組中的測試計時。由於測試用機器的不同,結果當然也會有所區別。這個程序的宗旨是揭示出不同集合類型的相對性能比較。下面是某一次運行得到的結果:
類型 獲取 反復 插入 刪除
ArrayList 110 270 1920 4780
LinkedList 1870 7580 170 110
可以看出,在ArrayList中進行隨機訪問(即get())以及循環反復是最劃得來的;但對於LinkedList卻是一個不小的開銷。但另一方面,在列表中部進行插入和刪除操作對於LinkedList來說卻比ArrayList劃算得多。我們最好的做法也許是先選擇一個ArrayList作為自己的默認起點。以後若發現由於大量的插入和刪除造成了性能的降低,再考慮換成LinkedList不遲。
2. 決定使用何種Set
可在ArraySet以及HashSet間作出選擇,具體取決於Set的大小(如果需要從一個Set中獲得一個順序列表,請用TreeSet;注釋⑧)。下面這個測試程序將有助於大家作出這方面的抉擇:
//: SetPerformance.java package c08.newcollections; import java.util.*; public class SetPerformance { private static final int REPS = 200; private abstract static class Tester { String name; Tester(String name) { this.name = name; } abstract void test(Set s, int size); } private static Tester[] tests = { new Tester("add") { void test(Set s, int size) { for(int i = 0; i < REPS; i++) { s.clear(); Collection1.fill(s, size); } } }, new Tester("contains") { void test(Set s, int size) { for(int i = 0; i < REPS; i++) for(int j = 0; j < size; j++) s.contains(Integer.toString(j)); } }, new Tester("iteration") { void test(Set s, int size) { for(int i = 0; i < REPS * 10; i++) { Iterator it = s.iterator(); while(it.hasNext()) it.next(); } } }, }; public static void test(Set s, int size) { // A trick to print out the class name: System.out.println("Testing " + s.getClass().getName() + " size " + size); Collection1.fill(s, size); for(int i = 0; i < tests.length; i++) { System.out.print(tests[i].name); long t1 = System.currentTimeMillis(); tests[i].test(s, size); long t2 = System.currentTimeMillis(); System.out.println(": " + ((double)(t2 - t1)/(double)size)); } } public static void main(String[] args) { // Small: test(new TreeSet(), 10); test(new HashSet(), 10); // Medium: test(new TreeSet(), 100); test(new HashSet(), 100); // Large: test(new HashSet(), 1000); test(new TreeSet(), 1000); } } ///:~
⑧:TreeSet在本書寫作時尚未成為一個正式的特性,但在這個例子中可以很輕松地為其添加一個測試。
最後對ArraySet的測試只有500個元素,而不是1000個,因為它太慢了。
類型 測試大小 添加 包含 反復
Type
Test size
Add
Contains
Iteration
10
22.0
11.0
16.0
TreeSet
100
22.5
13.2
12.1
1000
31.1
18.7
11.8
10
5.0
6.0
27.0
HashSet
100
6.6
6.6
10.9
1000
7.4
6.6
9.5
進行add()以及contains()操作時,HashSet顯然要比ArraySet出色得多,而且性能明顯與元素的多寡關系不大。一般編寫程序的時候,幾乎永遠用不著使用ArraySet。
3. 決定使用何種Map
選擇不同的Map實施方案時,注意Map的大小對於性能的影響是最大的,下面這個測試程序清楚地闡示了這一點:
//: MapPerformance.java // Demonstrates performance differences in Maps package c08.newcollections; import java.util.*; public class MapPerformance { private static final int REPS = 200; public static Map fill(Map m, int size) { for(int i = 0; i < size; i++) { String x = Integer.toString(i); m.put(x, x); } return m; } private abstract static class Tester { String name; Tester(String name) { this.name = name; } abstract void test(Map m, int size); } private static Tester[] tests = { new Tester("put") { void test(Map m, int size) { for(int i = 0; i < REPS; i++) { m.clear(); fill(m, size); } } }, new Tester("get") { void test(Map m, int size) { for(int i = 0; i < REPS; i++) for(int j = 0; j < size; j++) m.get(Integer.toString(j)); } }, new Tester("iteration") { void test(Map m, int size) { for(int i = 0; i < REPS * 10; i++) { Iterator it = m.entries().iterator(); while(it.hasNext()) it.next(); } } }, }; public static void test(Map m, int size) { // A trick to print out the class name: System.out.println("Testing " + m.getClass().getName() + " size " + size); fill(m, size); for(int i = 0; i < tests.length; i++) { System.out.print(tests[i].name); long t1 = System.currentTimeMillis(); tests[i].test(m, size); long t2 = System.currentTimeMillis(); System.out.println(": " + ((double)(t2 - t1)/(double)size)); } } public static void main(String[] args) { // Small: test(new Hashtable(), 10); test(new HashMap(), 10); test(new TreeMap(), 10); // Medium: test(new Hashtable(), 100); test(new HashMap(), 100); test(new TreeMap(), 100); // Large: test(new HashMap(), 1000); test(new Hashtable(), 1000); test(new TreeMap(), 1000); } } ///:~
由於Map的大小是最嚴重的問題,所以程序的計時測試按Map的大小(或容量)來分割時間,以便得到令人信服的測試結果。下面列出一系列結果(在你的機器上可能不同):
類型 測試大小 置入 取出 反復
Type
Test size
Put
Get
Iteration
10
11.0
5.0
44.0
Hashtable
100
7.7
7.7
16.5
1000
8.0
8.0
14.4
10
16.0
11.0
22.0
TreeMap
100
25.8
15.4
13.2
1000
33.8
20.9
13.6
10
11.0
6.0
33.0
HashMap
100
8.2
7.7
13.7
1000
8.0
7.8
11.9
即使大小為10,ArrayMap的性能也要比HashMap差——除反復循環時以外。而在使用Map時,反復的作用通常並不重要(get()通常是我們時間花得最多的地方)。TreeMap提供了出色的put()以及反復時間,但get()的性能並不佳。但是,我們為什麼仍然需要使用TreeMap呢?這樣一來,我們可以不把它作為Map使用,而作為創建順序列表的一種途徑。樹的本質在於它總是順序排列的,不必特別進行排序(它的排序方式馬上就要講到)。一旦填充了一個TreeMap,就可以調用keySet()來獲得鍵的一個Set“景象”。然後用toArray()產生包含了那些鍵的一個數組。隨後,可用static方法Array.binarySearch()快速查找排好序的數組中的內容。當然,也許只有在HashMap的行為不可接受的時候,才需要采用這種做法。因為HashMap的設計宗旨就是進行快速的檢索操作。最後,當我們使用Map時,首要的選擇應該是HashMap。只有在極少數情況下才需要考慮其他方法。
此外,在上面那張表裡,有另一個性能問題沒有反映出來。下述程序用於測試不同類型Map的創建速度:
//: MapCreation.java // Demonstrates time differences in Map creation package c08.newcollections; import java.util.*; public class MapCreation { public static void main(String[] args) { final long REPS = 100000; long t1 = System.currentTimeMillis(); System.out.print("Hashtable"); for(long i = 0; i < REPS; i++) new Hashtable(); long t2 = System.currentTimeMillis(); System.out.println(": " + (t2 - t1)); t1 = System.currentTimeMillis(); System.out.print("TreeMap"); for(long i = 0; i < REPS; i++) new TreeMap(); t2 = System.currentTimeMillis(); System.out.println(": " + (t2 - t1)); t1 = System.currentTimeMillis(); System.out.print("HashMap"); for(long i = 0; i < REPS; i++) new HashMap(); t2 = System.currentTimeMillis(); System.out.println(": " + (t2 - t1)); } } ///:~
在寫這個程序期間,TreeMap的創建速度比其他兩種類型明顯快得多(但你應親自嘗試一下,因為據說新版本可能會改善ArrayMap的性能)。考慮到這方面的原因,同時由於前述TreeMap出色的put()性能,所以如果需要創建大量Map,而且只有在以後才需要涉及大量檢索操作,那麼最佳的策略就是:創建和填充TreeMap;以後檢索量增大的時候,再將重要的TreeMap轉換成HashMap——使用HashMap(Map)構建器。同樣地,只有在事實證明確實存在性能瓶頸後,才應關心這些方面的問題——先用起來,再根據需要加快速度。