1. 程式人生 > >清晰解題: 談談你對 HashMap, ConcurrentHashMap 的理解

清晰解題: 談談你對 HashMap, ConcurrentHashMap 的理解

參考文章:

JAVA 面試的暖場題

Java 開發中用的比較多的資料結構是有哪些?

  • 如果答案中包含了 HashMap, 那很自然地引到下一個問題

談談你對 HashMap 的理解, 底層的基本實現。

  • HashMap 是計算機資料結構雜湊表 ( hash table ) 的一種實現。
    • Tips: java 中的另一個雜湊表實現類的名稱就是 Hashtable, 由於歷史原因, 命名還不是駝峰的, 後續閱讀中需要注意區分我們所談論的是資料結構的概念 hash table, 還是 JAVA 的實現類 Hashtable

HashMap 是否執行緒安全

  • 不是

Hashtable 是否執行緒安全

  • 是, 但是是 JAVA v1 的產物,同步效能開銷較大, 已經不被推薦使用

用什麼方案解決執行緒安全需求

  • ConcurrentHashMap

ConcurrentHashMap 如何高效實現執行緒安全

  • 後文展開敘述

先修知識

首先, HashMap 所對應的資料結構的學術名稱是雜湊表(Hash Table) , 其基本要素包含

  • 雜湊函式(Hash Function )
  • 桶陣列( Array Of Bucket), 其實就是一個數組, 桶是已經約定俗稱的名稱, 因為裡面儲存了 Hash 後的索引(Index)
  • 雜湊表就是一個將資料的鍵值(Key), 進行雜湊計算(Hash Function), 儲存到對應的桶陣列中的資料結構。 如下圖就展示了雜湊表如何儲存一個電話號碼本, 通過這個資料結構, 我們可以快速通過姓名檢索到其對應的電話號碼。 在這裡插入圖片描述

細心的同學必須要罵了, HashMap 的連結串列哪去了? HashMap 底層不應該是下圖這樣的嗎? 在這裡插入圖片描述

HashMap 確實大致類似(細節差異在後文中可以看到)上圖這樣, 在桶陣列的後面追加了連結串列, 但是這其實不是資料結構 hash table 的固定實現, 這種做法嚴格意義上來說只是 hash table 解決雜湊衝突(hash collision) 的一種方法而已。 具體而言, 衝突解決主要有如下 2 大類策略:

  • 獨立成鏈法

    • 獨立成鏈法中, 相同的索引上有往往有某種資料結構串聯起來的多個數據項(Entry)
    • 桶的後面可以跟的資料結構有
      • 連結串列(最常見)
      • 自平衡二叉樹(Java 8 中的 HashMap 實現就採用了這種方案
      • 其他資料結構
  • 開放定址法

    • 在這種策略下, 所有的資料項都儲存在桶陣列本身。
    • 在這種策略下, 當一個衝突發生時, 由於原本應該插入的桶位置已經被佔用, 新進的元素需要以已被佔用位置為起始點, 用某種方法,再次找到一個空置的位置插入。
      • 線性探測法 : 從被佔用的桶位置開始, 以固定間隔(通常是 1 ) 向後尋找空餘位置
      • 平方探測法 : 從被佔用的桶位置開始, 以固定間隔(k2k^2 ) 向後尋找空餘位置
      • 雙重雜湊法:第 i 次衝突發生以後,通過另一個hash 函式 h2h_2 計算出的間隔(ih2(k))mod  Ti \cdot h_2(k) ) \mod |T|)尋找空餘位置
    • 這種策略有一個明顯的缺點是: 儲存的資料項數量無法超過桶陣列的長度。 所以在應用中往往會伴隨著強制擴容(resizing), 帶來相應的開銷

HashMap 的實現細節(Java 8版本)

首先 HashMap 的桶結構的宣告程式碼如下:

    transient Node<K,V>[] table;

示意圖如下 在這裡插入圖片描述

Node 元素的宣告如下

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        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;
        }
		// ... 省略
}

示意圖 在這裡插入圖片描述

HashMap 的建構函式

public HashMap(int initialCapacity, float loadFactor){  
    // ... 省略
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

可以看到 HashMap 建構函式中沒有立刻初始化 Node<K,V>[] table , 採用了延遲載入的策略。

這裡值得注意的是, 初始容量(initialCapacity) 和 負載係數(loadFactor)是影響 HashMap 效能的兩個引數。

  • 初始容量(initialCapacity)
    • 桶陣列建立時的大小
  • 裝填係數(loadFactor)
    • 一種衡量何時需要重新調整 HashMap 大小, 並進行再雜湊的引數, 後面展開描述

HashMap 的 put 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);// 注意這裡已經對 key 呼叫了 hash 方法, 計算了對應的 hashcode
}

可以看到有兩個引數被預設設定成了 false 和 true, 分別是 onlyIfAbsent, evict .

  • onlyIfAbsent 為 false 表示, 如果放置的元素已經存在, 就予以替換
  • evict 引數在 HashMap 類中無意義, 因為搜尋一下可以發現, 只有一個方法 void afterNodeInsertion(boolean evict) { } 使用了這個引數, 而這個方法體是空的。 LinkedHashMap 繼承了 HashMap 實現了這個方法體, 這裡不做展開敘述。

