JDK1.7源碼分析【集合】HashMap的死循環

前言

JDK1.7&1.8源碼對比分析【集合】HashMap中我們遺留了一個問題:爲什麼HashMap在調用resize() 方法時會出現死循環?這篇文章就通過JDK1.7的源碼來分析並解釋這個問題。

如下,併發場景下使用HashMap造成Race Condition,從而導致死循環,現象是CPU 100%被佔用。

複製代碼

final HashMap<String, String> map = new HashMap<String, String>();
for (int i = 0; i < 1000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            map.put(UUID.randomUUID().toString(), "");
        }
    }).start();
}

複製代碼

目錄

一、問題症狀

二、Hash表數據結構

三、HashMap的rehash源代碼

1. 正常的rehash的過程

2. 併發下的rehash過程

一、問題症狀

我們在程序中會經常使用HashMap來存儲鍵值對,在單線程場景下使用沒有任何問題。當程序性能出現瓶頸,我們開始使用多線程來操作HashMap,但因此也帶來了問題:發現程序經常佔了100%的CPU,查看堆棧,你會發現程序都Hang在了HashMap.get()這個方法上了,重啓程序後問題消失。但是過段時間又會來。而且,這個問題在測試環境裏可能很難重現。

我們簡單的看一下我們自己的代碼,我們就知道HashMap被多個線程操作。而Java的文檔說HashMap是非線程安全的,應該用ConcurrentHashMap。

接下來我們分析一下具體的原因。

二、Hash表數據結構

HashMap通常會用一個指針數組(假設爲table[])來做分散所有的key,當一個key被加入時,會通過Hash算法通過key算出這個數組的下標i,然後就把這個<key, value>插到table[i]中,如果有兩個不同的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上形成一個鏈表。

我們知道,如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那麼碰撞非常頻繁,於是一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是hash表的缺陷(可參看《Hash Collision DoS 問題》)。

 所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大hash表的尺寸,但是這樣一來,整個hash表裏的無素都需要被重算一遍。這叫rehash,這個成本相當的大。

三、HashMap的rehash源代碼

下面,我們來看一下Java的HashMap的源代碼。

put一個key,value對到hash表中:

複製代碼

public V put(K key, V value) {
    // 判斷當前數組是否需要初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 如果 key 爲空,則 put 一個空值進去
    if (key == null)
        return putForNullKey(value);
    // 根據 key 計算出 hashcode
    int hash = hash(key);
    // 根據計算出的 hashcode 定位出所在桶
    int i = indexFor(hash, table.length);
    // 如果桶是一個鏈表則需要遍歷判斷裏面的 hashcode、key 是否和傳入 key 相等,如果相等則進行覆蓋,並返回原來的值
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 如果桶是空的,說明當前位置沒有數據存入;新增一個 Entry 對象寫入當前位置
    addEntry(hash, key, value, i);
    return null;
}

複製代碼

檢查容量是否超標:

複製代碼

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 判斷是否需要擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 如果需要就進行兩倍擴充,並將當前的 key 重新 hash 並定位
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    // 將當前位置的桶傳入到新建的桶中,如果當前桶有值就會在位置形成鏈表
    createEntry(hash, key, value, bucketIndex);
}

複製代碼

新建一個更大尺寸的hash表,然後把數據從老的Hash表中遷移到新的hash表中:

複製代碼

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 創建一個新的hash table
    Entry[] newTable = new Entry[newCapacity];
    // 將old hash table上的數據遷移到new hash table上
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

複製代碼

遷移的源代碼,注意粗體部分:

複製代碼

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    // 從old table中取一個元素出來,然後放到new table中
    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;
        }
    }
}

複製代碼

好了,這個代碼算是比較正常的。而且沒有什麼問題。

1. 正常的rehash的過程

假設我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。

最上面的是old hash 表,其中的Hash表的size = 2, 所以key = 3, 7, 5,在mod 2以後都衝突在table[1]這裏了。

接下來的三個步驟是hash表 resize成4,然後所有的<key, value> 重新rehash的過程。

2. 併發下的rehash過程

2.1 假設我們有兩個線程

我們再回頭看一下我們的 transfer代碼中的這個細節:

複製代碼

do {
    Entry<K,V> next = e.next; // <--假設線程一執行到這裏就被調度掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

複製代碼

而我們的線程二執行完成了。於是我們有下面的這個樣子。

注意,因爲線程一的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表。我們可以看到鏈表的順序被反轉後。

2.2 線程一被調度回來執行

先是執行 newTable[i] = e,然後是e = next,導致了e指向了key(7),而下一次循環的next = e.next導致了next指向了key(3)。

2.3 一切安好

線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

2.4 環形鏈接出現

e.next = newTable[i] 導致  key(3).next 指向了 key(7),注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

於是,當我們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章