編寫高質量程式碼:改善Java程式的151個建議(第5章:陣列和集合___建議79~82)

阿赫瓦里發表於2016-09-24

建議79:集合中的雜湊碼不要重複

  在一個列表中查詢某值是非常耗費資源的,隨機存取的列表是遍歷查詢,順序儲存的列表是連結串列查詢,或者是Collections的二分法查詢,但這都不夠快,畢竟都是遍歷嘛,最快的還要數以Hash開頭的集合(如HashMap、HashSet等類)查詢,我們以HashMap為例,看看是如何查詢key值的,程式碼如下: 

 1 public class Client79 {
 2     public static void main(String[] args) {
 3         int size = 10000;
 4         List<String> list = new ArrayList<String>(size);
 5         // 初始化
 6         for (int i = 0; i < size; i++) {
 7             list.add("value" + i);
 8         }
 9         // 記錄開始時間,單位納秒
10         long start = System.nanoTime();
11         // 開始查詢
12         list.contains("value" + (size - 1));
13         // 記錄結束時間,單位納秒
14         long end = System.nanoTime();
15         System.out.println("List的執行時間:" + (end - start) + "ns");
16         // Map的查詢時間
17         Map<String, String> map = new HashMap<String, String>(size);
18         for (int i = 0; i < size; i++) {
19             map.put("key" + i, "value" + i);
20         }
21         start = System.nanoTime();
22         map.containsKey("key" + (size - 1));
23         end = System.nanoTime();
24         System.out.println("map的執行時間:" + (end - start) + "ns");
25     }
26 }

  兩個不同的集合容器,一個是ArrayList,一個是HashMap,都是插入10000個元素,然後判斷是否包含最後一個加入的元素。邏輯相同,但是執行時間差別卻非常大,結果如下:

  

  HahsMap比ArrayList快了兩個數量級!兩者的contains方法都是判斷是否包含指定值,為何差距如此巨大呢?而且如果資料量增大,差距也會非線性增長。

  我們先來看看ArrayList,它的contains方法是一個遍歷對比,這很easy,不多說。我們看看HashMap的ContainsKey方法是如何實現的,程式碼如下:

