關於上上文hashmap的深入-hashmap產生死鎖的詳解

看到的好文(我的好文都是我能看懂的,說明寫的很詳細啦),轉載記錄學習~
不過人家也是轉載的,不管啊,好文都要分享噠~~

前言
幾個月前,上線了一個版本。但是上線了幾個小時之後 CPU 突然暴增至99%,在網上搜了一下,多半是因爲出現死循環問題了。就用 jstack dump 了當時的線程快照,發現這次死循環問題的起源是 HashMap 的 get()方法。之後先是迅速重啓了服務,這樣可以讓服務先運行一段時間。然後立即修復了這個 bug並提交到 SVN。

這次事故的原因是因爲開發時沒有注意到 HashMap 是非線程安全的,而使用 HashMap 的那個地方又是 PV 級別的代碼,多線程併發非常容易出現問題。但因爲這塊代碼不是我開發的,我也不清楚具體的細節,就沒有過多關注。最近正好在看 HashMap 的源碼,突然想起來這事,就正好看看究竟是神馬原因造成了 HashMap 的死鎖問題。

一、HashMap 的底層實現
這個可以參考上一篇文章:HashMap 源碼剖析,具體介紹了 HashMap 的底層實現:

數組:充當索引
鏈表:處理碰撞

簡單地說一下:

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

二、源碼剖析
首先來猜下,神馬情況會造成死鎖呢?

我們知道,如果要造成死循環,肯定和鏈表鏈表有關,因爲只有鏈表纔有指針。但是在源碼剖析中我們知道,每次添加元素都是在鏈表頭部添加元素,怎麼會造成死鎖呢?

其實,關鍵就在於rehash過程。在前面我們說了是 HashMap 的get()方法造成的死鎖。既然是 get()造成的死鎖,一定是跟put()進去元素的位置有關,所以我們從 put()方法開始看起。

 1 public V put(K key, V value) {
 2         if (table == EMPTY_TABLE) {
 3             inflateTable(threshold);
 4         }
 5         if (key == null)
 6             return putForNullKey(value);
 7         int hash = hash(key);
 8         int i = indexFor(hash, table.length);
 9         //如果該 key 存在,就替換舊值
10         for (Entry<K,V> e = table[i]; e != null; e = e.next) {
11             Object k;
12             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
13                 V oldValue = e.value;
14                 e.value = value;
15                 e.recordAccess(this);
16                 return oldValue;
17             }
18         }
19 
20         modCount++;
21         //如果沒有這個 key,就插入一個新元素!跟進去看看
22         addEntry(hash, key, value, i);
23         return null;
24     }
25 
26     void addEntry(int hash, K key, V value, int bucketIndex) {
27      //查看當前的size是否超過了我們設定的閾值threshold,如果超過,需要resize
28         if ((size >= threshold) && (null != table[bucketIndex])) {
29             resize(2 * table.length);
30             hash = (null != key) ? hash(key) : 0;
31             bucketIndex = indexFor(hash, table.length);
32         }
33 
34         createEntry(hash, key, value, bucketIndex);
35     }
36 
37     //新建一個更大尺寸的hash表,把數據從老的Hash表中遷移到新的Hash表中。
38     void resize(int newCapacity) {
39         Entry[] oldTable = table;
40         int oldCapacity = oldTable.length;
41         if (oldCapacity == MAXIMUM_CAPACITY) {
42             threshold = Integer.MAX_VALUE;
43             return;
44         }
45 
46         //創建一個新的 Hash 表
47         Entry[] newTable = new Entry[newCapacity];
48         //轉移!!!!跟進去
49         transfer(newTable, initHashSeedAsNeeded(newCapacity));
50         table = newTable;
51         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
52     }
53 
54     //高能預警!!!!重點全在這個函數中
55     void transfer(Entry[] newTable, boolean rehash) {
56         int newCapacity = newTable.length;
57         for (Entry<K,V> e : table) {
58             while(null != e) {
59                 Entry<K,V> next = e.next;
60                 if (rehash) {
61                     e.hash = null == e.key ? 0 : hash(e.key);
62                 }
63                 int i = indexFor(e.hash, newCapacity);
64                 e.next = newTable[i];
65                 newTable[i] = e;
66                 e = next;
67             }
68         }
69     }

看到最後這個函數transfer(),就算到達了問題的關鍵。我們先大概看下它的意思:

  1. 對索引數組中的元素遍歷
  2. 對鏈表上的每一個節點遍歷:用 next 取得要轉移那個元素的下一個,將 e 轉移到新 Hash 表的頭部,因爲可能有元素,所以先將 e.next 指向新 Hash 表的第一個元素(如果是第一次就是 null),這時候新 Hash 的第一個元素是 e,但是 Hash 指向的卻是 e 沒轉移時候的第一個,所以需要將 Hash 表的第一個元素指向 e
  3. 循環2,直到鏈表節點全部轉移
  4. 循環1,直到所有索引數組全部轉移
    經過這幾步,我們會發現轉移的時候是逆序的。假如轉移前鏈表順序是1->2->3,那麼轉移後就會變成3->2->1。這時候就有點頭緒了,死鎖問題不就是因爲1->2的同時2->1造成的嗎?所以,HashMap 的死鎖問題就出在這個transfer()函數上

三、單線程 rehash 詳細演示
單線程情況下,rehash 不會出現任何問題:

假設hash算法就是最簡單的 key mod table.length(也就是數組的長度)。
最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以後碰撞發生在 table[1]接下來的三個步驟是 Hash表 resize 到4,並將所有的 key,value 重新rehash到新 Hash 表的過程
如圖所示:

