Java 集合學習系列(一)----HashMap 的實現原理

HashMap 的實現原理java

1. HashMap 概述:算法

HashMap 是基於哈希表的 Map 接口的非同步實現。此實現提供全部可選的映射操做,並容許使用 null 值和 null 鍵。此類不保證映射的順序,特別是它不保證該順序恆久不變。編程

2. HashMap 的數據結構: 數組

在 java 編程語言中,最基本的結構就是兩種,一個是數組,另一個是模擬指針(引
用),全部的數據結構均可以用這兩個基本結構來構造的,HashMap 也不例外。HashMap其實是一個「鏈表散列」的數據結構,即數組和鏈表的結合體。
安全

從上圖中能夠看出, HashMap 底層就是一個數組結構,數組中的每一項又是一個鏈表。當新建一個 HashMap 的時候,就會初始化一個數組。
源碼以下:
數據結構

transient Entry[] table;  
  static class Entry<K,V> implements Map.Entry<K,V> {  
      final K key;  
      V value;  
      Entry<K,V> next;  
      final int hash; 
      ……  
  }

能夠看出,Entry 就是數組中的元素,每一個 Map.Entry 其實就是一個 key-value 對,它持有一個指向下一個元素的引用,這就構成了鏈表。
3. HashMap 的存取實現:
1)  存儲:
併發

public V put(K key, V value) {  
      // HashMap 容許存放 null 鍵和 null 值。  
      // 當 key 爲 null 時,調用 putForNullKey 方法,將 value 放置在數組第一個位置。  
      if (key == null)  
          return putForNullKey(value);  
      // 根據 key 的 keyCode 從新計算 hash 值。  
      int hash = hash(key.hashCode());  
      // 搜索指定 hash 值在對應 table 中的索引。  
      int i = indexFor(hash, table.length);  
      // 若是 i 索引處的 Entry 不爲 null,經過循環不斷遍歷 e 元素的下一個元素。  
      for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
          Object k;  
          if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
              V oldValue = e.value;  
              e.value = value;  
              e.recordAccess(this);  
              return oldValue;  
          }  
      }  
      // 若是 i 索引處的 Entry 爲 null,代表此處尚未 Entry。  
      modCount++;  
      // 將 key、value 添加到 i 索引處。  
      addEntry(hash, key, value, i);  
      return null;  
}

從上面的源代碼中能夠看出:當咱們往 HashMap 中 put 元素的時候,先根據 key 的
hashCode 從新計算 hash 值,根據 hash 值獲得這個元素在數組中的位置(即標),若是數組該位置上已經存放有其餘元素了,那麼在這個位置上的元素將以鏈表的形式存放,新加入的放在鏈頭,最早加入的放在鏈尾。若是數組該位置上沒有元素,就直接將該元素放到此數組中的該位置上。
編程語言

addEntry(hash, key, value, i)方法根據計算出的 hash 值,將 key-value 對放在數組 table的 i 索引處。addEntry 是 HashMap 提供的一個包訪問權限的方法,代碼以下:
Java 代碼
性能

void addEntry(int hash, K key, V value, int bucketIndex) {  

      // 獲取指定 bucketIndex 索引處的 Entry   

      Entry<K,V> e = table[bucketIndex];  

     // 將新建立的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的Entry  

      table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  

      // 若是 Map 中的 key-value 對的數量超過了極限  

      if (size++ >= threshold)  

      // 把 table 對象的長度擴充到原來的 2 倍。  

          resize(2 * table.length);  

  }

當系統決定存儲 HashMap 中的 key-value 對時,徹底沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每一個 Entry 的存儲位置。咱們徹底能夠把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的存儲位置以後,value 隨之保存在那裏便可。
hash(int h)方法根據 key 的 hashCode 從新計算一次散列。此算法加入了高位計算,防止低位不變,高位變化時,形成的 hash 衝突。
Java 代碼
優化

static int hash(int h) {  

      h ^= (h >>> 20) ^ (h >>> 12);  

      return h ^ (h >>> 7) ^ (h >>> 4);  

}

咱們能夠看到在 HashMap 中要找到某個元素,須要根據 key 的 hash 值來求得對應數組中的位置。如何計算這個位置就是 hash 算法。前面說過 HashMap 的數據結構是數組和鏈表的結合,因此咱們固然但願這個 HashMap 裏面的 元素位置儘可能的分佈均勻些,儘可能使得每一個位置上的元素數量只有一個,那麼當咱們用 hash 算法求得這個位置的時候,立刻就能夠知道對應位置的元素就是咱們要的,而不用再去遍歷鏈表,這樣就大大優化了查詢的效率。
對於任意給定的對象,只要它的 hashCode() 返回值相同,那麼程序調用 hash(int h) 方法所計算獲得的 hash 碼值老是相同的。咱們首先想到的就是把 hash 值對數組長度取模運算,這樣一來,元素的分佈相對來講是比較均勻的。可是,「模」運算的消耗仍是比較大的,在 HashMap 中是這樣作的:調用 indexFor(int h, int length) 方法來計算該對象應該保存在 table 數組的哪一個索引處。indexFor(int h, int length) 方法的代碼以下:
Java 代碼

