1. 程式人生 > 其它 >JDK1.8 HashMap

JDK1.8 HashMap

原文地址 tech.meituan.com

摘要 HashMap 是 Java 程式設計師使用頻率最高的用於對映 (鍵值對) 處理的資料型別。隨著 JDK(Java Developmet Kit)版本的更新,JDK1.8 對 H

摘要

HashMap 是 Java 程式設計師使用頻率最高的用於對映 (鍵值對) 處理的資料型別。隨著 JDK(Java Developmet Kit)版本的更新,JDK1.8 對 HashMap 底層的實現進行了優化,例如引入紅黑樹的資料結構和擴容的優化等。本文結合 JDK1.7 和 JDK1.8 的區別,深入探討 HashMap 的結構實現和功能原理。

簡介

Java 為資料結構中的對映定義了一個介面 java.util.Map,此介面主要有四個常用的實現類,分別是 HashMap、Hashtable、LinkedHashMap 和 TreeMap,類繼承關係如下圖所示:

下面針對各個實現類的特點做一些說明:

(1) HashMap:它根據鍵的 hashCode 值儲存資料,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但遍歷順序卻是不確定的。 HashMap 最多隻允許一條記錄的鍵為 null,允許多條記錄的值為 null。HashMap 非執行緒安全,即任一時刻可以有多個執行緒同時寫 HashMap,可能會導致資料的不一致。如果需要滿足執行緒安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有執行緒安全的能力,或者使用 ConcurrentHashMap。

(2) Hashtable:Hashtable 是遺留類,很多對映的常用功能與 HashMap 類似,不同的是它承自 Dictionary 類,並且是執行緒安全的,任一時間只有一個執行緒能寫 Hashtable,併發性不如 ConcurrentHashMap,因為 ConcurrentHashMap 引入了分段鎖。Hashtable 不建議在新程式碼中使用,不需要執行緒安全的場合可以用 HashMap 替換,需要執行緒安全的場合可以用 ConcurrentHashMap 替換。

(3) LinkedHashMap:LinkedHashMap 是 HashMap 的一個子類,儲存了記錄的插入順序,在用 Iterator 遍歷 LinkedHashMap 時,先得到的記錄肯定是先插入的,也可以在構造時帶引數,按照訪問次序排序。

(4) TreeMap:TreeMap 實現 SortedMap 介面,能夠把它儲存的記錄根據鍵排序,預設是按鍵值的升序排序,也可以指定排序的比較器,當用 Iterator 遍歷 TreeMap 時,得到的記錄是排過序的。如果使用排序的對映,建議使用 TreeMap。在使用 TreeMap 時,key 必須實現 Comparable 介面或者在構造 TreeMap 傳入自定義的 Comparator,否則會在執行時丟擲 java.lang.ClassCastException 型別的異常。

對於上述四種 Map 型別的類,要求對映中的 key 是不可變物件。不可變物件是該物件在建立後它的雜湊值不會被改變。如果物件的雜湊值發生變化,Map 物件很可能就定位不到對映的位置了。

通過上面的比較,我們知道了 HashMap 是 Java 的 Map 家族中一個普通成員,鑑於它可以滿足大多數場景的使用條件,所以是使用頻度最高的一個。下文我們主要結合原始碼,從儲存結構、常用方法分析、擴容以及安全性等方面深入講解 HashMap 的工作原理。

內部實現

搞清楚 HashMap,首先需要知道 HashMap 是什麼,即它的儲存結構 - 欄位;其次弄明白它能幹什麼,即它的功能實現 - 方法。下面我們針對這兩個方面詳細展開講解。

儲存結構 - 欄位

從結構實現來講,HashMap 是陣列 + 連結串列 + 紅黑樹(JDK1.8 增加了紅黑樹部分)實現的,如下如所示。

這裡需要講明白兩個問題:資料底層具體儲存的是什麼?這樣的儲存方式有什麼優點呢?

