圖解集合5:不正確地使用HashMap引發死迴圈及元素丟失

五月的倉頡發表於2015-12-12

問題引出

前一篇文章講解了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

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

 

相關文章