static int indexFor(int h, int length) {  
      return h & (length-1);
}

這個方法很是巧妙,它經過 h & (table.length -1) 來獲得該對象的保存位,而HashMap底層數組的長度老是 2 的 n 次方,這是 HashMap 在速度上的優化。在 HashMap 構造器中有以下代碼:
Java 代碼

int capacity = 1;  

      while (capacity < initialCapacity)  

          capacity <<= 1;

這段代碼保證初始化時 HashMap 的容量老是 2 的 n 次方,即底層數組的長度老是爲 2的 n 次方。當 length 老是 2 的 n 次方時,h& (length-1)運算等價於對 length 取模,也就是h%length,可是&比%具備更高的效率。
這看上去很簡單,其實比較有玄機的,咱們舉個例子來講明:
假設數組長度分別爲 15 和 16,優化後的 hash 碼分別爲 8 和 9,那麼&運算後的結果以下:

從上面的例子中能夠看出:當它們和 15-1(1110)「與」的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞, 8 和 9 會被放到數組中的同一個位置上造成鏈表,那麼查詢的時候就須要遍歷這個鏈 表,獲得 8 或者 9,這樣就下降了查詢的效率。同時,咱們也能夠發現,當數組長度爲 15 的時候,hash 值會與 15-1(1110)進行「與」,那麼 最後一位永遠是 0,而 0001, 0011, 0101, 1001, 1011, 0111,1101 這幾個位置永遠都不能存放元素了,空間浪費至關大,更糟的是這種狀況中,數組可使用的位置比數組長度小了不少,這意味着進一步增長了碰撞的概率,減慢了查詢的效率!
而當數組長度爲 16 時,即爲 2 的 n 次方時,2n-1 獲得的二進制數的每一個位上的值都爲 1,這使得在低位上&時,獲得的和原 hash 的低位相同,加之 hash(int h)方法對 key 的 hashCode的進一步優化,加入了高位計算,就使得只有相同的 hash 值的兩個值纔會被放到數組中的同一個位置上造成鏈表。因此說,當數組長度爲 2 的 n 次冪的時候,不一樣的 key 算得得 index相同的概率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的概率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。
根據上面 put 方法的源代碼能夠看出,當程序試圖將一個 key-value 對放入 HashMap中時,程序首先根據該 key 的 hashCode() 返回值決定該 Entry 的存儲位置:若是兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的存儲位置相同。若是這兩個 Entry 的 key 經過 equals 比較返回 true,新添加 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但 key 不會覆蓋。若是這兩個 Entry 的 key 經過 equals 比較返回 false,新添加的 Entry 將與集合中原有 Entry 造成 Entry 鏈,並且新添加的 Entry 位於 Entry 鏈的頭部——具體說明繼續看 addEntry() 方法的說明。
2) 讀取:
Java 代碼

public V get(Object key) {  

      if (key == null)  

          return getForNullKey();  

      int hash = hash(key.hashCode());  

      for (Entry<K,V> e = table[indexFor(hash, table.length)];  

          e != null;  

          e = e.next) {  

          Object k;  

          if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  

              return e.value;  

      }  

      return null;  

 }