(1) 從原始碼可知,HashMap 類中有一個非常重要的欄位,就是 Node[] table,即雜湊桶陣列,明顯它是一個 Node 的陣列。我們來看 Node[JDK1.8] 是何物。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //用來定位陣列索引位置
        final K key;
        V value;
        Node<K,V> next;   //連結串列的下一個node

        Node(int hash, K key, V value, Node<K,V> next) { ... }
        public final K getKey(){ ... }
        public final V getValue() { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}

Node 是 HashMap 的一個內部類,實現了 Map.Entry 介面,本質是就是一個對映 (鍵值對)。上圖中的每個黑色圓點就是一個 Node 物件。

(2) HashMap 就是使用雜湊表來儲存的。雜湊表為解決衝突,可以採用開放地址法和鏈地址法等來解決問題,Java 中 HashMap 採用了鏈地址法。鏈地址法,簡單來說,就是陣列加連結串列的結合。在每個陣列元素上都一個連結串列結構,當資料被 Hash 後,得到陣列下標,把資料放在對應下標元素的連結串列上。例如程式執行下面程式碼:

map.put("美團","小美");

系統將呼叫” 美團” 這個 key 的 hashCode() 方法得到其 hashCode 值(該方法適用於每個 Java 物件),然後再通過 Hash 演算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的儲存位置,有時兩個 key 會定位到相同的位置,表示發生了 Hash 碰撞。當然 Hash 演算法計算結果越分散均勻,Hash 碰撞的概率就越小,map 的存取效率就會越高。

如果雜湊桶陣列很大,即使較差的 Hash 演算法也會比較分散,如果雜湊桶陣列陣列很小,即使好的 Hash 演算法也會出現較多碰撞,所以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況確定雜湊桶陣列的大小,並在此基礎上設計好的 hash 演算法減少 Hash 碰撞。那麼通過什麼方式來控制 map 使得 Hash 碰撞的概率又小,雜湊桶陣列(Node[] table)佔用空間又少呢?答案就是好的 Hash 演算法和擴容機制。

在理解 Hash 和擴容流程之前,我們得先了解下 HashMap 的幾個欄位。從 HashMap 的預設建構函式原始碼可知,建構函式就是對下面幾個欄位進行初始化,原始碼如下:

int threshold;             // 所能容納的key-value對極限 
final float loadFactor;    // 負載因子
int modCount;  
int size;

首先,Node[] table 的初始化長度 length(預設值是 16),Load factor 為負載因子 (預設值是 0.75),threshold 是 HashMap 所能容納的最大資料量的 Node(鍵值對) 個數。threshold = length * Load factor。也就是說,在陣列定義好長度之後,負載因子越大,所能容納的鍵值對個數越多

結合負載因子的定義公式可知,threshold 就是在此 Load factor 和 length(陣列長度) 對應下允許的最大元素數目,超過這個數目就重新 resize(擴容),擴容後的 HashMap 容量是之前容量的兩倍。預設的負載因子 0.75 是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果記憶體空間很多而又對時間效率要求很高,可以降低負載因子 Load factor 的值;相反,如果記憶體空間緊張而對時間效率要求不高,可以增加負載因子 loadFactor 的值,這個值可以大於 1。

size 這個欄位其實很好理解,就是 HashMap 中實際存在的鍵值對數量。注意和 table 的長度 length、容納最大鍵值對數量 threshold 的區別。而 modCount 欄位主要用來記錄 HashMap 內部結構發生變化的次數,主要用於迭代的快速失敗。強調一點,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。

在 HashMap 中,雜湊桶陣列 table 的長度 length 大小必須為 2 的 n 次方 (一定是合數),這是一種非常規的設計,常規的設計是把桶的大小設計為素數。相對來說素數導致衝突的概率要小於合數,具體證明可以參考這篇文章,Hashtable 初始化桶大小為 11,就是桶大小設計為素數的應用(Hashtable 擴容後不能保證還是素數)。HashMap 採用這種非常規設計,主要是為了在取模和擴容時做優化,同時為了減少衝突,HashMap 定位雜湊桶索引位置時,也加入了高位參與運算的過程。

