HashMap 怎麼 hash?又如何 map?

HashMap 是 Java 中 Map 的一個實現類,它是一個雙列結構(數據+鏈表),這樣的結構使得它的查詢和插入效率都很高。HashMap 容許 null 鍵和值,它的鍵惟一,元素的存儲無序,而且它是線程不安全的。html

因爲 HashMap 的這些特性,它在 Java 中被普遍地使用,下面咱們就基於 Java 8 分析一下 HashMap 的源碼。前端

雙列結構:數組+鏈表

首先 HashMap 是一個雙列結構,它是一個散列表,存儲方式是鍵值對。 它繼承了 AbstractMap,實現了 Map<K,V> Cloneable Serializable 接口。node

HashMap 的雙列結構是數組 Node[]+鏈表,咱們知道數組的查詢很快,可是修改很慢,由於數組定長,因此添加或者減小元素都會致使數組擴容。而鏈表結構偏偏相反,它的查詢慢,由於沒有索引,須要遍歷鏈表查詢。可是它的修改很快,不須要擴容,只須要在首或者尾部添加便可。HashMap 正是應用了這兩種數據結構,以此來保證它的查詢和修改都有很高的效率。算法

HashMap 在調用 put() 方法存儲元素的時候,會根據 key 的 hash 值來計算它的索引,這個索引有什麼用呢?HashMap 使用這個索引來將這個鍵值對儲存到對應的數組位置,好比若是計算出來的索引是 n,則它將存儲在 Node[n] 這個位置。數據庫

HashMap 在計算索引的時候儘可能保證它的離散,但仍是會有不一樣的 key 計算出來的索引是同樣的,那麼第二次 put 的時候,key 就會產生衝突。HashMap 用鏈表的結構解決這個問題,當 HashMap 發現當前的索引下已經有不爲 null 的 Node 存在時,會在這個 Node 後面添加新元素,同一索引下的元素就組成了鏈表結構,Node 和 Node 之間如何聯繫能夠看下面 Node 類的源碼分析。編程

先了解一下 HashMap 裏數組的幾個參數:數組

DEFAULT_INITIAL_CAPACITY,默認初始長度,16:安全

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

MAXIMUM_CAPACITY,最大長度,2^30:網絡

static final int MAXIMUM_CAPACITY = 1 << 30;

DEFAULT_LOAD_FACTOR,默認加載因子,0.75:數據結構

static final float DEFAULT_LOAD_FACTOR = 0.75f;
final float loadFactor;

threshold,閾值,擴容的臨界值(capacity * load factor)

int threshold;

再看看 HashMap 構造函數

// 初始長度 加載因子
public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)

// 無參構造
public HashMap()
// 初始化一個Map
public HashMap(Map<? extends K, ? extends V> m)

下邊是很是重要的一個內部類 Node ,它實現了 Map.Entry,Node 是 HashMap 中的基本元素,每一個鍵值對都儲存在一個 Node 對象裏, Node 類有四個成員變量:hash key 的哈希值、鍵值對 key 與 value,以及 next 指針。next 也是 Node 類型,這個 Node 指向的是鏈表下一個鍵值對,這也就是前文提到的 hash 衝突時 HashMap 的處理辦法。

Node 類內部實現了 Map.Entry 接口中的 getKey()、getValue() 等方法,因此在遍歷 Map 的時候咱們能夠用 Map.entrySet() 。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; // 哈希值
        final K key;
        V value;
        // 鏈表結構, 這裏的next將指向鏈表的下一個Node鍵值對
        Node<K,V> next; 
        Node(int hash, K key, V value, Node<K,V> next) {
            ...
        }
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
    }

HashMap put() 流程

put() 方法

put() 主要是將 key 和 value 保存到 Node 數組中,HashMap 根據 key 的 hash 值來肯定它的索引,源碼裏 put 方法將調用內部的 putVal() 方法。

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}