有了上面存儲時的 hash 算法做爲基礎,理解起來這段代碼就很容易了。從上面的源代
碼中能夠看出:從 HashMap 中 get 元素時,首先計算 key 的 hashCode,找到數組中對應位置的某一元素,而後經過 key 的 equals 方法在對應位置的鏈表中找到須要的元素。
概括起來簡單地說,HashMap 在底層將 key-value 當成一個總體進行處理,這個總體
就是一個 Entry 對象。HashMap 底層採用一個 Entry[] 數組來保存全部的 key-value 對,當須要存儲一個 Entry 對象時,會根據 hash 算法來決定其在數組中的存儲位置,在根據 equals方法決定其在該數組位置上的鏈表中的存儲位置;當須要取出一個 Entry 時,也會根據 hash算法找到其在數組中的存儲位置,再根據 equals 方法從該位置上的鏈表中取出該Entry。
4. HashMap 的 resize(rehash):
當 HashMap 中的元素愈來愈多的時候,hash 衝突的概率也就愈來愈高,由於數組的
長度是固定的。因此爲了提升查詢的效率,就要對 HashMap 的數組進行擴容,數組擴容這個操做也會出如今 ArrayList 中,這是一個經常使用的操做,而在 HashMap 數組擴容以後,最消耗性能的點就出現了:原數組中的數據必須從新計算其在新數組中的位置,並放進去,這就是 resize。
那麼 HashMap 何時進行擴容呢?當 HashMap 中的元素個數超過數組大小*loadFactor 時,就會進行數組擴容,loadFactor 的默認值爲 0.75,這是一個折中的取值。
也就是說,默認狀況下,數組大小爲 16,那麼當 HashMap 中元素個數超過16*0.75=12 的時候,就把數組的大小擴展爲 2*16=32,即擴大一倍,而後從新計算每一個元素在數組中的位置,而這是一個很是消耗性能的操做,因此若是咱們已經預知 HashMap 中元素的個數,那麼預設元素的個數可以有效的提升 HashMap 的性能。

5. HashMap 的性能參數:
HashMap 包含以下幾個構造器:
HashMap():構建一個初始容量爲 16,負載因子爲 0.75 的 HashMap。
HashMap(int initialCapacity):構建一個初始容量爲 initialCapacity,負載因子
爲 0.75 的 HashMap。
HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。HashMap 的基礎構造器 HashMap(int initialCapacity, float loadFactor)帶有兩個參數,它們是初始容量 initialCapacity 和加載因子 loadFactor。
initialCapacity:HashMap 的最大容量,即爲底層數組的長度。
loadFactor:負載因子 loadFactor 定義爲:散列表的實際元素數目(n)/ 散列表的容量(m)。負載因子衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用鏈表法的散列表來講,查找一個元素的平均時間是 O(1+a),所以若是負載因子越大,對空間的利用更充分,然然後果是查找效率的下降;若是負載因子過小,那麼散列表的數據將過於稀疏,對空間形成嚴重浪費。
HashMap 的實現中,經過 threshold 字段來判斷 HashMap 的最大容量:
Java 代碼

threshold = (int)(capacity * loadFactor);

結合負載因子的定義公式可知, threshold 就是在此 loadFactor 和 capacity 對應下容許的最大元素數目,超過這個數目就從新 resize,以下降實際的負載因子。默認的的負載因子0.75 是對空間和時間效率的一個平衡選擇。當容量超出此最大容量時,  resize 後的 HashMap容量是容量的兩倍:
Java 代碼

if (size++ >= threshold)     

      resize(2 * table.length);

6.     Fail-Fast 機制:
咱們知道 java.util.HashMap 不是線程安全的,所以若是在使用迭代器的過程當中有其餘
線程修改了 map,那麼將拋出 ConcurrentModificationException,這就是所謂 fail-fast 策略。
這一策略在源碼中的實現是經過 modCount 域, modCount 顧名思義就是修改次數,對HashMap 內容的修改都將增長這個值,那麼在迭代器初始化過程當中會將這個值賦給迭代器的 expectedModCount。
Java 代碼

HashIterator() {  

      expectedModCount = modCount;  

      if (size > 0) { // advance to first entry  

      Entry[] t = table;  

      while (index < t.length && (next = t[index++]) == null)  

          ;  

      }  

  }

在迭代過程當中,判斷 modCount 跟 expectedModCount 是否相等,若是不相等就表示已經有其餘線程修改了 Map:
注意到 modCount 聲明爲 volatile,保證線程之間修改的可見性。
Java 代碼

final Entry<K,V> nextEntry() {     

      if (modCount != expectedModCount)     

          throw new ConcurrentModificationException();

在 HashMap 的 API 中指出:由全部 HashMap 類的「collection 視圖方法」所返回的迭代器都是快速失敗的:在迭代器建立以後,若是從結構上對映射進行修改,除非經過迭代器自己的 remove 方法,其餘任什麼時候間任何方式的修改,迭代器都將拋出 ConcurrentModificationException。所以,面對併發的修改,迭代器很快就會徹底失敗,而不冒在未來不肯定的時間發生任意不肯定行爲的風險。注意,迭代器的快速失敗行爲不能獲得保證,通常來講,存在非同步的併發修改時,不可能做出任何堅定的保證。快速失敗迭代器盡最大努力拋出ConcurrentModificationException。所以,編寫依賴於此異常的程序的作法是錯誤的,正確作法是:迭代器的快速失敗行爲應該僅用於檢測程序錯誤。

相關文章
相關標籤/搜索