這裡存在一個問題,即使負載因子和 Hash 演算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的效能。於是,在 JDK1.8 版本中,對資料結構做了進一步的優化,引入了紅黑樹。而當連結串列長度太長(預設超過 8)時,連結串列就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高 HashMap 的效能,其中會用到紅黑樹的插入、刪除、查詢等演算法。本文不再對紅黑樹展開討論,想了解更多紅黑樹資料結構的工作原理可以參考這篇文章

功能實現 - 方法

HashMap 的內部功能實現很多,本文主要從根據 key 獲取雜湊桶陣列索引位置、put 方法的詳細執行、擴容過程三個具有代表性的點深入展開講解。

1. 確定雜湊桶陣列索引位置

不管增加、刪除、查詢鍵值對,定位到雜湊桶陣列的位置都是很關鍵的第一步。前面說過 HashMap 的資料結構是陣列和連結串列的結合,所以我們當然希望這個 HashMap 裡面的元素位置儘量分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用 hash 演算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,不用遍歷連結串列,大大優化了查詢的效率。HashMap 定位陣列索引位置,直接決定了 hash 方法的離散效能。先看看原始碼的實現 (方法一 + 方法二):

//方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 為第一步 取hashCode值
     // h ^ (h >>> 16)  為第二步 高位參與運算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) {  //jdk1.7的原始碼,jdk1.8沒有這個方法,但是實現原理一樣的
     return h & (length-1);  //第三步 取模運算
}

這裡的 Hash 演算法本質上就是三步:取 key 的 hashCode 值、高位運算、取模運算

對於任意給定的物件,只要它的 hashCode() 返回值相同,那麼程式呼叫方法一所計算得到的 Hash 碼值總是相同的。我們首先想到的就是把 hash 值對陣列長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。但是,模運算的消耗還是比較大的,在 HashMap 中是這樣做的:呼叫方法二來計算該物件應該儲存在 table 陣列的哪個索引處。

這個方法非常巧妙,它通過 h & (table.length -1) 來得到該物件的儲存位,而 HashMap 底層陣列的長度總是 2 的 n 次方,這是 HashMap 在速度上的優化。當 length 總是 2 的 n 次方時,h& (length-1) 運算等價於對 length 取模,也就是 h%length,但是 & 比 % 具有更高的效率。

在 JDK1.8 的實現中,優化了高位運算的演算法,通過 hashCode() 的高 16 位異或低 16 位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在陣列 table 的 length 比較小的時候,也能保證考慮到高低 Bit 都參與到 Hash 的計算中,同時不會有太大的開銷。

下面舉例說明下,n 為 table 的長度。

2. 分析 HashMap 的 put 方法

HashMap 的 put 方法執行過程可以通過下圖來理解,自己有興趣可以去對比原始碼更清楚地研究學習。

①. 判斷鍵值對陣列 table[i] 是否為空或為 null,否則執行 resize() 進行擴容;

②. 根據鍵值 key 計算 hash 值得到插入的陣列索引 i,如果 table[i]==null,直接新建節點新增,轉向⑥,如果 table[i] 不為空,轉向③;

③. 判斷table[i] 的首個元素是否和 key 一樣,如果相同直接覆蓋 value,否則轉向④,這裡的相同指的是 hashCode 以及 equals;

④. 判斷 table[i] 是否為 treeNode,即 table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;

⑤. 遍歷 table[i],判斷連結串列長度是否大於 8,大於 8 的話把連結串列轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行連結串列的插入操作;遍歷過程中若發現 key 已經存在直接覆蓋 value 即可;

⑥. 插入成功後,判斷實際存在的鍵值對數量 size 是否超多了最大容量 threshold,如果超過,進行擴容。

