前言
在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。