HashMap原理詳解,包括底層原理

loopyhz發表於2024-11-21

HashMap

是什麼

HashMap是一個用於儲存Key-Value鍵值對的集合,每一個鍵值對也叫做Entry。這些個鍵值對(Entry)分散儲存在一個陣列當中,這個陣列就是HashMap的主幹。HashMap陣列每一個元素的初始值都是Null。

HashMap可以以平均O(1)的時間複雜度去獲取集合中某個元素是否存在。

1.7

陣列 + 連結串列 實現的 每個資料單元是一個Entry

初始化

new 的時候預設建立一個長度為預設值16的Entry[]陣列,如果事先知道大概的資料量大小,可以透過建構函式傳入,以減少動態擴容的次數,這樣可以大大提高效能

插入元素 計算位置

為了提高效能,HashMap在根據物件計算hashcode()後,還進行了擾動計算來減少雜湊衝突

雜湊函式一共有九次擾動計算 四次位運算 五次異或運算 儘量做到任何一位的變化都能對最終得到的結果產生影響 降低雜湊衝突機率

// 獲取物件的 hashCode 值
h = k.hashCode();

// 擾動運算
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

得到擾動函式的返回值後 再計算索引

int index = hash & (capacity - 1);// hash是擾動函式的結果 capacity 是雜湊表的容量  

發生衝突
當不同的key計算出的hash值相同時(發生衝突),就用連結串列的形式將Node結點(衝突的key及key對應的value)插在連結串列的頭部(頭插法)

擴容機制

預設負載因子是0.75。當 HashMap 中元素個數超過 0.75*capacity(capacity 表示雜湊表的容量)的時候,就會啟動擴容,每次擴容都會擴容為原來的兩倍大小。

擴容後 HashMap會將舊陣列中的資料重新分配到新陣列中,不需要重新計算雜湊值,但是需要根據新的容量重新計算索引位置。

計算完畢索引位置後,會進行資料轉移,從原連結串列尾部開始轉移,轉移到新連結串列的對應位置的時候採用頭插法,正因為這樣,元素的順序會被顛倒

轉移資料完畢後,插入剛才要插入的新元素。

1.8

JDK1.8的底層陣列+連結串列+紅黑樹 組成。每個資料單元都是一個Node結構,裡面有四個欄位分別是 key,value,hash和next 。其中hash就是將key的hashCode()的高16位異或低16位的值,next欄位就是發生hash衝突後,當前桶位中的Node與衝突Node連成一個連結串列用的欄位。

初始化

在首次呼叫put()方法的時候,底層建立長為16的陣列

插入

計算雜湊值

key不為空的話,就呼叫hashcode()方法計算,得到的值,再進行高16位和低16位的異或運算

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 兩次擾動  一次位運算 一次異或
}

接著計算索引

index = (hash & (capacity - 1));

發生衝突時的解決方案

當發生衝突時,新節點會掛在連結串列的尾部,這樣就不會向頭插法一樣可能出現死迴圈問題。

ps: 死迴圈問題

在jdk1.7的擴容過程中,使用頭插法進行重建,如果某些情況下,出現多個執行緒同時對連結串列進行操作,可能會導致連結串列斷裂並出現環形結構

當連結串列長度過長的時候,遍歷的效率就會下降到O(n),為了解決這個問題, 當連結串列的長度超過8,並且陣列的容量大於64的時候,HashMap會將連結串列轉化為紅黑樹(紅黑樹是一種自平衡的二叉搜尋樹,增刪改查效率是O(logn)).

擴容機制

當容量超過0.75的時候,觸發擴容機制。 擴容為原來的兩倍。資料遷移相對於1.7有了最佳化。

新位置計算

無需重新計算雜湊值,只需要經過簡單的位運算就可以確定新的位置。 對於每個元素,將他的雜湊值和舊容量進行與運算,如果結果為0,保持舊索引不變,新索引就是舊索引,結果為1,新索引為舊索引加上舊容量值。

資料遷移 使用的是尾插法

如果舊陣列的這個位置是null , 就直接給新陣列中他要放到的位置也賦值為null,當然預設值也是null

如果舊陣列的這個位置只有一個Node,就直接放到新陣列他要放到的位置就行了

如果舊陣列的這個位置是一個連結串列 ,就初始化一個低位連結串列頭尾指標,一個高位連結串列的頭尾指標,遍歷舊連結串列,雜湊值和舊容量進行與運算等於0的節點,放到低位連結串列中,不為0的放到高位連結串列中。低位連結串列直接放到新索引=舊索引的地方 高位連結串列放到新索引=舊索引+舊容量的位置

如果舊陣列的這個位置是一個樹,就呼叫TreeNode.split() 方法,將紅黑樹拆成兩個樹或者兩個連結串列。分別對應低位和高位。低位的放到新索引=舊索引的地方 高位放到新索引=舊索引+舊容量的位置。如果拆分後樹節點的數量小於等於6,就把紅黑樹退化為連結串列

get(key)操作