JDK1.8HashMap 的 put 方法原始碼如下:

 public V put(K key, V value) {
      // 對key的hashCode()做hash
      return putVal(hash(key), key, value, false, true);
  }
  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                 boolean evict) {
      Node<K,V>[] tab; Node<K,V> p; int n, i;
      // 步驟①:tab為空則建立
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // 步驟②:計算index,並對null做處理 
     if ((p = tab[i = (n - 1) & hash]) == null) 
         tab[i] = newNode(hash, key, value, null);
     else {
         Node<K,V> e; K k;
         // 步驟③:節點key存在,直接覆蓋value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // 步驟④:判斷該鏈為紅黑樹
         else if (p instanceof TreeNode)
             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
         // 步驟⑤:該鏈為連結串列
         else {
             for (int binCount = 0; ; ++binCount) {
                 if ((e = p.next) == null) {
                     p.next = newNode(hash, key,value,null);
                        //連結串列長度大於8轉換為紅黑樹進行處理
                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                         treeifyBin(tab, hash);
                     break;
                 }
                    // key已經存在直接覆蓋value
                 if (e.hash == hash &&
                     ((k = e.key) == key || (key != null && key.equals(k)))) 
							break;
                 p = e;
             }
         }
         if (e != null) { // existing mapping for key
             V oldValue = e.value;
             if (!onlyIfAbsent || oldValue == null)
                 e.value = value;
             afterNodeAccess(e);
             return oldValue;
         }
     }
     ++modCount;
     // 步驟⑥:超過最大容量 就擴容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
 }

3. 擴容機制

擴容 (resize) 就是重新計算容量,向 HashMap 物件裡不停的新增元素,而 HashMap 物件內部的陣列無法裝載更多的元素時,物件就需要擴大陣列的長度,以便能裝入更多的元素。當然 Java 裡的陣列是無法自動擴容的,方法是使用一個新的陣列代替已有的容量小的陣列,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。

我們分析下 resize 的原始碼,鑑於 JDK1.8 融入了紅黑樹,較複雜,為了便於理解我們仍然使用 JDK1.7 的程式碼,好理解一些,本質上區別不大,具體區別後文再說。

 void resize(int newCapacity) {   //傳入新的容量
      Entry[] oldTable = table;    //引用擴容前的Entry陣列
      int oldCapacity = oldTable.length;         
      if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的陣列大小如果已經達到最大(2^30)了
          threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
          return;
      }
      Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry陣列
      transfer(newTable);                         //!!將資料轉移到新的Entry數組裡
      table = newTable;                           //HashMap的table屬性引用新的Entry陣列
      threshold = (int)(newCapacity * loadFactor);//修改閾值
 }

這裡就是使用一個容量更大的陣列來代替已有的容量小的陣列,transfer() 方法將原有 Entry 陣列的元素拷貝到新的 Entry 數組裡。

 void transfer(Entry[] newTable) {
      Entry[] src = table;                   //src引用了舊的Entry陣列
      int newCapacity = newTable.length;
      for (int j = 0; j < src.length; j++) { //遍歷舊的Entry陣列
          Entry<K,V> e = src[j];             //取得舊Entry陣列的每個元素
          if (e != null) {
              src[j] = null;//釋放舊Entry陣列的物件引用(for迴圈後,舊的Entry陣列不再引用任何物件)
              do {
                  Entry<K,V> next = e.next;
                  int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在陣列中的位置
                  e.next = newTable[i]; //標記[1]
                  newTable[i] = e;      //將元素放在陣列上
                  e = next;             //訪問下一個Entry鏈上的元素
              } while (e != null);
          }
      }
  }

newTable[i] 的引用賦給了 e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在連結串列的頭部位置;這樣先放在一個索引上的元素終會被放到 Entry 鏈的尾部 (如果發生了 hash 衝突的話),這一點和 Jdk1.8 有區別,下文詳解。在舊陣列中同一條 Entry 鏈上的元素,通過重新計算索引位置後,有可能被放到了新陣列的不同位置上。

