首頁>技術>

注:感謝美團點評技術團隊的分享~,部落格部分內容摘抄自其中。侵刪!

今天我們來探究一下 HashMap 的內部實現機制。

明確 JDK 1.8 中的 HashMap 使用陣列 + 連結串列 + 紅黑樹的結構進行實現。

HashMap 的底層思想主要是雜湊表,我們來看看 Java 的設計者們是怎麼使用陣列 + 連結串列 + 紅黑樹設計出 HashMap 的。

1 HashMap的基本屬性

既然是用雜湊表進行實現,那麼基本的資料結構就是陣列了,HashMap 部分原始碼如下:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {    transient Node<K,V>[] table;    // HashMap 底層資料結構(Node 陣列)    transient int size;             // HashMap 中實際存在的鍵值對數量    transient int modCount;         // 記錄 HashMap 內部結構發生變化的次數,用於快速失敗機制    int threshold;                  // 所能容納的 key-value 對極限(我將之稱為“負載”)    final float loadFactor;         // 負載因子:預設 0.75}

除了 table 陣列之外,我將原始碼中的常用欄位也貼了出來。對於上面的程式碼,我們需要注意以下幾點:

不瞭解 AbstractMap<K,V> 抽象類、Map<K,V>, Cloneable, Serializable 介面的請自行百度transient 關鍵字:阻止本欄位進行序列化(具體使用請自行百度)threshold = length(雜湊表長度) * loadFactormodCount 記錄的是 HashMap 內部結構發生變化的次數,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。

有了對 table 陣列的認識,那麼我們用一張圖來描述一下 HashMap 中的雜湊表結構(來自 "美團點評技術團隊" 侵刪):

瞭解了 HashMap 中的成員變數,再來看一下 HashMap 中定義的常量:

// 預設的初始容量,必須是2的冪。  static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   //最大容量(必須是 2 的冪且小於 2 的 30 次方,傳入容量過大將被這個值替換)  static final int MAXIMUM_CAPACITY = 1 << 30;    // 裝載因子static final float DEFAULT_LOAD_FACTOR = 0.75f;    // JDK1.8特有  // 當 hash 值相同的記錄超過 TREEIFY_THRESHOLD,會動態的使用一個專門的紅黑樹實現來代替連結串列結構,使得查詢時間複雜度從 O(n) 變為 O(logn)  static final int TREEIFY_THRESHOLD = 8;    // JDK1.8特有  // 也是閾值,同上一個相反,當桶(bucket)上的連結串列數小於 UNTREEIFY_THRESHOLD 時紅黑樹轉連結串列  static final int UNTREEIFY_THRESHOLD = 6;  // JDK1.8特有  // 樹的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然後為了避免(resizing 和 treeification thresholds) 設定成64  static final int MIN_TREEIFY_CAPACITY = 64;
2 HashMap中的Node元素

現在,我們關心的是 table 陣列中 Node 元素的實現,原始碼如下:

// 靜態內部類、操縱了 Map 介面中的 Entry<K,V> 介面static class Node<K,V> implements Map.Entry<K,V> {    // key 所產生的 hash 值 (不變)    final int hash;    // key (不變)    final K key;    // value    V value;    // 指向下一個 Node 節點、(鏈地址法解決衝突)    Node<K,V> next;    // 建構函式    Node(int hash, K key, V value, Node<K,V> next) {        this.hash = hash;        this.key = key;        this.value = value;        this.next = next;    }    // 這些方法都不可被重寫    public final K getKey()        { return key; }    public final V getValue()      { return value; }    public final String toString() { return key + "=" + value; }    // 計算 key 所產生的 hash 碼    public final int hashCode() {        return Objects.hashCode(key) ^ Objects.hashCode(value);    }    // 設定新值,返回舊值    public final V setValue(V newValue) {        V oldValue = value;        value = newValue;        return oldValue;    }    // 比較兩個物件是否相等    public final boolean equals(Object o) {        if (o == this)            return true;        // 是否都操作 Map.Entry 介面        if (o instanceof Map.Entry) {            // 屬於同一個類之後再對物件的屬性進行比較            Map.Entry<?,?> e = (Map.Entry<?,?>)o;            if (Objects.equals(key, e.getKey()) &&                Objects.equals(value, e.getValue()))                return true;        }        return false;    }}

在這裡我們需要注意:

Node 的實現是一個靜態內部類,有關內部類與靜態內部類的理解,請檢視我的知乎回答:為什麼Java內部類要設計成靜態和非靜態兩種?hash 值與 key 的不變性:即使在 HashMap 中對 key 及 hash 做了final 關鍵字的約束,但是我們還是需要注意,最好使用不變物件作為 key。

首先我們來了解一下 final 關鍵字在基本型別與引用型別的使用上有什麼不同?

當 final 修飾基本變數型別時,不能對基本型別變數重新賦值,因此基本型別變數不能被改變。當 final 修飾引用型別變數時,final 只保證這個引用型別變數所引用的地址不會改變,即一直引用同一個物件,但是這個物件(物件的非 final 成員變數的值可以改變)完全可以發生改變。

再來討論,我們在使用 HashMap 時,為什麼最好選用不可變物件作為 key。

來看一下選用可變物件作為 HashMap 的 key 有可能會造成什麼影響?

import java.util.HashMap;import java.util.Map; public class MutableDemo1 {    public static void main(String[] args) {        Map<MutableKey, String> map = new HashMap<>();                MutableKey key = new MutableKey(10, 20);         map.put(key, "Robin");                System.out.println(map.get(key));        key.setI(30);        System.out.println(map.get(key));    }}

輸出:

Robinnull

為什麼最好不要使用可變物件作為 HashMap 的 key,結論:

如果 key 物件是可變的,那麼 key 的雜湊值就可能改變。在 HashMap 中可變物件作為 key 會造成資料丟失。

怎麼解決?

在 HashMap 中,儘量使用 String、Integer 等不可變型別用作 key。重寫自定義類的 hashcode 方法,保證在成員變數改變的同時該物件的雜湊值不變即可。(具體實現參見:HashMap 的 key 可以是可變的物件嗎?)3 HashMap中的put方法3.1 Hash值的計算

我們對 HashMap 的基本組成結構已經有了完整的認識,接下來我們分析 HashMap 中最常用的方法之一:put()。

直接上原始碼:

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

在分析 putVal 的原始碼之前,我們先來看看 hash(key):

static final int hash(Object key) {    int h;    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}

key 的 hash 值就是這樣得到的,key.hashCode()是一個本地方法,具體實現在原始碼中並沒有給出,但這並不是重點,我們需要注意的是在計算出 hash 值後,它又與本身的高 16 位進行了異或。(hash 值本身是 32 位)

為什麼這樣做?這樣做的好處是什麼呢?

主要是從速度、功效、質量來考慮的,這麼做可以在陣列 table 的 length 比較小的時候,也能保證考慮到高低 Bit 都參與到 Hash 的計算中,同時不會有太大的開銷。在混合了原始 hashCode 值的高位和低位後,加大了低位的隨機性,而且混合後的低位摻雜了高位的部分特徵,這樣高位的資訊也被變相保留下來,這就使得 hash 方法返回的值,具有更高的隨機性,減少了衝突。

下面舉例說明,n 為 table 的長度(假設為 16)。

3.2 put方法的解析

在分析 put 方法的原始碼之前,我們先來看一張有關 put 方法執行過程的圖解:

根據圖片我們再對 put 方法的執行流程做一個總結,方便等下閱讀原始碼:

判斷鍵值對陣列 table 是否為空或為 null,否則執行 resize() 進行擴容;根據鍵值 key 計算 hash 值得到插入的陣列索引 i,如果 table[i] == null,直接新建節點新增,轉向 6,如果 table[i] 不為空,轉向 3;判斷 table[i] 的首個元素是否和 key 一樣,如果相同直接覆蓋 value,否則轉向 4,這裡的相同指的是 hashCode 以及 equals;判斷 table[i] 是否為 treeNode,即 table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向 5;遍歷 table[i],判斷連結串列長度是否大於 8,大於 8 的話把連結串列轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行連結串列的插入操作;遍歷過程中若發現 key 已經存在直接覆蓋 value 即可;插入成功後,判斷實際存在的鍵值對數量 size 是否超過了負載 threshold,如果超過,進行擴容。

putVal 方法原始碼:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;        // 雜湊表為null || 雜湊表的長度為 0(resize 也是一個經典的方法)    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;            // (n - 1) & hash 計算出 key 在雜湊表中應該儲存的位置(除留餘數法,使用 & 運算 比 % 運算更快)    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;                // 插入的 key 在 HashMap 中已經存在(之後進行 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)                         treeifyBin(tab, hash);                    break;                }                                // 插入的 key 在 HashMap 中已經存在                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }        }                // key 已經存在,直接覆蓋舊值        if (e != null) {            V oldValue = e.value;            if (!onlyIfAbsent || oldValue == null)                e.value = value;            afterNodeAccess(e);            return oldValue;        }    }        ++modCount;                 // 記錄 HashMap 內部結構發生變化的次數,用於快速失敗機制    if (++size > threshold)        resize();               // 擴容            afterNodeInsertion(evict);  // 作用不明確        return null;}

put 方法分析到這裡基本上就結束了,但是同樣有兩個值得思考的問題:

雜湊表索引定位:(n - 1) & hash;擴容機制:resize()。

關於紅黑樹與快速失敗機制,不在這篇部落格中進行講述。

3.3 索引定位

你不覺得以(n - 1) & hash這種方式定位元素在雜湊表中的位置很有趣嗎?

本質上,它還是“除留餘數法”,只不過由於位運算的緣故,會比取模運算要高效許多。

但是使用這種方法有一個前提,就是雜湊表 table 的長度 n 必須滿足 2 冪次方,因為 n-1 對應的二進位制就是前面全是 0,後面全是 1,相與後,只留下 hash 的後幾位,正好在長度為 n 的陣列下標範圍內。

舉個例子,假設 hash 值為 3,陣列長度 n 為 16,那麼我們使用取模運算得到:3 % 16 = 3,使用 & 運算:0011 & (16 - 1) 即 0011 & 1111 = 0011 得到的還是 3。

而在 HashMap 中,雜湊表 table 的預設初始值也為 16(原始碼如下):

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
3.4 擴容機制

我們不談紅黑樹,但必須探究包含在 put 方法中的 resize(擴容)機制。瞭解過 resize 方法之後,你會感嘆其設計之巧妙!

首先,對擴容機制做一個簡單的介紹:

擴容(resize)就是重新計算容量,向 HashMap 物件裡不停的新增元素,而 HashMap 物件內部的陣列無法裝載更多的元素時,物件就需要擴大陣列的長度,以便能裝入更多的元素。如果 HashMap 的實際大小 > 負載,則 HashMap 中的 table 的容量擴充為當前的一倍。容量翻倍後,重新計算每個 Node 的 index,將有限的元素對映到更大的陣列中,減少 hash 衝突的機率。

我將擴容機制分為了兩部分:1. 建立新的 table 陣列;2. 對元素進行 rehash。

建立新的 table 陣列,過程還是比較簡單的:

原 table 陣列的大小已經最大,無法擴容,則修改 threshold 的大小為 Integer.MAX_VALUE。產生的效果就是隨你碰撞,不再擴容;原 table 陣列正常擴容,更新 newCap(新陣列的大小) 與 newThr(新陣列的負載);原 table 陣列為 null || length 為 0,則擴容使用預設值;原 table 陣列的大小在擴容後超出範圍,將 threshold 的大小更改為 Integer.MAX_VALUE。

我們先擷取第一部分(建立新陣列)的原始碼進行研究:

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) {        // 超過最大值就不再擴充,隨你去碰撞(將 threshold 設定為 Integer.MAX_VALUE,則不會產生擴容)        if (oldCap >= MAXIMUM_CAPACITY) {            threshold = Integer.MAX_VALUE;            return oldTab;        }                // 擴容成功,更新 newCap 與 newThr 的大小(2 倍擴充套件)        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)            newThr = oldThr << 1;     }        // !!!對應的哪種情況?    else if (oldThr > 0)        newCap = oldThr;        // oldCap == 0 || oldTab == null    else {                       newCap = DEFAULT_INITIAL_CAPACITY;        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);    }        // 擴容失敗(擴容後 newCap >= MAXIMUM_CAPACITY)    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;        // rehash 的過程    ... ...        return newTab;}
3.4.1 JDK 1.7中的rehash

直接閱讀 JDK 1.8 中的 rehash 過程讓人有點頭大,為了便於理解,我們先來看看 JDK 1.7 中的 rehash,總體來說,兩個版本差別不大:

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];                   // 頭插法                newTable[i] = e;                        // 將元素放在陣列上                e = next;                               // 訪問下一個 Entry 鏈上的元素            } while (e != null);        }    }}