HashMap 在 put 鍵值對的時候會調用 hash() 方法計算 key 的 hash 值,hash() 方法會調用 Object 的 native 方法 hashCode() 而且將計算以後的 hash 值高低位作異或運算,以增長 hash 的複雜度。(Java 裏一個 int 類型佔 4 個字節,一個字節是 8 bit,因此下面源碼中的 h 與 h 右移 16 位就至關於高低位異或)

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//key.hashCode() 是Object類的native方法, 實現是將內部地址轉換成一個integer, 可是並非由Java實現的
public native int hashCode();

putVal() 方法

這部分是主要 put 的邏輯

  1. 計算容量:根據 map 的 size 計算數組容量大小,若是元素數量也就是 size 大於數組容量 ×0.75,則對數組進行擴容,擴容到原來的 2 倍。
  2. 查找數據索引:根據 key 的 hash 值和數組長度找到 Node 數組索引。
  3. 儲存:這裏有如下幾種狀況(假設計算出的 hash 爲 i,數組爲 tab,變量以代碼爲例)
    1. 當前索引爲 null,直接 new 一個 Node 並存到數組裏,tab[i]=newNode(hash, key, value, null)
    2. 數組不爲空,這時兩個元素的 hash 是同樣的,再調用 equals 方法判斷 key 是否一致,相同,則覆蓋當前的 value,不然繼續向下判斷
    3. 上面兩個條件都不知足,說明 hash 發生衝突,Java 8 裏實現了紅黑樹,紅黑樹在進行插入和刪除操做時經過特定算法保持二叉查找樹的平衡,從而能夠得到較高的查找性能。本篇也是基於 Java 8 的源碼進行分析,在這裏 HashMap 會判斷當前數組上的元素 tab[i] 是不是紅黑樹,若是是,調用紅黑樹的 putTreeVal 的 put 方法,它會將新元素以紅黑樹的數據結構儲存到數組中。

若是以上條件都不成立,代表 tab[i] 上有其它 key 元素存在,而且沒有轉成紅黑樹結構,這時只需調用 tab[i].next 來遍歷此鏈表,找到鏈表的尾而後將元素存到當前鏈表的尾部。

transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
            boolean evict) {
 Node<K,V>[] tab; Node<K,V> p; int n, i;
 // 根據當前的map size計算容量大小capacity, 主要實現是在resize()中計算capacity,須要擴容的時候, 長度左移一位(二倍)
 if ((tab = table) == null || (n = tab.length) == 0)
     n = (tab = resize()).length;
 // 這裏就是常常說的桶結構了, 看過HashMap介紹的都知道它的內部有不一樣的桶, 這個桶實際上就是一個鏈表結構
 // 在這個地方, HashMap先判斷key所屬的桶是否存在。 (n - 1) & hash 至關於計算桶的序號, 根據桶序號來找到對應的桶
 // 這裏的table 是HashMap的數組, 數組爲空就新建一個數組 newNode(hash, key, value, null)
 if ((p = tab[i = (n - 1) & hash]) == null)
     tab[i] = newNode(hash, key, value, null);
 else {
     //數組不爲空, 先判斷key是否存在, 存在 就覆蓋value
     Node<K,V> e; K k;
     if (p.hash == hash &&
         ((k = p.key) == key || (key != null && key.equals(k))))
         e = p;
     // 若是此鏈表是紅黑樹結構(TreeNode)
     else if (p instanceof TreeNode)
         e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
     else {
         // 循環當前鏈表, 找出p.next爲空的位置就是鏈表的末端, 添加上
         for (int binCount = 0; ; ++binCount) {
             if ((e = p.next) == null) { 
                p.next = newNode(hash, key, value, null);
                 // 這裏會判斷這個鏈表是否須要轉換爲紅黑樹鏈表
                 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                     treeifyBin(tab, hash);
                 break;
             }
             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)
     // put以後,若是元素個數大於當前的數組容量了,進行數組擴容
     resize();
 afterNodeInsertion(evict);
 return null;
}

HashMap 的 get()

get() 方法會調用 getNode() 方法,這是 get() 的核心,getNode() 方法的兩個參數分別是 hash 值和 key。

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

這裏重點來看 getNode() 方法,前面講到過,HashMap 是經過 key 生成的 hash 值來存儲到數組的對應索引上,HashMap 在 get 的時候也是用這種方式來查找元素的。

  1. 根據 hash 值和數組長度找到 key 對應的數組索引。
  2. 拿到當前的數組元素,也就是這個鏈表的第一個元素 first,先用 hash 和 equals() 判斷是否是第一個元素,是的話直接返回,不是的話繼續下面的邏輯。
  3. 不是鏈表的第一個元素,判斷這個元素 first 是否是紅黑樹,若是是調用紅黑樹的 getTreeNode 方法來查詢。
  4. 若是不是紅黑樹結構,從 first 元素開始遍歷當前鏈表,直到找到要查詢的元素,若是沒有則返回 null。
final Node<K,V> getNode(int hash, Object key) {
   // tab: HashMap的數組
   Node<K,V>[] tab; 
   Node<K,V> first, e; 
   int n; K k;
   // 判斷數組不爲空, 桶不爲空
   if ((tab = table) != null && (n = tab.length) > 0 &&
       (first = tab[(n - 1) & hash]) != null) {
       // 先查詢桶的第一個元素
       if (first.hash == hash && // always check first node
           ((k = first.key) == key || (key != null && key.equals(k))))
           return first;
       // 不是第一個元素
       if ((e = first.next) != null) {
           // 若是是紅黑樹, 則用紅黑樹的方法查詢
           if (first instanceof TreeNode)
               return ((TreeNode<K,V>)first).getTreeNode(hash, key);
           // 不是紅黑樹, 遍歷桶, 直到找到對應的key, 返回
           do {
               // 1. 判斷hash值是否相等; 2. 判斷key相等。 防止hash碰撞發生
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   return e;
           } while ((e = e.next) != null);
       }
   }
   return null;
    }

數組擴容時再哈希(re-hash)的理解

前面提到,當 HashMap 在 put 元素的時候,HashMap 會調用 resize() 方法來從新計算數組容量,數組擴容以後,數組長度發生變化。咱們知道 HashMap 是根據 key 的 hash 和數組長度計算元素位置的,那當數組長度發生變化時,若是不從新計算元素的位置,當咱們 get 元素的時候就找不到正確的元素了,因此 HashMap 在擴容的同時也從新對數組元素進行了計算。

這時還有一個問題,re-hash 的時候同一個桶(bucket)上的鏈表會從新排列仍是鏈表仍然在同一桶上。先考慮一下當它擴容的時候同一個桶上的元素再與新數組長度作與運算 & 時,可能計算出來的數組索引不一樣。假如數組長度是 16,擴容後的數組長度將是 32。

下邊用二進制說明這個問題:

最終的結果是 0000 1111,和用 oldLen 計算的結果同樣,其實看上式能夠發現真正能改變索引值的是 hash 第 5 位(從右向左)上的值,也就是 length 的最高非零位,因此,同一個鏈表上的元素在擴容後,它們的索引只有兩種可能,一種就是保持原位(最高非零位是 0),另外一種就是 length+ 原索引 i (第五位是 1,結果就相等於 25+原索引 i,也就是 length+i)。

下邊所示的 HashMap 源碼中就是用這個思路來 re-hash 一個桶上的鏈表,e.hash & oldCap == 0 判斷 hash 對應 length 的最高非 0 位是不是 1,是 1 則把元素存在原索引,不然將元素存在 length+原索引的位置。HashMap 定義了四個 Node 對象,lo 開頭的是低位的鏈表(原索引),hi 開頭的是高位的鏈表(length+原索引,因此至關因而新 length 的高位)。

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) {
        // 最高非零位與操做結果是0,擴容後元素索引不發生變化
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        // 須要將元素放到新的索引上
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    // 這部分的鏈表索引沒有發生變化,將鏈表放到原索引上
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    // 這部分的鏈表索引起生變化,將鏈表放到新索引上
    newTab[j + oldCap] = hiHead;
}

HashMap 與 HashTable

另外對比一下 HashMap 與 HashTable:

  • HashMap 是線程不安全的,HashTable 線程安全,由於它在 get、put 方法上加了 synchronized 關鍵字。
  • HashMap 和 HashTable 的 hash 值是不同的,所在的桶的計算方式也不同。HashMap 的桶是經過 & 運算符來實現 (tab.length - 1) & hash,而 HashTable 是經過取餘計算,速度更慢(hash & 0x7FFFFFFF) % tab.length (當 tab.length = 2^n 時,由於 HashMap 的數組長度正好都是 2^n,因此二者是等價的)
  • HashTable 的 synchronized 是方法級別的,也就是它是在 put() 方法上加的,這也就是說任何一個 put 操做都會使用同一個鎖,而實際上不一樣索引上的元素之間彼此操做不會受到影響;ConcurrentHashMap 至關因而 HashTable 的升級,它也是線程安全的,並且只有在同一個桶上加鎖,也就是說只有在多個線程操做同一個數組索引的時候才加鎖,極大提升了效率。

總結

  • HashMap 底層是數組+鏈表結構,數組長度默認是 16,當元素的個數大於數組長度×0.75 時,數組會擴容。
  • HashMap 是散列表,它根據 key 的 hash 值來找到對應的數組索引來儲存, 發生 hash 碰撞的時候(計算出來的 hash 值相等) HashMap 將採用拉鍊式來儲存元素,也就是咱們所說的單向鏈表結構。
  • 在 Java7 中,若是 hash 碰撞,致使拉鍊過長,查詢的性能會降低, 因此在 Java8 中添加紅黑樹結構,當一個桶的長度超過 8 時,將其轉爲紅黑樹鏈表,若是小於 6,又從新轉換爲普通鏈表。
  • re-hash 再哈希問題:HashMap 擴容的時候會從新計算每個元素的索引,從新計算以後的索引只有兩種可能,要麼等於原索引要麼等於原索引加上原數組長度。
  • 由上一條可知,每次擴容,整個 hash table 都須要從新計算索引,很是耗時,因此在平常使用中必定要注意這個問題。

參考文檔

做者介紹

樊騰飛,有開源精神,樂於分享,但願經過寫博客認識更多志同道合的人。我的博客:rollsbean.com

本文系做者投稿文章。歡迎投稿。

投稿內容要求

  • 互聯網技術相關,包括但不限於開發語言、網絡、數據庫、架構、運維、前端、DevOps(DevXXX)、AI、區塊鏈、存儲、移動、安全、技術團隊管理等內容。
  • 文章不須要首發,能夠是已經在開源中國博客或網上其它平臺發佈過的。可是鼓勵首發,首發內容被收錄可能性較大。
  • 若是你是記錄某一次解決了某一個問題(這在博客中佔絕大比例),那麼須要將問題的來龍去脈描述清楚,最直接的就是結合圖文等方式將問題復現,同時完整地說明解決思路與最終成功的方案。
  • 若是你是分析某一技術理論知識,請從定義、應用場景、實際案例、關鍵技術細節、觀點等方面,對其進行較爲全面地介紹。
  • 若是你是以實際案例分享本身或者公司對諸如某一架構模型、通用技術、編程語言、運維工具的實踐,那麼請將事件相關背景、具體技術細節、演進過程、思考、應用效果等方面描述清楚
  • 其它未盡 case 具體狀況具體分析,不虛的,文章投過來試試先,好比咱們並不拒絕就某個熱點事件對其進行的報導、深刻解析。

投稿方式

重要說明

  • 做者須要擁有所投文章的全部權,不能將別人的文章拿過來投遞。
  • 投遞的文章須要通過審覈,若是開源中國編輯以爲須要的話,將與做者一塊兒進一步完善文章,意在使文章更佳、傳播更廣。
  • 文章版權歸做者全部,開源中國得到文章的傳播權,可在開源中國各個平臺進行文章傳播,同時保留文章原始出處和做者信息,可在官方博客中標原創標籤。
相關文章
相關標籤/搜索