併發編程專題八-hashMap死循環分析

1、hashMap併發中存在的問題

在咱們開發程序過程當中,hashMap算是咱們最經常使用的數據結構了,那麼若是咱們在高併發下使用hashMap可能會出現什麼問題呢?java

一、拿到的結果不是咱們想要的。(非線程安全)編程

二、擴容而致使程序死循環。導致CPU100%;(JDK1.7版本擴容,1.8暫無此問題。嚴重數組

爲何會出現死循環,接下來咱們進行分析一下。首先咱們瞭解下hashMap的源碼,以及put操做。安全

1.1 1.7HashMap源碼簡析

1.1.1 構造函數解析微信

hashMap代價都比較熟悉了,這裏就簡單介紹HashMap幾個關鍵點。HashMap的數據結構就是數組+鏈表的數據結構,以下數據結構

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構
        int hash;//對key的hashcode值進行hash運算後獲得的值,存儲在Entry,避免重複計算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

當實例化一個HashMap時,系統會建立一個長度爲Capacity的Entry數組,這個長度被稱爲容量(Capacity),在這個數組中能夠存放元素的位置咱們稱之爲「桶」(bucket),每一個bucket都有本身的索引,系統能夠根據索引快速的查找bucket中的元素。 每一個bucket中存儲一個元素,即一個Entry對象,但每個Entry對象能夠帶一個引用變量,用於指向下一個元素,所以,在一個桶中,就有可能生成一個Entry鏈。 Entry是HashMap的基本組成單元,每個Entry包含一個key-value鍵值對。 多線程

一個長度爲16的數組中,每一個元素存儲的是一個鏈表的頭結點。那麼這些元素是按照什麼樣的規則存儲到數組中呢。通常狀況是經過hash(key)%len得到,也就是元素的key的哈希值對數組長度取模獲得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108以及140都存儲在數組下標爲12的位置。併發

在存儲一對值時(Key—->Value對),其實是存儲在一個Entry的對象e中,程序經過key計算出Entry對象的存儲位置。換句話說,Key—->Value的對應關係是經過key—-Entry—-value這個過程實現的,因此就有咱們表面上知道的key存在哪裏,value就存在哪裏。函數

構造函數裏的參數值高併發

//默認初始化化容量,即16  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//最大容量,即2的30次方  
static final int MAXIMUM_CAPACITY = 1 << 30;  

//默認裝載因子  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  

//HashMap內部的存儲結構是一個數組,此處數組爲空,即沒有初始化以前的狀態  
static final Entry<?,?>[] EMPTY_TABLE = {};  

//空的存儲實體  
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  

//實際存儲的key-value鍵值對的個數
transient int size;

//閾值,當table == {}時,該值爲初始容量(初始容量默認爲16);當table被填充了,也就是爲table分配內存空間後,threshold通常爲 capacity*loadFactory。HashMap在進行擴容時須要參考threshold
int threshold;

//負載因子,表明了table的填充度有多少,默認是0.75,當超過容量*負載因子時會進行擴容
final float loadFactor;

//用於快速失敗,因爲HashMap非線程安全,在對HashMap進行迭代時,若是期間其餘線程的參與致使HashMap的結構發生變化了(好比put,remove等操做),須要拋出異常ConcurrentModificationException
transient int modCount;

1.2.2 put源碼分析

一、當咱們將一個元素放入hashMap中,首先判斷map是否爲空,若是是空,對數組進行填充

二、若是Key爲null,則將值存儲在table[0]的位置或table[0]的鏈表衝突連上

三、對key值再進行hash散列,使其散列均勻,而後經過indexFor進行肯定table的位置,該方法爲h & (length-1);就是拿當前hash的值如table長度-1作與運算,其實本質等於hash值除table長度取餘。

四、而後循環遍歷鏈表,查看鏈表裏的key是否存在,存在的話替換舊值。而後return 老的值

五、若是不存在,則在尾部添加新的entry節點。

public V put(K key, V value) {
        //若是table數組爲空數組{},進行數據填充
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//分配數組空間
        }
       //若是key爲null,存儲位置爲table[0]或table[0]的衝突鏈上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//對key的hashcode進一步hash計算,確保散列均勻
        int i = indexFor(hash, table.length);//獲取在table中的實際位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //若是該對應數據已存在,執行覆蓋操做。用新value替換舊value,並返回舊value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);//調用value的回調函數,其實這個函數也爲空實現
                return oldValue;
            }
        }
        modCount++;//保證併發訪問時,若HashMap內部結構發生變化,快速響應失敗
        addEntry(hash, key, value, i);//新增一個entry
        return null;
    }

