簡述HashMap的原理

哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表。

1 什麼是哈希表

在討論哈希表之前,我們先大概瞭解下其他數據結構在新增、查找等基礎操作執行性能:

數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間複雜度爲O(n),當然,對於有序數組,則可採用二分查找、插值查找、斐波那契查找等方式,可將查找複雜度提高爲O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均複雜度也爲O(n)。

線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間複雜度爲O(1),而查找操作需要遍歷鏈表逐一進行比對,複雜度爲O(n)。

二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入、查找、刪除等操作,平均複雜度均爲O(logn)。

哈希表:相比上述幾種數據結構,在哈希表中進行添加、刪除,查找等操作,性能十分之高,不考慮哈希衝突的情況下(後面會探討下哈希衝突的情況),僅需一次定位即可完成,時間複雜度爲O(1),接下來我們就來看看哈希表是如何實現達到驚豔的常數階O(1)的。

我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧、隊列、樹、圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主幹就是數組

比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字,通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。這個函數可以簡單描述爲:存儲位置 = f(關鍵字) ,這個函數f一般稱爲哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。

哈希衝突

然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎麼辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然後要進行插入的時候,發現已經被其他元素佔用了,其實這就是所謂的哈希衝突,也叫哈希碰撞

前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證計算簡單和散列地址分佈均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生衝突。

那麼哈希衝突如何解決呢?哈希衝突的解決方案有多種:開放定址法(發生衝突,繼續尋找下一塊未被佔用的存儲地址)、再散列函數法、鏈地址法。而HashMap即是採用了鏈地址法,也就是數組+鏈表的方式。

2 JDK7的HashMap實現原理

HashMap的主幹是一個Entry數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。(其實所謂Map其實就是保存了兩個對象之間的映射關係的一種集合)。Entry是HashMap中的一個靜態內部類,是一個單鏈表結構。所以HashMap的總體結構如下:

簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那麼查找、添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間複雜度爲O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然後通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能纔會越好。

例如程序執行下面代碼:

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

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

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

其他幾個重要字段:

/**實際存儲的key-value鍵值對的個數*/
transient int size;

/**閾值,當table == {}時,該值爲初始容量(初始容量默認爲16);當table被填充了,也就是爲table分配內存空間後,
threshold一般爲 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,後面會詳細談到*/
int threshold;

/**負載因子,代表了table的填充度有多少,默認是0.75
加載因子存在的原因,還是因爲減緩哈希衝突,如果初始桶爲16,等到滿16個元素才擴容,某些桶裏可能就有不止一個元素了。
所以加載因子默認爲0.75,也就是說大小爲16的HashMap,到了第13個元素,就會擴容成32。
*/
final float loadFactor;

/**HashMap被改變的次數,由於HashMap非線程安全,在對HashMap進行迭代時,
如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),
需要拋出異常ConcurrentModificationException*/
transient int modCount;

Node[] table的初始化長度length(默認值是16),Load factor爲負載因子(默認值是0.75),threshold是HashMap所能容納的最大數據量的Node(鍵值對)個數。threshold = length * Load factor。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。

當發生哈希衝突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度爲之前數組2倍的新的數組,然後將當前的Entry數組中的元素全部傳輸過去,擴容後的新數組長度爲之前的2倍,所以擴容相對來說是個耗資源的操作。

3 JDK1.8中HashMap的性能優化

這裏存在一個問題,即使負載因子和Hash算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能。

於是,在JDK1.8版本中,對數據結構做了進一步的優化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。

HashMap put方法邏輯圖(JDK1.8)

①.判斷鍵值對數組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.8 HashMap的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;
 }
4 小結

(1) 擴容是一個特別耗性能的操作,所以當程序在使用HashMap的時候,估算map的大小,初始化的時候給一個大致的數值,避免map進行頻繁的擴容。

(2) 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。

(3) HashMap是線程不安全的,不要在併發的環境中同時操作HashMap,建議使用ConcurrentHashMap。

(4) JDK1.8引入紅黑樹大程度優化了HashMap的性能。

(5) 還沒升級JDK1.8的,現在開始升級吧。HashMap的性能提升僅僅是JDK1.8的冰山一角。


題外話:HashMap原理是Java面試過程中很常見的問題,我在最近面試看機會的過程中,也是遇到有好幾回了,但自己一直都沒有好好複習整理,總是帶着一點僥倖的心理。

可前兩天原本一個以爲是十拿九穩的offer,卻因爲這個問題沒有答上來而錯失了機會。在那個過程中,我可以明顯感覺到面試官態度的轉變。畢竟對原理的把握,在一定程度上決定着對程序安全與可用性的一個把握,不管不能做到這部分的掌握,對應精通只能說還有很長一段的路要走。

試問,此時不整理更待何時呢,於是就有了自己對HashMap原理的整理。

參考文章
【1】深入淺出學Java——HashMap;
【2】HashMap 一遍就懂;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章