public boolean containsKey(Object key) {
        //判斷getEntry是否為空
        return getEntry(key) != null;
    }

  getEntry方法會根據key值查詢它的鍵值對(也就是Entry物件),如果沒有找到,則返回null。我們再看看該方法又是如何實現的,程式碼如下: 

 1 final Entry<K,V> getEntry(Object key) {
 2          //計算key的雜湊碼
 3             int hash = (key == null) ? 0 : hash(key);
 4             //定位Entry、indexOf方法是根據hash定位陣列的位置的
 5             for (Entry<K,V> e = table[indexFor(hash, table.length)];
 6                  e != null;
 7                  e = e.next) {
 8                 Object k;
 9                 //雜湊碼相同,並且鍵值也相等才符合條件
10                 if (e.hash == hash &&
11                     ((k = e.key) == key || (key != null && key.equals(k))))
12                     return e;
13             }
14             return null;
15         }

  注意看上面程式碼中紅色字型部分,通過indexFor方法定位Entry在陣列table中的位置,這是HashMap實現的一個關鍵點,怎麼能根據hashCode定位它在陣列中的位置呢?

  要解釋此問題,還需要從HashMap的table陣列是如何儲存元素的說起,首先要說明三點:

  • table陣列的長度永遠是2的N次冪。
  • table陣列的元素是Entry型別
  • table陣列中的元素位置是不連續的

  table陣列為何如此設計呢?下面逐步來說明,先來看HashMap是如何插入元素的,程式碼如下: 

 1 public V put(K key, V value) {
 2         //null鍵處理
 3         if (key == null)
 4             return putForNullKey(value);
 5         //計算hash碼,並定位元素
 6         int hash = hash(key);
 7         int i = indexFor(hash, table.length);
 8         for (Entry<K, V> e = table[i]; e != null; e = e.next) {
 9             Object k;
10             //雜湊碼相同,並且key相等,則覆蓋
11             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
12                 V oldValue = e.value;
13                 e.value = value;
14                 e.recordAccess(this);
15                 return oldValue;
16             }
17         }
18         modCount++;
19         //插入新元素,或者替換雜湊的舊元素並建立連結串列
20         addEntry(hash, key, value, i);
21         return null;
22     }

  注意看,HashMap每次增加元素時都會先計算其雜湊碼值,然後使用hash方法再次對hashCode進行抽取和統計,同時兼顧雜湊碼的高位和低位資訊產生一個唯一值,也就是說hashCode不同,hash方法返回的值也不同,之後再通過indexFor方法與陣列長度做一次與運算,即可計算出其在陣列中的位置,簡單的說,hash方法和indexFor方法就是把雜湊碼轉變成陣列的下標,原始碼如下:

 1    final int hash(Object k) {
 2         int h = 0;
 3         if (useAltHashing) {
 4             if (k instanceof String) {
 5                 return sun.misc.Hashing.stringHash32((String) k);
 6             }
 7             h = hashSeed;
 8         }
 9 
10         h ^= k.hashCode();
11 
12         // This function ensures that hashCodes that differ only by
13         // constant multiples at each bit position have a bounded
14         // number of collisions (approximately 8 at default load factor).
15         h ^= (h >>> 20) ^ (h >>> 12);
16         return h ^ (h >>> 7) ^ (h >>> 4);
17     }
1 /**
2      * Returns index for hash code h.
3      */
4     static int indexFor(int h, int length) {
5         return h & (length-1);
6     }

  順便說一下,null值也是可以作為key值的,它的位置永遠是在Entry陣列中的第一位。

  現在有一個很重要的問題擺在前面了:雜湊運算存在著雜湊衝突問題,即對於一個固定的雜湊演算法f(k),允許出現f(k1)=f(k2),但k1≠k2的情況,也就是說兩個不同的Entry,可能產生相同的雜湊碼,HashMap是如何處理這種衝突問題的呢?答案是通過連結串列,每個鍵值對都是一個Entry,其中每個Entry都有一個next變數,也就是說它會指向一個鍵值對---很明顯,這應該是一個單向連結串列,該連結串列是由addEntity方法完成的,其程式碼如下: 

