HashMap 底層實現、載入因子、容量值及死迴圈

Planeswalker23發表於2020-05-22

HashMap 簡介

HashMap是一個基於雜湊表實現的無序的key-value容器,它鍵和值允許設定為 null,同時它是執行緒不安全的。

HashMap 底層實現

  • jdk 1.7HashMap是以陣列+連結串列的實現的
  • jdk1.8開始引入紅黑樹,HashMap底層變成了陣列+連結串列+紅黑樹實現

紅黑樹簡介

紅黑樹是一種特殊的平衡二叉樹,它有如下的特徵:

  • 節點是紅色或黑色
  • 根節點是黑色的
  • 所有葉子都是黑色。(葉子是NULL節點)
  • 每個紅色節點的兩個子節點都是黑色的(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
  • 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

所以紅黑樹的時間複雜度為: O(lgn)

9358d109b3de9c828cdb8e7c6481800a18d84382.jpeg

jdk1.8:陣列+連結串列+紅黑樹

HashMap的底層首先是一個陣列,元素存放的陣列索引值就是由該元素的雜湊值(key-valuekey的雜湊值)確定的,這就可能產生一種特殊情況——不同的key雜湊值相同。

在這樣的情況下,於是引入連結串列,如果key的雜湊值相同,在陣列的該索引中存放一個連結串列,這個連結串列就包含了所有key的雜湊值相同的value值,這就解決了雜湊衝突的問題。

但是如果發生大量雜湊值相同的特殊情況,導致連結串列很長,就會嚴重影響HashMap的效能,因為連結串列的查詢效率需要遍歷所有節點。於是在jdk1.8引入了紅黑樹,當連結串列的長度大於8,且HashMap的容量大於64的時候,就會將連結串列轉化為紅黑樹。

// jdk1.8
// HashMap#putVal

// binCount 是該連結串列的長度計數器,當連結串列長度大於等於8時,執行樹化方法
// TREEIFY_THRESHOLD = 8
if (binCount >= TREEIFY_THRESHOLD - 1)
    treeifyBin(tab, hash);

// HashMap#treeifyBin    
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // MIN_TREEIFY_CAPACITY=64
    // 若 HashMap 的大小小於64,僅擴容,不樹化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

載入因子為什麼是0.75

所謂的載入因子,也叫擴容因子或者負載因子,它是用來進行擴容判斷的。

假設載入因子是0.5,HashMap初始化容量是16,當HashMap中有16 * 0.5=8個元素時,HashMap就會進行擴容操作。

HashMap中載入因子為0.75,是考慮到了效能和容量的平衡。

由載入因子的定義,可以知道它的取值範圍是(0, 1]。

  • 如果載入因子過小,那麼擴容門檻低,擴容頻繁,這雖然能使元素儲存得更稀疏,有效避免了雜湊衝突發生,同時操作效能較高,但是會佔用更多的空間。
  • 如果載入因子過大,那麼擴容門檻高,擴容不頻繁,雖然佔用的空間降低了,但是這會導致元素儲存密集,發生雜湊衝突的概率大大提高,從而導致儲存元素的資料結構更加複雜(用於解決雜湊衝突),最終導致操作效能降低。
  • 還有一個因素是為了提升擴容效率。因為HashMap的容量(size屬性,建構函式中的initialCapacity變數)有一個要求:它一定是2的冪。所以載入因子選擇了0.75就可以保證它與容量的乘積為整數。
// 建構函式
public HashMap(int initialCapacity, float loadFactor) {
    // ……
    this.loadFactor = loadFactor;// 載入因子
    this.threshold = tableSizeFor(initialCapacity);
}

/**
 * Returns a power of two size for the given target capacity.返回2的冪
 * MAXIMUM_CAPACITY = 1 << 30
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

HashMap 的容量為什麼是2的 n 次冪

HashMap的預設初始容量是16,而每次擴容是擴容為原來的2倍。這裡的16和2倍就保證了HashMap的容量是2的n次冪,那麼這樣設計的原因是什麼呢?

原因一:與運算高效

與運算&,基於二進位制數值,同時為1結果為1,否則就是0。如1&1=1,1&0=0,0&0=0。使用與運算的原因就是對於計算機來說,與運算十分高效。

原因二:有利於元素充分雜湊,減少 Hash 碰撞

在給HashMap新增元素的putVal函式中,有這樣一段程式碼:

// n為容量,hash為該元素的hash值
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

它會在新增元素時,通過i = (n - 1) & hash計算該元素在HashMap中的位置。

當 HashMap 的容量為 2 的 n 次冪時,他的二進位制值是100000……(n個0),所以n-1的值就是011111……(n個1),這樣的話(n - 1) & hash的值才能夠充分雜湊。

舉個例子,假設容量為16,現在有雜湊值為1111,1110,1011,1001四種將被新增,它們與n-1(15的二進位制=01111)的雜湊值分別為1111、1110、1110、1011,都不相同。

而假設容量不為2的n次冪,假設為10,那麼它與上述四個雜湊值進行與運算的結果分別是:0101、0100、0001、0001。

可以看到後兩個值發生了碰撞,從中可以看出,非2的n次冪會加大雜湊碰撞的概率。所以 HashMap 的容量設定為2的n次冪有利於元素的充分雜湊。

參考:HashMap初始容量為什麼是2的n次冪及擴容為什麼是2倍的形式

HashMap 是如何導致死迴圈的

HashMap會導致死迴圈是在jdk1.7中,由於擴容時的操作是使用頭插法,在多執行緒的環境下可能產生迴圈連結串列,由此導致了死迴圈。在jdk1.8中改為使用尾插法,避免了該死迴圈的情況。

在網上找到了比較詳細的解釋分析部落格與視訊:

老生常談,HashMap的死迴圈【基於JDK1.7】

jdk1.7及1.8HashMap,ConcurrentHashMap實現原理,自己使用,侵刪

相關文章