下面為 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;
        if ((tab = table) == null || (n = tab.length) == 0)
        	// table 為空時, 通過 resize() 方法進行初始化
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)// 注意到此處指標 p 已經被指向了桶中的一個元素
        	// 此處通過(n - 1) & hash 計算出該元素在桶陣列中的下標, 如果此位置為空,則可以直接放置該元素
        	// 為什麼通過 (n - 1) & hash 計算下標在文章後面詳細解釋
            tab[i] = newNode(hash, key, value, null);
        else {
        	// 下面對應桶的位置已經被佔用的情況, 屬於 hash 取模後索引·衝突解決的部分
            Node<K,V> e; K k;// 初始化 element 指標 e
            if (p.hash == hash && // 桶中已經放置的元素hash值是否和當前待放置的元素hash值相等
                ((k = p.key) == key || (key != null && key.equals(k))))// 且桶中已經放置的元素 key 值是否和當前待放置的元素 key 值相同
                e = p; // e 指向待放置的元素
            else if (p instanceof TreeNode)
            // 如果桶中已經放置的元素是一個樹節點,說明這個桶的位置上已經發生多次衝突, 屬於這個位置的多個元素以自平衡二叉樹的結構, 連線在這個桶的後面了所以新的待放置的元素需要插入到這顆樹中,故呼叫 putTreeVal
            // 此處傳入當前 hashMap 的引用 this 的原因是, putTreeVal() 是一個定義在靜態內部類 TreeNode 的方法, 該方法內部需要呼叫一個定義在 HashMap 類的非靜態方法 newTreeNode() , 而靜態內部類是不能直接訪問外部類的非靜態成員的, 所以需要傳入引用
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)    
            else {
            // 桶的位置上是一個連結串列頭
                for (int binCount = 0; ; ++binCount) {// binCount 用於計數連結串列中的元素個數
                    if ((e = p.next) == null) {// p.next 為空說明到達連結串列尾
                        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))))
                        // 如果在遍歷的過程中發現連結串列中已經存在該 key 值相同的元素,跳出迴圈 
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
            // 這個地方針對桶中或桶後連結串列中發現key值相同元素的情形
            // 根據onlyIfAbsent 引數決定是否對已有元素的值進行替換
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);// 用於LinkedHashMap 的方法, 對於HashMap無意義
                return oldValue;
            }
        }
        ++modCount; //HashMap的資料被修改的次數,這個變數用於迭代過程中的Fail-Fast機制,其存在的意義在於保證發生了執行緒安全問題時,能及時的發現(操作前備份的count和當前modCount不相等)並丟擲異常終止操作。
        if (++size > threshold)// hashMap 節點數目大於閾值, 進行擴容
            resize();
        afterNodeInsertion(evict);// 用於LinkedHashMap 的方法, 對於HashMap無意義
        return null;
    }

結合註釋通讀程式碼後, 我們先回答註釋中沒有解決的問題:

  • 桶的索引計算過程為什麼是 (n - 1) & hash?
    • 答: 這就是一個運算技巧, 當 length=2nlength = 2^n 時, X%length=X&amp;(length1)X \% length = X \&amp; (length - 1)
    • HashMap 的桶陣列大小永遠都是 2^n, 擴容也是翻倍當前的大小

這個問題進一步拓展:

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

注意, 這裡獲取 hash 值的計算呼叫的是HashMap 中的一個方法 hash(key) , 並沒有直接呼叫 key 的 hashCode()方法來直接產生hash值。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 這裡的運算邏輯是, 將 key 的 hashCode 方法返回值 與 其本身右移16位的值作與操作。
  • 這樣做的效果是, hashCode 的高位資料被右移到了低位, 與原有的低位資料做了異或運算, 這樣是為了解決有些資料的 key 值計算後的 hash 差異主要在高位, 如果將這種資料取餘後, 很容易會發生 hash 碰撞。(例如 100000001 和900000001 對 16 取餘結果都是 1 ) , 進行這種運算後, 高位的差異就會在低位得到體現, 減小發生碰撞的概率。

引申問題: 考慮到 HashMap 最常見的 key 型別是 StringString 類的 hashCode() 是怎樣實現的呢

下面是 String 類 hashCode() 的原始碼

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

上面的計算邏輯是:String[0]31n1+String[1]31n2++String[n1]String [0]*31^{n-1} + String[1]*31^{n-2} + \cdots +String[n-1]

  • 這就是簡單的一個多項式類乘公式。 乘數被選為 31 的原因有一些內在的原因
  • Joshua Bloch 在 《Effective Java》 中解釋:
    • “選 31 作為乘數是因為它是一個素數, 如果是一個偶數的話, 當乘數溢位以後, 這個數的部分資訊就被丟失了, 因為乘 2 就等同於二進位制的左移操作。 但為什麼不選用奇數而是偶數做乘數的原因就沒那麼清晰了, 但是這是一個傳統。 31 有一個很好的性質, 就是乘以 31 可以通過二進位制位移以及一次減法操作快速實現 , 因為: 31i==(i&lt;&lt;5)31 * i == (i &lt;&lt; 5), 現代的虛擬機器實現可以自動對這個運算進行優化”
    • 這種計算方法會導致字首相同的字串很容易得到相同的 hashCode, 例如字串a 與字串 b 有相同的字首, 要得到相同的 hashCode, 只需要滿足下列條件:
      • 31(b[n2]a[n2])==(a[n1]b[n1])31*(b[n-2] - a[n-2]) == (a[n-1] - b[n-1])
 String a = "Aa";
 String b = "BB";
 System.out.println(a.hashCode());
 System.out.println(b.hashCode());
 System.out.println(31 * ('C' - 'D') == ('B' - 'a'));
 System.out.println(31 * ('B' - 'A') == ('a' - 'B'));
 System.out.println("common_prefixDB".hashCode());
 System.out.println("common_prefixCa".hashCode());
  • 瞭解 String 預設的 hashCode 這個特點以後, 就會發現, HashMap 以 String 作為Key , 其實出現 hash 碰撞還挺容易的,這裡就可以看到 Java 8 中為 HashMap 新增樹化機制的深意了