JDk1.7 HashMap原始碼解析——執行緒安全問題

外酥裡嫩唐僧肉發表於2020-12-31

Jdk1.7的HashMap, 在多執行緒環境下,擴容的時候可能會形成環狀連結串列導致死迴圈和資料丟失問題。

HashMap在擴容的流程

  1. 擴容相關常量
  • DEFAULT_LOAD_FACTOR: 預設負載因子,這個引數是判斷擴容時的重要引數,當Map中的元素的數量達到最大容量乘上負載因子時,就會進行擴容。如果在構造方法中沒有指定,那麼預設就是0.75。這個0.75是個非常合理的值,如果負載因子等於1,那麼只有元素數量達到最大容量的時候才會進行擴容,導致每一個桶的連結串列長度都過長,執行效率變低。如果負載因子等於0.5,那麼Map每儲存一半的元素就擴容,浪費記憶體空間。

  • threshold: 容量達到閾值時(threshold = 初始容量 * 載入因子),在 put 資料時,就會擴容,相當於實際使用的容量。

  • table:儲存Entry也就是我們儲存的key,value的物件陣列,擴容時會 new 一個新的陣列,長度為老陣列的一倍,然後逐一將這個table的元素移至新的陣列,然後將新的陣列覆蓋原陣列來實現擴容。

/**
`* 預設負載因子
`*/`
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
`* Entry陣列
`*/`
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
 * 容量, 預設達到 size * 0.75 就會擴容
 */
transient int size;
  1. 擴容的條件

當我們新增元素(put 方法)的時候,需要去判斷是否能夠存下這個元素,如果存的下就存,存不下就擴容再存。

  1. 擴容流程

以 put 方法 為例

public V put(K key, V value) {
    // 判斷是否是空表
    if (table == EMPTY_TABLE) {
        // 初始化, 強制把 初始化容量 轉換為 2 的整次冪
        inflateTable(threshold);
    }
    // 判斷是否是空值
    if (key == null)
        return putForNullKey(value);
    // 獲取 hash 值
    int hash = hash(key);
    // 獲取 索引
    int i = indexFor(hash, table.length);
    // 遍歷 指定索引下的連結串列
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 判斷put 的key是否已經存在
        // 如果存在,則替換
        // 並返回 舊值
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    // 如果 put 的key不存在,則新增進去
    // 此方法會判斷是否要擴容
    addEntry(hash, key, value, i);
    return null;
}

addEntry 方法, 會判斷陣列當前容量是否已經超過的閾值,例如,假設當前的陣列容量是16,載入因子為0.75,即超過了12,並且剛好要插入的索引處有元素,這時候就需要進行擴容操作,可以看到resize擴容大小是原陣列的兩倍,仍然符合陣列的長度是2的指數次冪。

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 判斷是否需要擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 擴容
        resize(2 * table.length);
        // 重新計算hash值
        hash = (null != key) ? hash(key) : 0;
        // 計算所要插入的桶的索引值
        bucketIndex = indexFor(hash, table.length);
    }
    // 新增Entry
    createEntry(hash, key, value, bucketIndex);
}

resize 方法, 首先,如果這個HashMap的容量已經非常大了,新的長度會大於我們預設的最大容量,這時直接return;來終止這個方法。如果沒有,後面會進行陣列的轉移操作,即transfer方法。

initHashSeedAsNeeded方法, 主要是判斷一下是否需要初始化雜湊,儘量避免HashMap的值太過集中不夠雜湊。

這裡 預設的最大容量是 MAXIMUM_CAPACITY = 1 << 30, 就是 MAXIMUM_CAPACITY = 2 ^ 30, 如果達到最大 預設容量, 那麼,HashMap 可以使用的 的容量就是 Integer.MAX_VALUE

/**
 * 擴容
 */
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 達到最大值,無法擴容
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 設定為 HashMap 的最大容量
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    // 將資料轉移到新的Entry[]陣列中
    // 新陣列是舊陣列的兩倍大小
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化雜湊種子
    // 覆蓋原陣列
    table = newTable;
    // 重新計算 可以使用的容量
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 方法, 把原來陣列的值逐一複製到這個新的陣列, 首先是遍歷table陣列,如果遍歷到Entry不為空,我們進入while迴圈,進行連結串列操作,每次操作結束都將進入迴圈的e用e.next覆蓋,直至連結串列到達尾部。

/**
 * 
 * @param newTable 新陣列的引用
 * @param rehash true代表需要重新獲取 hash 值
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    // 遍歷 老陣列
    for (Entry<K,V> e : table) {
        // 如果 有元素,就遍歷連結串列
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                // 重雜湊
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新定位當前節點在新陣列中的位置
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

多執行緒下,連結串列的插入

這裡 模擬了兩個執行緒 A 和 B,在併發情況下,插入資料的擴容圖示:

注意: 圖中所有的箭頭都是指標(或者引用),執行緒 A 的新陣列 是 陣列A;執行緒 B 的新陣列 是 陣列B。

假設,執行緒 A 執行到

Entry<K,V> next = e.next;

的程式碼時阻塞了, 因為執行緒 A 被阻塞了,其後面的程式碼就沒法繼續執行了,而此時執行緒 B 也進入方法進行擴容,擴容後的結果就是單執行緒時擴容後的結果,此時相比於擴容前的HashMap,原陣列的連結串列元素的位置已經調換。

執行緒 B 的圖示 是就是簡單的單執行緒擴容,就只畫出執行後的結果圖;

執行緒 A 的圖示 依賴於 執行緒 B 的結果,每個圖示,代表一次 while 迴圈後的結果。

圖示:

在這裡插入圖片描述

可以看到,最後出現了環,並且 資料 8 丟失了。

相關文章