根據傳入的key,計算雜湊值 計算索引位置。

static final int hash(Object key) {
    int h;
    // key.hashCode() 計算原始雜湊值
    // 與自身右移16位的值進行異或,混合高低位雜湊碼,減少衝突
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
int index = (n - 1) & hash;

對應位置為空 返回null
對應位置不為空
只有頭結點 用equals()比較頭結點的鍵和目標鍵 一樣則返回頭結點的值 不一樣返回null
頭結點有next 判斷是不是樹形結構 e instanceof TreeNode
是樹 進入紅黑樹的查詢流程 找到返回值 找不到null
是連結串列 遍歷連結串列 逐個節點進行比較 找到返回值 找不到返回null

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

final Node<K,V> getNode(int hash, Object key) {
    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 && // 檢查雜湊值是否相等
            ((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);
            do {
                // 連結串列查詢
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

put()操作

檢查雜湊表是不是為空

空的話呼叫resize方法,以預設的16為容量初始化 計算雜湊值 計算索引 插入即可
不是空 計算出索引位置 獲取索引位置上的頭結點
頭結點為空 直接插入即可
頭結點不為空
是樹 呼叫putTreeVal()方法在紅黑樹中進行操作
鍵存在 更新對應值並返回舊值
鍵不存在 插入新節點 維護紅黑樹平衡
是連結串列 遍歷連結串列
鍵存在 更新對應值並返回舊值
鍵不存在 尾插法 記錄連結串列長度 判斷 是否需要樹化 需要則進行樹化
插入操作完成後 執行++size 更新元素數量 判斷是否超過閾值 是否進行擴容

public V put(K key, V value) {
    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> e;
    int n, i;
    if ((tab = table) == null || (n = tab.length) == 0) {
        // 初始化 table
        n = (tab = resize()).length;
    }
    // 計算索引位置
    if ((e = tab[i = (n - 1) & hash]) == null) {
        // 插入新節點
        tab[i] = newNode(hash, key, value, null);
    } else {
        Node<K,V> existing;
        K k;
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
            // 頭節點的鍵相同,覆蓋值
            existing = e;
        } else if (e instanceof TreeNode) {
            // 紅黑樹節點,呼叫樹的插入方法
            existing = ((TreeNode<K,V>)e).putTreeVal(this, tab, hash, key, value);
        } else {
            // 連結串列節點,遍歷連結串列
            for (int binCount = 0; ; ++binCount) {
                if ((existing = e.next) == null) {
                    // 插入到連結串列尾部
                    e.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) {
                        // 連結串列長度超過閾值,樹化
                        treeifyBin(tab, hash);
                    }
                    break;
                }
                if (existing.hash == hash && ((k = existing.key) == key || (key != null && key.equals(k)))) {
                    // 找到相同的鍵
                    break;
                }
                e = existing;
            }
        }
        if (existing != null) {
            // 覆蓋值
            V oldValue = existing.value;
            if (!onlyIfAbsent || oldValue == null) {
                existing.value = value;
            }
            afterNodeAccess(existing);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) {
        resize();
    }
    afterNodeInsertion(evict);
    return null;
}

執行緒安全問題

為什麼執行緒不安全?
  1. 在多執行緒環境下,JDK1.7的HashMap進行擴容時容易發生死鏈現象,主要因為往連結串列裡面新新增元素的時候使用頭插法。
  2. 在多執行緒環境下,JDK1.8的HashMap擴容後進行資料遷移使用的時候尾插法,而且會將連結串列拆分成一個低位連結串列和一個高位連結串列,然後分別放在對應的位置上,這樣就能防止死鏈的產生。但是進行擴容時可能發生丟失資料現象。(多執行緒下容易發生資料覆蓋和size不正確)
執行緒不安全怎麼辦?
  1. 替換成Hashtable,Hashtable透過對整個表上鎖實現執行緒安全,但是效率比較低。
  2. 使用Collections.synchronizedMap(new HashMap<String, Integer>());底層其實使用裝飾器模式將HashMap的所有方法重寫,然後用synchronized()來修飾每個重寫後的方法,從而保證執行緒安全。
  3. 使用JUC包下的ConcurrentHashMap,它使用分段鎖來保證執行緒安全。

為什麼HashMap的長度總是2的n次方?

當 length 為 2 的 n 次方時,h & (length-1) 就相當於對 length 取模,而且速度比直接取模快得多,這是 HashMap 在速度上的一個最佳化。而且每次擴容時都是翻倍。

擴容後陣列的長度變成原來的2倍,還是2的冪次方。那麼資料進行遷移時,要麼在原來位置,要麼在原來位置+擴容長度。不需要進行再次雜湊計算,提高效率。

資料會更加均勻

為什麼HashMap中String、Integer這樣的包裝類適合做為Key?

  1. 都是final型別,即不可變性,保證key的不可更改性,不會存在獲取hash值不同的情況。
  2. 內部已重寫了equals()、hashCode()等方法,遵守了HashMap內部的規範,不容易出現Hash值計算錯誤的情況。

相關文章