這裏寫圖片描述

四、多線程 rehash 詳細演示
首先我們把關鍵代碼貼出來,如果在演示過程中忘了該執行哪一步,就退回來看看:

 1 while(null != e) {
 2     Entry<K,V> next = e.next;
 3     if (rehash) {
 4         e.hash = null == e.key ? 0 : hash(e.key);
 5     }
 6     int i = indexFor(e.hash, newCapacity);
 7     e.next = newTable[i];
 8     newTable[i] = e;
 9     e = next;
10 }

上面代碼就是重中之重,不過我們可以再簡化一下,因爲中間的 i 就是判斷新表的位置,我們可以跳過。簡化後代碼:

1 while(null != e) {
2     Entry<K,V> next = e.next;
3     e.next = newTable[i];
4     newTable[i] = e;
5     e = next;
6 }

去掉了一些與本過程冗餘的代碼,意思就非常清晰了:

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

——因爲是單鏈表,如果要轉移頭指針,一定要保存下一個結點,不然轉移後鏈表就丟了

e.next = newTable[i];

——e 要插入到鏈表的頭部,所以要先用 e.next 指向新的 Hash 表第一個元素(爲什麼不加到新鏈表最後?因爲複雜度是 O(N))

newTable[i] = e;

——現在新 Hash 表的頭指針仍然指向 e 沒轉移前的第一個元素,所以需要將新 Hash 表的頭指針指向 e

e = next

——轉移 e 的下一個結點
好了,代碼層面已經全部 ok,下面開始演示:

假設這裏有兩個線程同時執行了put()操作,並進入了transfer()環節
粉紅色代表線程1,淺藍色代碼線程2
1. 初始狀態
現在假設線程1的工作情況如下代碼所示,而線程2完成了整個transfer()過程,所以就完成了 rehash。

1 while(null != e) {
2     Entry<K,V> next = e.next; //線程1執行到這裏被調度掛起了
3     e.next = newTable[i];
4     newTable[i] = e;
5     e = next;
6 }

那麼現在的狀態爲:
這裏寫圖片描述

從上面的圖我們可以看到,因爲線程1的 e 指向了 key(3),而 next 指向了 key(7),在線程2 rehash 後,就指向了線程2 rehash 後的鏈表。

  1. 第一步
    然後線程1被喚醒了:

  2. 執行e.next = newTable[i],於是 key(3)的 next 指向了線程1的新 Hash 表,因爲新 Hash 表爲空,所以e.next = null,

  3. 執行newTable[i] = e,所以線程1的新 Hash 表第一個元素指向了線程2新 Hash 表的 key(3)。好了,e 處理完畢。
  4. 執行e = next,將 e 指向 next,所以新的 e 是 key(7)
    狀態圖爲:
    這裏寫圖片描述

  5. 第二步
    然後該執行 key(3)的 next 節點 key(7)了:

  6. 現在的 e 節點是 key(7),首先執行Entry<K,V> next = e.next ,那麼 next 就是 key(3)了

  7. 執行e.next = newTable[i],於是key(7) 的 next 就成了 key(3)
  8. 執行newTable[i] = e,那麼線程1的新 Hash 表第一個元素變成了 key(7)
  9. 執行e = next,將 e 指向 next,所以新的 e 是 key(3)
    這時候的狀態圖爲:
    這裏寫圖片描述

  10. 第三步
    然後又該執行 key(7)的 next 節點 key(3)了:

  11. 現在的 e 節點是 key(3),首先執行Entry<K,V> next = e.next,那麼 next 就是 null

  12. 執行e.next = newTable[i],於是key(3) 的 next 就成了 key(7)
  13. 執行newTable[i] = e,那麼線程1的新 Hash 表第一個元素變成了 key(3)
  14. 執行e = next,將 e 指向 next,所以新的 e 是 key(7)
    這時候的狀態如圖所示:
    這裏寫圖片描述

很明顯,環形鏈表出現了!!當然,現在還沒有事情,因爲下一個節點是 null,所以transfer()就完成了,等put()的其餘過程搞定後,HashMap 的底層實現就是線程1的新 Hash 表了。

沒錯,put()過程雖然造成了環形鏈表,但是它沒有發生錯誤。它靜靜的等待着get()這個冤大頭的到來。

  1. 死鎖吧,騷年!!!
    現在程序被執行了一個hashMap.get(11),這時候會調用getEntry(),這個函數就是去找對應索引的鏈表中有沒有這個 key。然後。。。。悲劇了。。。Infinite Loop~~

五、啓示
通過上面的講解,我們就弄明白了 HashMap 死鎖的原因,其實在很久以前這個 Bug 就被提交給了 Sun,但是 Sun 認爲這不是一個 Bug,因爲文檔中明確說了 HashMap 不是線程安全的。要併發就使用 ConcurrentHashMap。

因爲 HashMap 爲了性能考慮,沒有使用鎖機制。所以就是非線程安全的,而 ConcurrentHashMap 使用了鎖機制,所以是線程安全的。當然,要知其然知其所以然。最好是去看一下 ConcurrentHashMap 是如何實現鎖機制的(其實是分段鎖,不然所有的 key 在鎖的時候都無法訪問)。就像侯捷在《STL 源碼剖析》中說的:

源碼面前,了無祕密。
對我們的啓示在前面的文章踩坑記中就提到過:

使用新類、新函數時,一定一定要過一遍文檔
不要望文生義或者憑直覺“猜”,不然坑的不僅僅是自己。

轉自:http://github.thinkingbar.com/hashmap-infinite-loop/

發佈了17 篇原創文章 · 獲贊 11 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章