為了方便理解,下面舉個例子說明下擴容過程:

注:JDK 1.7 中的 put 方法使用的是頭插法進行新節點的插入,在 JDK 1.8 中,則使用的是尾插法(見上述原始碼)。對 JDK 1.7 put 方法感興趣的同學可自行查閱有關資料。

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

3.4.2 JDK 1.8中的rehash

JDK 1.8 中的 rehash 過程與 JDK 1.7 大同小異,相比 JDK 1.7,它主要對重新定位元素在雜湊表中的位置做了最佳化:

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

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

因此,我們在擴充 HashMap 的時候,不需要像 JDK1.7 那樣重新計算 hash,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成“原索引 + oldCap”。

瞭解了 JDK 1.8 相比 JDK 1.7 所做的最佳化之後,我們再看一下 JDK 1.8 中的 rehash 過程:

final Node<K,V>[] resize() {    ... ...        // rehash 的過程    if (oldTab != null) {        for (int j = 0; j < oldCap; ++j) {            Node<K,V> e;            if ((e = oldTab[j]) != null) {                // 釋放舊 Node 陣列的物件引用(for迴圈後,舊的 Node 陣列不再引用任何物件)                oldTab[j] = null;                                // oldTab[j] 只有一個元素,直接進行 rehash                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 {                    // 原索引(頭指標與尾指標)                    Node<K,V> loHead = null, loTail = null;                    // 原索引 + oldCap(頭指標與尾指標)                    Node<K,V> hiHead = null, hiTail = null;                    Node<K,V> next;                                        // 對元素進行 rehash 的過程                    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);                                        // 將建立的連結串列放到新 table 數組合適的位置上                    if (loTail != null) {                        loTail.next = null;                        newTab[j] = loHead;                    }                    if (hiTail != null) {                        hiTail.next = null;                        newTab[j + oldCap] = hiHead;                    }                }            }        }    }    return newTab;}
3.5 HashMap的執行緒安全性

在多執行緒使用場景中,應該儘量避免使用執行緒不安全的 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 方法的首行。注意此時兩個執行緒已經成功新增資料。放開 thread1 的斷點至 transfer 方法的Entry next = e.next 這一行;然後放開執行緒 2 的斷點,讓執行緒 2 進行 resize。結果如下圖。

newTable 是區域性變數,所以兩個執行緒都有自己擴容後開闢的新的 table 陣列。(對應圖中橙色與紫色方塊)

注意,由於 Thread1 執行到了Entry next = e.next這一行,因此 e 指向了 key(3),而 next 指向了 key(7),其線上程二 rehash 後,指向了執行緒二重組後的連結串列(rehash 之後,會將 newtable 賦值給 HashMap 的成員變數 table)。

接著下一部分:

執行緒一被排程回來執行,先是執行 newTalbe[i] = e(對應圖中 thread1 的索引 3 處指向了 thread2 中 索引 3 處的 key = 3 的節點(thread2 中的 table 此時已經是成員變量了,因此共享)), 然後是 e = next,導致了 e 指向了 key(7),而下一次迴圈的 next = e.next 導致了 next 指向了 key(3)。

當 next 指向 key(3) 的時候,e 為 key(7),又經過一次迴圈後,結果如下圖:

虛線也表示有引用指向 key(7),只不過是想將 thread1 所擁有的 table 與 成員變數 table 區分開。

此時再更新 e 與 next 的值,e 為 key(3),next 為 null,因此下一次迴圈就是最後一次迴圈。經過下一次迴圈之後,由於 e.next = newTable[i] 導致 key(3).next 指向了 key(7),而此時的 key(7).next 已經指向了 key(3),環形連結串列就此形成。結果如下圖:

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

4 總結明白靜態內部類 Node 的相關實現,清楚 HashMap 的底層實現是有關 Node 的 table 陣列(雜湊表)。注意使用 HashMap 時最好使用不變的物件作為 key。注意 HashMap 計算 key 的 hash 值時,使用了低位與高位異或的方式,返回最終的 hashcode。瞭解 HashMap 中的定位方式:(n - 1) & hash。在 HashMap 中使用鏈地址法解決衝突,並且當連結串列的節點個數大於 8 的時候,會轉換為紅黑樹。(JDK 1.8 新特性)JDK 1.8 中使用尾插法進行 put 與 resize,JDK 1.7 中使用頭插法進行 put 與 resize。JDK 1.8 中的 rehash 過程不用重新計算元素的雜湊值,因為元素的位置只有兩種情況:原位置 與 原位置 + 原本雜湊表的長度。清楚多執行緒環境下使用 HashMap 可能會造成的一種錯誤---形成環形連結串列

作者:_dhengyi連結:https://juejin.cn/post/6934332114280120327

3
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 眾包測試中測試報告分類的領域適應