1.2.3 addEntry節點方法以及擴容

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//當size超過臨界閾值threshold,而且即將發生哈希衝突時進行擴容,新容量爲舊容量的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);//擴容後從新計算插入的位置下標
        }

        //把元素放入HashMap的桶的對應位置
        createEntry(hash, key, value, bucketIndex);
    }
//建立元素  
    void createEntry(int hash, K key, V value, int bucketIndex) {  
        Entry<K,V> e = table[bucketIndex];  //獲取待插入位置元素
        table[bucketIndex] = new Entry<>(hash, key, value, e);//這裏執行連接操做,使得新插入的元素指向原有元素。
//這保證了新插入的元素老是在鏈表的頭  
        size++;//元素個數+1  
    }

一、當咱們對要插入的元素定好位置以後,則須要對當前鏈表進行判斷是否須要擴容

二、當前table數組長度大於threashold(表默認長度*負載因子)的時候,則須要對數組進行擴容,擴容方式爲當前的數組長度的兩倍

三、當數組擴容完畢,須要從新計算全部的元素的位置,而後插入對應的位置當中。擴容方法主要爲resize以及resize中的transfer方法,不在細說

如圖

擴容前,3,7,5對數組長度2取摩都是值1,因此都是在1的位置.

擴容後數組table長度爲4,7,3取摩後爲位置爲3,5取摩爲位置爲1

四、最後執行createEntry方法,將生成的節點插入對應位置的頭部。

1.三、HashMap死循環分析

瞭解了hashMap中put以及擴容操做以後,咱們模擬一個場景。有原hashMap以下

在多線程環境下有線程一和線程二,同時對該map進行擴容執行擴容。

當線程一執行到以下代碼時被掛起

此時線程1的數據結構爲,table已經擴容完畢,從新計算每一個元素位置時被掛起,此時key爲3的還在1位置,3.next爲7。

這時,線程2完成擴容,擴容後的數據結構爲

當線程1被調度回來執行以後,由於線程一執行的e.next =newTable[i];將key3插入到3號位置,同時3.next=key7。此時e=key3,next=key7;

當執行到e=next的時候,e=key7,next=key7;

而後開始下一輪while。但此時由於線程二已經將key=7的next設置爲key3(問題就在這裏,線程二執行的時候,key7.next已是null了,但這裏線程一去執行的時候key7.next倒是key3)。因此當第二輪循環開始,執行next=e.next後,next = key3。

以後經過頭插法,將key7插入key3以前。在執行完next=e.next 以後,e=key3,next=key3;

而後開始第三輪循環。e和next都是key3。因此根據頭插法。key3又要插入key7以前,這就致使了key3.next爲key7,key7.next爲key3

因此當咱們去get值得時候,當定位到3的時候,就會產生死循環。致使永遠拿不到數據。

總結

HashMap之因此在併發下的擴容形成死循環,是由於,多個線程併發進行時,由於一個線程先期完成了擴容,將原Map的鏈表從新散列到本身的表中,而且鏈表變成了倒序,後一個線程再擴容時,又進行本身的散列,再次將倒序鏈表變爲正序鏈表。因而造成了一個環形鏈表,當get表中不存在的元素時,形成死循環。在1.8當中,鏈表擴容轉爲紅黑樹,沒有相關的問題。

其餘閱讀   併發編程專題,你們有問題能夠加我微信哈~

481021518c4b8fa00ba60ef9609c53b2b5f.jpg

相關文章
相關標籤/搜索