下面舉個例子說明下擴容過程。假設了我們的 hash 演算法就是簡單的用 key mod 一下表的大小(也就是陣列的長度)。其中的雜湊桶陣列 table 的 size=2, 所以 key = 3、7、5,put 順序依次為 5、7、3。在 mod 2 以後都衝突在 table[1] 這裡了。這裡假設負載因子 loadFactor=1,即當鍵值對的實際大小 size 大於 table 的實際大小時進行擴容。接下來的三個步驟是雜湊桶陣列 resize 成 4,然後所有的 Node 重新 rehash 的過程。

下面我們講解下 JDK1.8 做了哪些優化。經過觀測可以發現,我們使用的是 2 次冪的擴充套件 (指長度擴為原來 2 倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動 2 次冪的位置。看下圖可以明白這句話的意思,n 為 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容後 key1 和 key2 兩種 key 確定索引位置的示例,其中 hash1 是 key1 對應的雜湊與高位運算結果。

元素在重新計算 hash 之後,因為 n 變為 2 倍,那麼 n-1 的 mask 範圍在高位多 1bit(紅色),因此新的 index 就會發生這樣的變化:

因此,我們在擴充 HashMap 的時候,不需要像 JDK1.7 的實現那樣重新計算 hash,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成 “原索引 + oldCap”,可以看看下圖為 16 擴充為 32 的 resize 示意圖:

這個設計確實非常的巧妙,既省去了重新計算 hash 值的時間,而且同時,由於新增的 1bit 是 0 還是 1 可以認為是隨機的,因此 resize 的過程,均勻的把之前的衝突的節點分散到新的 bucket 了。這一塊就是 JDK1.8 新增的優化點。有一點注意區別,JDK1.7 中 rehash 的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置,但是從上圖可以看出,JDK1.8 不會倒置。有興趣的同學可以研究下 JDK1.8 的 resize 原始碼,寫的很贊,如下:

 final Node<K,V>[] resize() {
      Node<K,V>[] oldTab = table;
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      int oldThr = threshold;
      int newCap, newThr = 0;
      if (oldCap > 0) {
          // 超過最大值就不再擴充了,就只好隨你碰撞去吧
          if (oldCap >= MAXIMUM_CAPACITY) {
              threshold = Integer.MAX_VALUE;
              return oldTab;
          }
          // 沒超過最大值,就擴充為原來的2倍
          else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                   oldCap >= DEFAULT_INITIAL_CAPACITY)
              newThr = oldThr << 1; // double threshold
      }
      else if (oldThr > 0) // initial capacity was placed in threshold
          newCap = oldThr;
      else {               // zero initial threshold signifies using defaults
          newCap = DEFAULT_INITIAL_CAPACITY;
          newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }
      // 計算新的resize上限
      if (newThr == 0) {
          float ft = (float)newCap * loadFactor;
          newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
      }
      threshold = newThr;
      @SuppressWarnings({"rawtypes","unchecked"})
          Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      table = newTab;
      if (oldTab != null) {
          // 把每個bucket都移動到新的buckets中
          for (int j = 0; j < oldCap; ++j) {
              Node<K,V> e;
              if ((e = oldTab[j]) != null) {
                  oldTab[j] = null;
                  if (e.next == null)
                      newTab[e.hash & (newCap - 1)] = e;
                  else if (e instanceof TreeNode)
                      ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                  else { // 連結串列優化重hash的程式碼塊
                      Node<K,V> loHead = null, loTail = null;
                      Node<K,V> hiHead = null, hiTail = null;
                      Node<K,V> next;
                      do {
                          next = e.next;
                          // 原索引
                          if ((e.hash & oldCap) == 0) {
                              if (loTail == null)
                                  loHead = e;
                              else
                                  loTail.next = e;
                              loTail = e;
                          }
                          // 原索引+oldCap
                          else {
                              if (hiTail == null)
                                  hiHead = e;
                              else
                                  hiTail.next = e;
                              hiTail = e;
                          }
                      } while ((e = next) != null);
                      // 原索引放到bucket裡
                      if (loTail != null) {
                          loTail.next = null;
                          newTab[j] = loHead;
                      }
                      // 原索引+oldCap放到bucket裡
                      if (hiTail != null) {
                          hiTail.next = null;
                          newTab[j + oldCap] = hiHead;
                      }
                  }
              }
          }
      } 
      return newTab;
  }

