爲什麼HashMap線程不安全?以及實現HashMap線程安全的解決方案

一、爲什麼HashMap線程不安全?

原著參考

1、JDK1.7 擴容引發的死循環和數據丟失

(1).當前jdk1.7版本的HashMap線程不安全主要是發生在擴容函數中,其中調用了HshMap的transfer()方法

//jdk 1.7的transfer方法,HashMap的擴容操作
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

在執行數組擴容操作時,數據會重新定位新數組中索引,並採用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點。如何造成死循環以及數據丟失的。

(2)、數組擴容時如何造成死循環和數據丟失?

我們假設現在有兩個線程A、B同時對下面這個HashMap進行擴容操作:

在這裏插入圖片描述
正常擴容後的結果
在這裏插入圖片描述
但是當線程A執行到上面transfer函數的第11行代碼時,CPU時間片耗盡,線程A被掛起。即如下圖中位置所示:
在這裏插入圖片描述
此時線程A中:e=3、next=7、e.next=null
在這裏插入圖片描述
當線程A的時間片耗盡後,CPU開始執行線程B,並在線程B中成功的完成了數據遷移
在這裏插入圖片描述
根據JMM可知,線程B執行完數據遷移後,此時主內存中newTable和table都是最新的,也就是說:7.next=3、3.next=null
在這裏插入圖片描述
隨後線程A再次獲得CPU時間片繼續執行newTable[i] = e,將3放入新數組對應的位置,執行完此輪循環後線程A的情況如下:
在這裏插入圖片描述
接着繼續執行下一輪循環,此時e=7,從主內存中讀取e.next時發現主內存中7.next=3,此時next=3,並將7採用頭插法的方式放入新數組中,並繼續執行完此輪循環,結果如下:
在這裏插入圖片描述
上輪7.next=3,而e=3,執行下一次循環可以發現,3.next=null,所以此輪循環將會是最後一輪循環。
在這裏插入圖片描述
接下來當執行完e.next=newTable[i]即3.next=7後,3和7之間就相互連接了,當執行完newTable[i]=e後,3被頭插法重新插入到鏈表中,執行結果如下圖所示:
在這裏插入圖片描述
此時e.next=null即next=null,當執行完e=null後,將不會進行下一輪循環。到此線程A、B的擴容操作完成,很明顯當線程A執行完後,HashMap中出現了環形結構,當在以後對該HashMap進行操作時會出現死循環。同時元素5在擴容期間,發生了數據丟失的問題。

2、JDK1.8中的數據覆蓋

(1)dk1.7的數據丟失、死循環問題在JDK1.8中已經得到了很好的解決,直接在HashMap的resize()中完成了數據遷移。

(2)爲什麼說 JDK1.8會出現數據覆蓋的情況?
查看這段JDK1.8中的put操作代碼:
在這裏插入圖片描述
如下圖框中的代碼是判斷是否出現hash碰撞,假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完該行判斷代碼後由於時間片耗盡導致被掛起,而線程B得到時間片後在該下標處插入了元素,完成了正常的插入,然後線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。
在這裏插入圖片描述
除此之外,下圖pullVal方法中還有框中代碼的有個++size語句,如果還是線程A、B,這兩個線程同時進行put操作時,假設當前HashMap的size大小爲10,當線程A執行到第38行代碼時,從主內存中獲得size的值爲10後準備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主內存中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主內存,然後線程A再次拿到CPU並繼續執行(此時size的值仍爲10),當執行完put操作後,還是將size=11寫回內存,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。
在這裏插入圖片描述

二、HashMap線程安全的解決方案

1.Hashtable(廢棄)

不建議使用HashTable,Oracle官方也將其廢棄,建議在多線程環境下使用ConcurrentHashMap類。
在這裏插入圖片描述
在這裏插入圖片描述

(1)Hashtable的操作
HashTable的操作幾乎和HashMap一致,主要的區別在於HashTable爲了實現多線程安全,在幾乎所有的方法上都加上了synchronized鎖(鎖的是類的實例,也就是整個map結構),當一個線程訪問 Hashtable 的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用 put 方法時,另一個線程不但不可以使用 put 方法,連 get 方法都不可以,而加鎖的結果就是HashTable操作的效率十分低下在這裏插入圖片描述

