不正確地使用 HashMap 引發死循環及元素丟失

我從這轉的:戳我進去


問題引出

前一篇文章講解了HashMap的實現原理,講到了HashMap不是線程安全的。那麼HashMap在多線程環境下又會有什麼問題呢?

幾個月前,公司項目的一個模塊在線上運行的時候出現了死循環,死循環的代碼就卡在HashMap的get方法上。儘管最終發現不是因爲HashMap導致的,但卻讓我重視了HashMap在多線程環境下會引發死循環的這個問題,下面先用一段代碼簡單模擬出HashMap的死循環:

複製代碼
public class HashMapThread extends Thread
{
    private static AtomicInteger ai = new AtomicInteger(0);
    private static Map<Integer, Integer> map = new HashMap<Integer, Integer>(1);
    
    public void run()
    {
        while (ai.get() < 100000)
        {
            map.put(ai.get(), ai.get());
            ai.incrementAndGet();
        }
    }
}
複製代碼

這個線程的作用很簡單,給AtomicInteger不斷自增並寫入HashMap中,其中AtomicInteger和HashMap都是全局共享的,也就是說所有線程操作的都是同一個AtomicInteger和HashMap。開5個線程操作一下run方法中的代碼:

複製代碼
public static void main(String[] args)
{
    HashMapThread hmt0 = new HashMapThread();
    HashMapThread hmt1 = new HashMapThread();
    HashMapThread hmt2 = new HashMapThread();
    HashMapThread hmt3 = new HashMapThread();
    HashMapThread hmt4 = new HashMapThread();
    hmt0.start();
    hmt1.start();
    hmt2.start();
    hmt3.start();
    hmt4.start();
}
複製代碼

多運行幾次之後死循環就出來了,我大概運行了7次、8次的樣子,其中有幾次是數組下標越界異常ArrayIndexOutOfBoundsException。這裏面要提一點,多線程環境下代碼會出現問題並不意味着多線程環境下一定會出現問題,但是隻要出現了問題,或者是死鎖、或者是死循環,那麼你的項目除了重啓就沒有什麼別的辦法了,所以代碼的線程安全性在開發、評審的時候必須要重點考慮到。OK,看一下控制檯:

紅色方框一直亮着,說明代碼死循環了。死循環問題的定位一般都是通過jps+jstack查看堆棧信息來定位的:

看到Thread-0處於RUNNABLE,而從堆棧信息上應該可以看出,這次的死循環是由於Thread-0對HashMap進行擴容而引起的。

所以,本文就解讀一下,HashMap的擴容爲什麼會引起死循環。

 

正常的擴容過程

先來看一下HashMap一次正常的擴容過程。簡單一點看吧,假設我有三個經過了最終rehash得到的數字,分別是5 7 3,HashMap的table也只有2,那麼HashMap把這三個數字put進數據結構了之後應該是這麼一個樣子的:

這應該很好理解。然後看一下resize的代碼,上面的堆棧裏面就有:

void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}
複製代碼
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
複製代碼
複製代碼
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            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);
        }
    }
}
複製代碼

我總結一下這三段代碼,HashMap一次擴容的過程應該是:

1、取當前table的2倍作爲新table的大小

2、根據算出的新table的大小new出一個新的Entry數組來,名爲newTable

3、輪詢原table的每一個位置,將每個位置上連接的Entry,算出在新table上的位置,並以鏈表形式連接

4、原table上的所有Entry全部輪詢完畢之後,意味着原table上面的所有Entry已經移到了新的table上,HashMap中的table指向newTable

這樣就完成了一次擴容,用圖表示是這樣的:

HashMap的一次正常擴容就是這樣的,這很好理解。

 

擴容導致的死循環

既然是擴容導致的死循環,那麼繼續看擴容的代碼:

複製代碼
 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) {
 5         Entry<K,V> e = src[j];
 6         if (e != null) {
 7             src[j] = null;
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity);
11                 e.next = newTable[i];
12                 newTable[i] = e;
13                 e = next;
14             } while (e != null);
15         }
16     }
17 }
複製代碼

兩個線程,線程A和線程B。假設第9行執行完畢,線程A切換,那麼對於線程A而言,是這樣的:

CPU切換到線程B運行,線程B將整個擴容過程全部執行完畢,於是就形成了:

此時CPU切換到線程A上,執行第8行~第14行的do...while...循環,首先放置3這個Entry:

我們必須要知道,由於線程B已經執行完畢,因此根據Java內存模型(JMM),現在table裏面所有的Entry都是最新的,也就是7的next是3,3的next是null。3放置到table[3]的位置上了,下面的步驟是:

1、e=next,即e=7

2、判斷e不等於null,循環繼續

3、next=e.next,即next=7的next,也就是3

4、放置7這個Entry

所以,用圖表示就是:

放置完7之後,繼續運行代碼:

1、e=next,也就是說e=3

2、判斷e不等於null,循環繼續

3、next=e.next,即3的next,也就是null

4、放置3這個Entry

把3移到table[3]上去,死循環就出來了:

3移到table[3]上去了,3的next指向7,由於原先7的next指向3,這樣就成了一個死循環。

此時執行13行的e=next,那麼e=null,循環終止。儘管此次循環確實結束了,但是後面的操作,只要涉及輪詢HashMap數據結構的,無論是迭代還是擴容,都將在table[3]這個鏈表處出現死循環。這也就是前面的死循環堆棧出現的原因,transfer的484行,因爲這是一次擴容操作,需要遍歷HashMap數據結構,transfer方法是擴容的最後一個方法。

 

3 5 7又會有怎樣的結果

可能有人覺得上面的數字5 7 3太巧了,像是專門爲了產生HashMap的死循環而故意選擇的數字。

這個問題,我這麼回答:我記得在《從Paxos到Zookeeper分佈式一致性原理與實踐》有一段話大概是這麼描述的,有一個被反覆實踐得出的結論是,任何在多線程下可能發生的錯誤場景最終一定會發生

5 7 3這個數字可不巧,擴容前相鄰兩個Entry被分配到擴容後同樣的table位置是很正常的。關鍵的是,即使這種異常場景發生的可能性再低,只要發生一次,那麼你的系統就部分甚至全部不可用了----除了重啓系統沒有任何辦法。所以,這種可能會發生的異常場景必須提前扼殺。

OK,不扯了,前面講了5 7 3導致了死循環,現在看一下正常的順序3 5 7,會發生什麼問題。簡單看一下,就不像上面講得這麼詳細了:

這是擴容前數據結構中的內容,擴容之後正常的應該是:

現在在多線程下遇到問題了,某個線程先放7:

 

再接着放5:

由於5的next此時爲null,因此擴容操作結束,3 5 7造成的結果就是元素丟失。

 

如何解決

把一個線程非安全的集合作爲全局共享的,本身就是一種錯誤的做法,併發下一定會產生錯誤。

所以,解決這個問題的辦法很簡單,有兩種:

1、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap變成一個線程安全的Map

2、使用Hashtable、ConcurrentHashMap這兩個線程安全的Map

不過,既然選擇了線程安全的辦法,那麼必然要在性能上付出一定的代價----畢竟這個世界上沒有十全十美的事情,既要運行效率高、又要線程安全。

 

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