執行緒安全性

在多執行緒使用場景中,應該儘量避免使用執行緒不安全的 HashMap,而使用執行緒安全的 ConcurrentHashMap。那麼為什麼說 HashMap 是執行緒不安全的,下面舉例子說明在併發的多執行緒使用場景中使用 HashMap 可能造成死迴圈。程式碼例子如下 (便於理解,仍然使用 JDK1.7 的環境):

public class HashMapInfiniteLoop {  

    private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);  
    public static void main(String[] args) {  
        map.put(5, "C");  

        new Thread("Thread1") {  
            public void run() {  
                map.put(7, "B");  
                System.out.println(map);  
            };  
        }.start();  
        new Thread("Thread2") {  
            public void run() {  
                map.put(3, "A);  
                System.out.println(map);  
            };  
        }.start();        
    }  
}

其中,map 初始化為一個長度為 2 的陣列,loadFactor=0.75,threshold=2*0.75=1,也就是說當 put 第二個 key 的時候,map 就需要進行 resize。

通過設定斷點讓執行緒 1 和執行緒 2 同時 debug 到 transfer 方法 (3.3 小節程式碼塊) 的首行。注意此時兩個執行緒已經成功新增資料。放開 thread1 的斷點至 transfer 方法的“Entry next = e.next;” 這一行;然後放開執行緒 2 的的斷點,讓執行緒 2 進行 resize。結果如下圖。

注意,Thread1 的 e 指向了 key(3),而 next 指向了 key(7),其線上程二 rehash 後,指向了執行緒二重組後的連結串列。

執行緒一被排程回來執行,先是執行 newTalbe[i] = e, 然後是 e = next,導致了 e 指向了 key(7),而下一次迴圈的 next = e.next 導致了 next 指向了 key(3)。

e.next = newTable[i] 導致 key(3).next 指向了 key(7)。注意:此時的 key(7).next 已經指向了 key(3), 環形連結串列就這樣出現了。

於是,當我們用執行緒一呼叫 map.get(11) 時,悲劇就出現了——Infinite Loop。

JDK1.8 與 JDK1.7 的效能對比

HashMap 中,如果 key 經過 hash 演算法得出的陣列索引位置全部不相同,即 Hash 演算法非常好,那樣的話,getKey 方法的時間複雜度就是 O(1),如果 Hash 演算法技術的結果碰撞非常多,假如 Hash 算極其差,所有的 Hash 演算法結果得出的索引位置一樣,那樣所有的鍵值對都集中到一個桶中,或者在一個連結串列中,或者在一個紅黑樹中,時間複雜度分別為 O(n) 和 O(lgn)。 鑑於 JDK1.8 做了多方面的優化,總體效能優於 JDK1.7,下面我們從兩個方面用例子證明這一點。

Hash 較均勻的情況

為了便於測試,我們先寫一個類 Key,如下:

class Key implements Comparable<Key> {

    private final int value;

    Key(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }

    @Override
    public int hashCode() {
        return value;
    }
}

這個類複寫了 equals 方法,並且提供了相當好的 hashCode 函式,任何一個值的 hashCode 都不會相同,因為直接使用 value 當做 hashcode。為了避免頻繁的 GC,我將不變的 Key 例項快取了起來,而不是一遍一遍的建立它們。程式碼如下:

public class Keys {

    public static final int MAX_KEY = 10_000_000;
    private static final Key[] KEYS_CACHE = new Key[MAX_KEY];