(2)HashTable與HashMap對比
①線程安全
HashMap是線程不安全的類,多線程下會造成併發衝突,但單線程下運行效率較高;
HashTable是線程安全的類,很多方法都是用synchronized修飾,但同時因爲加鎖導致併發效率低下,單線程環境效率也十分低;

②插入null
HashMap最多允許有一個鍵爲null,允許多個值爲null;
HashTable不允許鍵或值爲null;

③容量
HashMap底層數組長度必須爲2的冪(16,32,128…),這樣做是爲了hash準備,默認爲16;
HashTable底層數組長度可以爲任意值,這就造成了hash算法散射不均勻,容易造成hash衝突,默認爲11;

④Hash映射
HashMap的hash算法通過非常規設計,將底層table長度設計爲2的冪,使用位與運算代替取模運算,減少運算消耗;

// HashMap
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 下標index運算
int index = (table.length - 1) & hash(key)

HashTable的hash算法首先使得hash值小於整型數最大值,再通過取模進行散射運算;

// HashTable
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;

⑤擴容機制
HashMap創建一個爲原先2倍的數組,然後對原數組進行遍歷以及rehash(再次求桶);
HashTable擴容將創建一個原長度2倍的數組,再使用頭插法將鏈表進行反序;

⑥結構區別
HashMap是由數組+鏈表形成,在JDK1.8之後鏈表長度大於8時轉化爲紅黑樹;
HashTable一直都是數組+鏈表;

⑦繼承關係
HashTable繼承自Dictionary類;
HashMap繼承自AbstractMap類;

⑧迭代器
HashMap的 Iterator 迭代器是fail-fast;

fail-fast(快速失敗機制):當我們在遍歷集合元素的時候,經常會使用迭代器,但在迭代器遍歷元素的過程中,如果集合的結構被修改(增加、刪除),就會拋出Concurrent Modification Exception(併發修改異常),防止繼續遍歷。

fail-safe(安全失敗機制):當集合的結構被改變的時候,fail-safe機制會在複製原集合的一份數據出來,然後在複製的那份數據遍歷。因此,fail-safe不會拋出異常,但存在以下缺點:①複製時需要額外的空間和時間上的開銷。②不能保證遍歷的是最新內容。

HashTable的 Enumerator不是。

2.Collections.synchronizedMap(一般不用)

通過Collections.synchronizedMap()返回一個新的Map的實現

Map<String,String> map = Collections.synchronizedMap(new HashMap<>());

我們在調用上面這個方法的時候就需要傳入一個Map,如下圖可以看到有兩個構造器,如果你傳入了mutex參數,則將對象排斥鎖賦值爲傳入的對象。
如果沒有,則將對象排斥鎖賦值爲this,即調用synchronizedMap的對象,就是上面的Map
在這裏插入圖片描述

通過Collections.synchronizedMap()來封裝所有不安全的HashMap的方法。
在這裏插入圖片描述
封裝的關鍵點有2處
1 )使用了經典的synchronized來進行互斥
2)使用了代理模式new了一個新的類,這個類同樣實現了Map接口.在Hashmap上面,synchronized鎖住的是對象,所以第一個申請的得到鎖,其他線程將進入阻塞,等待喚醒

優點:代碼實現十分簡單,一看就懂

缺點:從鎖的角度來看,基本上是鎖住了儘可能大的代碼塊.性能會比較差.

3.ConcurrentHashMap(常用)

(1)JDK 1.7 中,採用分段鎖的機制,實現併發的更新操作,底層採用數組+鏈表的存儲結構,包括兩個核心靜態內部類 Segment 和 HashEntry。
    ①、Segment 繼承 ReentrantLock(重入鎖) 用來充當鎖的角色,每個 Segment 對象守護每個散列映射表的若干個桶;
    ②、HashEntry 用來封裝映射表的鍵-值對;
    ③、每個桶是由若干個 HashEntry 對象鏈接起來的鏈表

在這裏插入圖片描述
分段鎖:Segment數組中,一個Segment對象就是一把鎖,對應一個HashEntry數組,該數組中的數據同步依賴於同一把鎖,不同HashEntry數組的讀寫互不干擾
在這裏插入圖片描述