1  void addEntry(int hash, K key, V value, int bucketIndex) {
2         if ((size >= threshold) && (null != table[bucketIndex])) {
3             resize(2 * table.length);
4             hash = (null != key) ? hash(key) : 0;
5             bucketIndex = indexFor(hash, table.length);
6         }
7 
8         createEntry(hash, key, value, bucketIndex);
9     }
 void createEntry(int hash, K key, V value, int bucketIndex) {
        //取得當前位置元素
        Entry<K,V> e = table[bucketIndex];
       //生成新的鍵值對,並進行替換,建立連結串列
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

  這段程式涵蓋了兩個業務邏輯,如果新加入的元素的鍵值對的hashCode是唯一的,那直接插入到陣列中,它Entry的next值則為null;如果新加入的鍵值對的hashCode與其它元素衝突,則替換掉陣列中的當前值,並把新加入的Entry的next變數指向被替換的元素,於是一個連結串列就產生了,如下圖所示:

  

   HashMap的儲存主線還是陣列,遇到雜湊碼衝突的時候則使用連結串列解決。瞭解了HashMap是如何儲存的,查詢也就一目瞭然了:使用hashCode定位元素,若有雜湊衝突,則遍歷對比,換句話說,如果沒有雜湊衝突的情況下,HashMap的查詢則是依賴hashCode定位的,因為是直接定位,那效率當然就高了。

  知道HashMap的查詢原理,我們就應該很清楚:如果雜湊碼相同,它的查詢效率就與ArrayList沒什麼兩樣了,遍歷對比,效能會大打折扣。特別是哪些進度緊張的專案中,雖重寫了hashCode方法但返回值卻是固定的,此時如果把哪些物件插入到HashMap中,查詢就相當耗時了。

  注意:HashMap中的hashCode應避免衝突。

建議80:多執行緒使用Vector或HashTable

  Vector是ArrayList的多執行緒版本,HashTable是HashMap的多執行緒版本,這些概念我們都很清楚,但我們經常會逃避使用Vector和HashTable,因為用的少,不熟嘛!只有在真正需要的時候才會想要使用它們,但問題是什麼時候真正需要呢?我們來看一個例子,看看使用多執行緒安全的Vector是否可以解決問題,程式碼如下: 

 1 public class Client80 {
 2     public static void main(String[] args) {
 3         // 火車票列表
 4         final List<String> tickets = new ArrayList<String>(100000);
 5         // 初始化票據池
 6         for (int i = 0; i < 100000; i++) {
 7             tickets.add("火車票" + i);
 8         }
 9         // 退票
10         Thread returnThread = new Thread() {
11             @Override
12             public void run() {
13                 while (true) {
14                     tickets.add("車票" + new Random().nextInt());
15                 }
16 
17             };
18         };
19 
20         // 售票
21         Thread saleThread = new Thread() {
22             @Override
23             public void run() {
24                 for (String ticket : tickets) {
25                     tickets.remove(ticket);
26                 }
27             }
28         };
29         // 啟動退票執行緒
30         returnThread.start();
31         // 啟動售票執行緒
32         saleThread.start();
33 
34     }
35 }

  模擬火車站售票程式,先初始化一堆火車票,然後開始出售,同時也有退票產生,這段程式有木有問題呢?可能會有人看出了問題,ArrayList是執行緒不安全的,兩個執行緒訪問同一個ArrayList陣列肯定會有問題。

  沒錯,確定有問題,執行結果如下:

  

 

 運氣好的話,該異常馬上就會丟擲,也會會有人說這是一個典型錯誤,只須把ArrayList替換成Vector即可解決問題,真的是這樣嗎?我們把ArrayList替換成Vector後,結果依舊。仍然丟擲相同的異常,Vector應經是執行緒安全的,為什麼還會報這個錯呢?

 這是因為它混淆了執行緒安全和同步修改異常,基本上所有的集合類都有一個快速失敗(Fail-Fast)的校驗機制,當一個集合在被多個執行緒修改並訪問時,就可能出現ConcurrentModificationException異常,這是為了確保集合方法一致而設定的保護措施,它的實現原理就是我們經常提到的modCount修改計數器:如果在讀列表時,modCount發生變化(也就是有其它執行緒修改)則會丟擲ConcurrentModificationException異常,這與執行緒同步是兩碼事,執行緒同步是為了保護集合中的資料不被髒讀、髒寫而設定的,我們來看看執行緒安全到底用在什麼地方,程式碼如下:

 1 public static void main(String[] args) {
 2         // 火車票列表
 3         final List<String> tickets = new ArrayList<String>(100000);
 4         // 初始化票據池
 5         for (int i = 0; i < 100000; i++) {
 6             tickets.add("火車票" + i);
 7         }
 8         // 10個視窗售票
 9         for (int i = 0; i < 10; i++) {
10             new Thread() {
11                 public void run() {
12                     while (true) {
13                         System.out.println(Thread.currentThread().getId()
14                                 + "----" + tickets.remove(0));
15                         if (tickets.size() == 0) {
16                             break;
17                         }
18                     }
19                 };
20             }.start();
21         }
22     }

  還是火車站售票程式,有10個視窗在賣火車票,程式列印出視窗號(也就是執行緒號)和車票編號,我們很快就可以看到這樣的輸出:

  

 

  注意看,上面有兩個執行緒在賣同一張火車票,這才是執行緒不同步的問題,此時把ArrayList修改為Vector即可解決問題,因為Vector的每個方法前都加上了synchronized關鍵字,同時知會允許一個執行緒進入該方法,確保了程式的可靠性。

  雖然在系統開發中我們一再說明,除非必要,否則不要使用synchronized,這是從效能的角度考慮的,但是一旦涉及到多執行緒(注意這裡說的是真正的多執行緒,並不是併發修改的問題,比如一個執行緒增加,一個執行緒刪除,這不屬於多執行緒的範疇),Vector會是最佳選擇,當然自己在程式中加synchronized也是可行的方法。

  HashMap的執行緒安全類HashTable與此相同,不再贅述。

建議81:非穩定排序推薦使用List

  我們知道Set和List的最大區別就是Set中的元素不可以重複(這個重複指的是equals方法的返回值相等),其它方面則沒有太大區別了,在Set的實現類中有一個比較常用的類需要了解一下:TreeSet,該類實現了預設排序為升序的Set集合,如果插入一個元素,預設會按照升序排列(當然是根據Comparable介面的compareTo的返回值確定排序位置了),不過,這樣的排序是不是在元素經常變化的場景中也適用呢?我們來看看例子:  

 1 public class Client81 {
 2     public static void main(String[] args) {
 3         SortedSet<Person> set = new TreeSet<Person>();
 4         // 身高180CM
 5         set.add(new Person(180));
 6         // 身高175CM
 7         set.add(new Person(175));
 8         for (Person p : set) {
 9             System.out.println("身高:" + p.getHeight());
10         }
11     }
12 
13     static class Person implements Comparable<Person> {
14         // 身高
15         private int height;
16 
17         public Person(int _height) {
18             height = _height;
19         }
20 
21         public int getHeight() {
22             return height;
23         }
24 
25         public void setHeight(int height) {
26             this.height = height;
27         }
28 
29         // 按照身高排序
30         @Override
31         public int compareTo(Person o) {
32             return height - o.height;
33         }
34 
35     }
36 }

  這是Set的簡單用法,定義一個Set集合,之後放入兩個元素,雖然175後放入,但是由於是按照升序排列的,所以輸出結果應該是175在前,180在後。

  這沒有問題,隨著時間的推移,身高175cm的人長高了10cm,而180cm卻保持不變,那排序位置應該改變一下吧,程式碼如下: 

 1 public static void main(String[] args) {
 2         SortedSet<Person> set = new TreeSet<Person>();
 3         // 身高180CM
 4         set.add(new Person(180));
 5         // 身高175CM
 6         set.add(new Person(175));
 7         set.first().setHeight(185);
 8         for (Person p : set) {
 9             System.out.println("身高:" + p.getHeight());
10         }
11     }

  找出身高最矮的人,也就是排在第一位的人,然後修改一下身高值,重新排序了?我們看下輸出結果:

  

 

  很可惜,竟然沒有重現排序,偏離了我們的預期。這正是下面要說明的問題,SortedSet介面(TreeSet實現了此介面)只是定義了在給集合加入元素時將其進行排序,並不能保證元素修改後的排序結果,因此TreeSet適用於不變數的集合資料排序,比如String、Integer等型別,但不使用與可變數的排序,特別是不確定何時元素會發生變化的資料集合。

  原因知道了,那如何解決此類重排序問題呢?有兩種方式:

  (1)、Set集合重排序:重新生成一個Set物件,也就是對原有的Set物件重新排序,程式碼如下:

         set.first().setHeight(185);
        //set重排序
        set=new TreeSet<Person>(new ArrayList<Person>(set));

  就這一行紅色程式碼即可重新排序,可能有人會問,使用TreeSet<SortedSet<E> s> 這個建構函式不是可以更好的解決問題嗎?不行,該建構函式只是原Set的淺拷貝,如果裡面有相同的元素,是不會重新排序的。

  (2)、徹底重構掉TreeSet,使用List解決問題

    我們之所以使用TreeSet是希望實現自動排序,即使修改也能自動排序,既然它無法實現,那就用List來代替,然後使用Collections.sort()方法對List排序,程式碼比較簡單,不再贅述。

  兩種方式都可以解決我們的問題,如果需要保證集合中元素的唯一性,又要保證元素值修改後排序正確,那該如何處理呢?List不能保證集合中的元素唯一,它是可以重複的,而Set能保證元素唯一,不重複。如果採用List解決排序問題,就需要自行解決元素重複問題(若要剔除也很簡單,轉變為HashSet,剔除後再轉回來)。若採用TreeSet,則需要解決元素修改後的排序問題,孰是孰非,就需要根據具體的開發場景來決定了。

  注意:SortedSet中的元素被修改後可能會影響到其排序位置。

建議82:由點及面,集合大家族總結

  Java中的集合類實在是太豐富了,有常用的ArrayList、HashMap,也有不常用的Stack、Queue,有執行緒安全的Vector、HashTable,也有執行緒不安全的LinkedList、TreeMap,有阻塞式的ArrayBlockingQueue,也有非阻塞式的PriorityQueue等,整個集合大家族非常龐大,可以劃分以下幾類:

  (1)、List:實現List介面的集合主要有:ArrayList、LinkedList、Vector、Stack,其中ArrayList是一個動態陣列,LinkedList是一個雙向連結串列,Vector是一個執行緒安全的動態陣列,Stack是一個物件棧,遵循先進後出的原則。  

  (2)、Set:Set是不包含重複元素的集合,其主要實現類有:EnumSet、HashSet、TreeSet,其中EnumSet是列舉型別專用Set,所有元素都是列舉型別;HashSet是以雜湊碼決定其元素位置的Set,其原理與HashMap相似,它提供快速的插入和查詢方法;TreeSet是一個自動排序的Set,它實現了SortedSet介面。

  (3)、Map:Map是一個大家族,他可以分為排序Map和非排序Map,排序Map主要是TreeMap類,他根據key值進行自動排序;非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子類,它的主要用途是從Property檔案中載入資料,並提供方便的操作,EnumMap則是要求其Key必須是某一個列舉型別。

   Map中還有一個WeakHashMap類需要說明,  它是一個採用弱鍵方式實現的Map類,它的特點是:WeakHashMap物件的存在並不會阻止垃圾回收器對鍵值對的回收,也就是說使用WeakHashMap裝載資料不用擔心記憶體溢位的問題,GC會自動刪除不用的鍵值對,這是好事。但也存在一個嚴重的問題:GC是靜悄悄的回收的(何時回收,God,Knows!)我們的程式無法知曉該動作,存在著重大的隱患。

  (4)、Queue:對列,它分為兩類,一類是阻塞式佇列,佇列滿了以後再插入元素會丟擲異常,主要包括:ArrayBlockingQueue、PriorityQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一個以陣列方式實現的有界阻塞佇列;另一類是非阻塞佇列,無邊界的,只要記憶體允許,都可以持續追加元素,我們經常使用的是PriorityQuene類。

  還有一種佇列,是雙端佇列,支援在頭、尾兩端插入和移除元素,它的主要實現類是:ArrayDeque、LinkedBlockingDeque、LinkedList。

  (5)、陣列:陣列與集合的最大區別就是陣列能夠容納基本型別,而集合就不行,更重要的一點就是所有的集合底層儲存的都是陣列。

  (6)、工具類:陣列的工具類是java.util.Arrays和java.lang.reflect.Array,集合的工具類是java.util.Collections,有了這兩個工具類,運算元組和集合就會易如反掌,得心應手。

  (7)、擴充套件類:集合類當然可以自行擴充套件了,想寫一個自己的List?沒問題,但最好的辦法還是"拿來主義",可以使用Apache的common-collections擴充套件包,也可以使用Google的google-collections擴充套件包,這些足以應對我們的開發需要。

相關文章