    static {
        for (int i = 0; i < MAX_KEY; ++i) {
            KEYS_CACHE[i] = new Key(i);
        }
    }

    public static Key of(int value) {
        return KEYS_CACHE[value];
    }
}

現在開始我們的試驗,測試需要做的僅僅是,建立不同 size 的 HashMap(1、10、100、……10000000),遮蔽了擴容的情況,程式碼如下:

static void test(int mapSize) {

        HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
        for (int i = 0; i < mapSize; ++i) {
            map.put(Keys.of(i), i);
        }

        long beginTime = System.nanoTime(); //獲取納秒
        for (int i = 0; i < mapSize; i++) {
            map.get(Keys.of(i));
        }
        long endTime = System.nanoTime();
        System.out.println(endTime - beginTime);
    }

    public static void main(String[] args) {
        for(int i=10;i<= 1000 0000;i*= 10){
            test(i);
        }
    }

在測試中會查詢不同的值,然後度量花費的時間,為了計算 getKey 的平均時間,我們遍歷所有的 get 方法,計算總的時間,除以 key 的數量,計算一個平均值,主要用來比較,絕對值可能會受很多環境因素的影響。結果如下:

通過觀測測試結果可知,JDK1.8 的效能要高於 JDK1.7 15% 以上,在某些 size 的區域上,甚至高於 100%。由於 Hash 演算法較均勻,JDK1.8 引入的紅黑樹效果不明顯,下面我們看看 Hash 不均勻的的情況。

Hash 極不均勻的情況

假設我們又一個非常差的 Key,它們所有的例項都返回相同的 hashCode 值。這是使用 HashMap 最壞的情況。程式碼修改如下:

class Key implements Comparable<Key> {

    //...

    @Override
    public int hashCode() {
        return 1;
    }
}

仍然執行 main 方法,得出的結果如下表所示:

從表中結果中可知,隨著 size 的變大,JDK1.7 的花費時間是增長的趨勢,而 JDK1.8 是明顯的降低趨勢,並且呈現對數增長穩定。當一個連結串列太長的時候,HashMap 會動態的將它替換成一個紅黑樹,這話的話會將時間複雜度從 O(n) 降為 O(logn)。hash 演算法均勻和不均勻所花費的時間明顯也不相同,這兩種情況的相對比較,可以說明一個好的 hash 演算法的重要性。

測試環境:處理器為 2.2 GHz Intel Core i7,記憶體為 16 GB 1600 MHz DDR3,SSD 硬碟,使用預設的 JVM 引數,執行在 64 位的 OS X 10.10.1 上。

小結

  1. 擴容是一個特別耗效能的操作,所以當程式設計師在使用 HashMap 的時候,估算 map 的大小,初始化的時候給一個大致的數值,避免 map 進行頻繁的擴容。
  2. 負載因子是可以修改的,也可以大於 1,但是建議不要輕易修改,除非情況非常特殊。
  3. HashMap 是執行緒不安全的,不要在併發的環境中同時操作 HashMap,建議使用 ConcurrentHashMap。
  4. JDK1.8 引入紅黑樹大程度優化了 HashMap 的效能。
  5. 還沒升級 JDK1.8 的,現在開始升級吧。HashMap 的效能提升僅僅是 JDK1.8 的冰山一角。

參考文獻

  1. JDK1.7&JDK1.8 原始碼。
  2. CSDN 部落格頻道,HashMap 多執行緒死迴圈問題,2014。
  3. 紅黑聯盟,Java 類集框架之 HashMap(JDK1.8) 原始碼剖析,2015。
  4. CSDN 部落格頻道, 教你初步瞭解紅黑樹,2010。
  5. Java Code Geeks,HashMap performance improvements in Java 8,2014。
  6. Importnew,危險!在 HashMap 中將可變物件用作 Key,2014。
  7. CSDN 部落格頻道,為什麼一般 hashtable 的桶數會取一個素數,2013。