jdk 1.7源碼分析
ConcurrentHashMap繼承了AbstractMap類,並且實現了ConcurrentMap接口,並且線程安全的實現了接口中的增刪改查的方法
在這裏插入圖片描述
ConcurrentMap接口中的方法聲明
在這裏插入圖片描述
ConcurrentMap中的靜態變量
在這裏插入圖片描述
ConcurrentMap中的成員變量
在這裏插入圖片描述
靜態內部類HashEntry
在這裏插入圖片描述
靜態內部類Segment
在這裏插入圖片描述
靜態內部類Segment-成員方法
在這裏插入圖片描述
ConcurrentHashMap的構造器
在這裏插入圖片描述
put方法
在這裏插入圖片描述
調用segment的put方法,首先第一步的時候會嘗試獲取鎖,如果獲得鎖則node=null
在這裏插入圖片描述
獲取失敗肯定就有其他線程存在競爭,則利用 scanAndLockForPut() 自旋獲取鎖
在這裏插入圖片描述
擴容操作,在concurrentHashMap中是被保護的
在這裏插入圖片描述
當 put 方法時,發現元素個數超過了閾值,則會擴容。需要注意的是,每個Segment只管它自己的擴容,互相之間並不影響,只要是2的n次冪)。

get 方法的邏輯,需要將 Key 通過 Hash 之後定位到具體的 Segment ,再通過一次 Hash 定位到具體的元素上
由於 HashEntry 中的 value 屬性是用 volatile 關鍵詞修飾的,保證了內存可見性,所以每次獲取時都是最新值。

缺點:jdk1.7版本雖然可以支持每個Segment併發訪問,但是基本上還是數組加鏈表的方式,當執行查詢的時候,還得遍歷鏈表,會導致效率很低.

(2)JDK 1.8中拋棄了原有的 Segment 分段鎖,來保證採用Node + CAS + Synchronized來保證併發安全性。取消類 Segment,直接用table 數組存儲鍵值對;當 Node對象組成的鏈表長度超過TREEIFY_THRESHOLD 時,鏈表轉換爲紅黑樹,提升性能。底層變更爲數組 + 鏈表 + 紅黑樹。
在這裏插入圖片描述

首先Node代替了1.7之中的HashEntry,並在val和next添加了volatile,保證了原子的可見性,也引入了紅黑樹,在鏈表大於一定值的時候會轉換(默認是8)。
在這裏插入圖片描述
ConcurrentHashMap的put方法
在這裏插入圖片描述
CAS以及ABA問題

CAS性能很高,但synchronized之前一直都是重量級的鎖,jdk1.8 引入了synchronized,採用鎖升級的方式。

針對 synchronized 獲取鎖的方式,JVM 使用了鎖升級的優化方式,就是先使用偏向鎖優先同一線程然後再次獲取鎖,如果失敗,就升級爲 CAS 輕量級鎖,如果失敗就會短暫自旋,防止線程被系統掛起。最後如果以上都失敗就升級爲重量級鎖。

偏向鎖:爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行,始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它線程去執行同步。
輕量級鎖:當有兩個線程,競爭的時候就會升級爲輕量級鎖。引入輕量級鎖的主要目的是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。
重量級鎖:大多數情況下,在同一時間點,常常有多個線程競爭同一把鎖,悲觀鎖的方式,競爭失敗的線程會不停的在阻塞及被喚醒態之間切換,代價比較大

ConcurrentHashMap的get方法
在這裏插入圖片描述

 

一、學習目標

1、HashMap線程不安全原因:

原因:

  • JDK1.7 中,由於多線程對HashMap進行擴容,調用了HashMap#transfer(),具體原因:某個線程執行過程中,被掛起,其他線程已經完成數據遷移,等CPU資源釋放後被掛起的線程重新執行之前的邏輯,數據已經被改變,造成死循環、數據丟失。
  • JDK1.8 中,由於多線程對HashMap進行put操作,調用了HashMap#putVal(),具體原因:假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完第六行代碼後由於時間片耗盡導致被掛起,而線程B得到時間片後在該下標處插入了元素,完成了正常的插入,然後線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。

改善:

  • 數據丟失、死循環已經在在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到HashMap#transfer(),因爲JDK1.8直接在HashMap#resize()中完成了數據遷移。

2、HashMap線程不安全的體現:

  • JDK1.7 HashMap線程不安全體現在:死循環、數據丟失
  • JDK1.8 HashMap線程不安全體現在:數據覆蓋

二、HashMap線程不安全、死循環、數據丟失、數據覆蓋的原因

1、JDK1.7 擴容引發的線程不安全

