【HashMap】HashMap死循環與元素丟失(一)

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

公司項目的一個模塊在線上運行的時候出現了死循環,死循環的代碼就卡在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。這裏面要提一點,多線程環境下代碼會出現問題並不意味着多線程環境下必定會出現問題,但 是隻要出現了問題,或者是死鎖、或者是死循環,那麼你的項目除了重啓就沒有什麼別的辦法了,因此代碼的線程安全性在開發、評審的時候必需要重點考慮到。 死循環問題的定位通常都是經過jps+jstack查看堆棧信息來定位的:數據結構

看到Thread-0處於RUNNABLE,而從堆棧信息上應該能夠看出,此次的死循環是因爲Thread-0對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一次擴容的過程應該是:spa

一、取當前table的2倍做爲新table的大小線程

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

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

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

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

擴容致使的死循環

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

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);
            }
        }
    }

兩個線程,線程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]的位置上了,下面的步驟是:

一、e=next,即e=7

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

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

四、放置7這個Entry

因此,用圖表示就是:

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

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

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

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

四、放置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方法是擴容的最後一個方法。

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

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

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

再接着放5:

因爲5的next此時爲null,所以擴容操做結束,3 5 7形成的結果就是元素丟失。

解決

把一個線程非安全的集合做爲全局共享的,自己就是一種錯誤的作法,併發下必定會產生錯誤。因此,解決這個問題的辦法很簡單,有兩種:

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

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

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

相關文章
相關標籤/搜索