HashMap的線程不安全主要是發生在擴容函數中,其中調用了JDK1.7 HshMap#transfer():

 
ini
複製代碼
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

這段代碼是HashMap的擴容操作,重新定位每個桶的下標,並採用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點。理解了頭插法後再繼續往下看是如何造成死循環以及數據丟失的。

2、擴容造成死循環和數據丟失

假設現在有兩個線程A、B同時對下面這個HashMap進行擴容操作:

image

正常擴容後的結果是下面這樣的:

image

但是當線程A執行到上面transfer函數的第11行代碼時,CPU時間片耗盡,線程A被掛起。即如下圖中位置所示:

image

此時線程A中:e=3、next=7、e.next=null

image

當線程A的時間片耗盡後,CPU開始執行線程B,並在線程B中成功的完成了數據遷移

image

重點來了,根據Java內存模式可知,線程B執行完數據遷移後,此時主內存中newTable和table都是最新的,也就是說:7.next=3、3.next=null。

隨後線程A獲得CPU時間片繼續執行newTable[i] = e,將3放入新數組對應的位置,執行完此輪循環後線程A的情況如下:

image

接着繼續執行下一輪循環,此時e=7,從主內存中讀取e.next時發現主內存中7.next=3,此時next=3,並將7採用頭插法的方式放入新數組中,並繼續執行完此輪循環,結果如下:

image

此時沒任何問題。

上輪next=3,e=3,執行下一次循環可以發現,3.next=null,所以此輪循環將會是最後一輪循環。

接下來當執行完e.next=newTable[i]即3.next=7後,3和7之間就相互連接了,當執行完newTable[i]=e後,3被頭插法重新插入到鏈表中,執行結果如下圖所示:

image

上面說了此時e.next=null即next=null,當執行完e=null後,將不會進行下一輪循環。到此線程A、B的擴容操作完成,很明顯當線程A執行完後,HashMap中出現了環形結構,當在以後對該HashMap進行操作時會出現死循環。

並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。

3、JDK1.8中的線程不安全

上面的擴容造成的數據丟失、死循環已經在在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到HashMap#transfer(),因爲JDK1.8直接在HashMap#resize()中完成了數據遷移。

爲什麼說 JDK1.8會出現數據覆蓋的情況? 我們來看一下下面這段JDK1.8中的put操作代碼:

image

其中第六行代碼是判斷是否出現hash碰撞,假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完第六行代碼後由於時間片耗盡導致被掛起,而線程B得到時間片後在該下標處插入了元素,完成了正常的插入,然後線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。

除此之前,還有就是代碼的第38行處有個++size,我們這樣想,還是線程A、B,這兩個線程同時進行put操作時,假設當前HashMap的zise大小爲10,當線程A執行到第38行代碼時,從主內存中獲得size的值爲10後準備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主內存中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主內存,然後線程A再次拿到CPU並繼續執行(此時size的值仍爲10),當執行完put操作後,還是將size=11寫回內存,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。

三、如何使HashMap在多線程情況下進行線程安全操作?

使用 Collections.synchronizedMap(map),包裝成同步Map,原理就是在HashMap的所有方法上synchronized。

例如:Collections.SynchronizedMap#get()

 
vbnet
複製代碼
public V get(Object key) {
    synchronized (mutex) {
        return m.get(key);
    }
}

四、總結

1、HashMap線程不安全原因:

原因:

  • JDK1.7 中,由於多線程對HashMap進行擴容,調用了HashMap#transfer(),具體原因:某個線程執行過程中,被掛起,其他線程已經完成數據遷移,等CPU資源釋放後被掛起的線程重新執行之前的邏輯,數據已經被改變,造成死循環、數據丟失。
  • JDK1.8 中,由於多線程對HashMap進行put操作,調用了HashMap#putVal(),具體原因:假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完第六行代碼後由於時間片耗盡導致被掛起,而線程B得到時間片後在該下標處插入了元素,完成了正常的插入,然後線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。

改善:

  • 數據丟失、死循環已經在在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到HashMap#transfer(),因爲JDK1.8直接在HashMap#resize()中完成了數據遷移。

2、HashMap線程不安全的體現:

  • JDK1.7 HashMap線程不安全體現在:死循環、數據丟失
  • JDK1.8 HashMap線程不安全體現在:數據覆蓋

五、參考

blog.csdn.net/swpu_ocean/… coolshell.cn/articles/96…


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