JDK1.7和JDK1.8中HashMap是线程不安全的,并发容器ConcurrentHashMap模型
一、HashMap是線程不安全的
前言只要是對于集合有一定了解的一定都知道HashMap是線程不安全的,我們應該使用ConcurrentHashMap。但是為什么HashMap是線程不安全的呢,之前面試的時候也遇到到這樣的問題,但是當時只停留在***知道是***的層面上,并沒有深入理解***為什么是***。于是今天重溫一個HashMap線程不安全的這個問題。
首先需要強調一點,HashMap的線程不安全體現在會造成死循環、數據丟失、數據覆蓋這些問題。其中死循環和數據丟失是在JDK1.7中出現的問題,在JDK1.8中已經得到解決,然而1.8中仍會有數據覆蓋這樣的問題。
擴容引發的線程不安全
HashMap的線程不安全主要是發生在擴容函數中,即根源是在transfer函數中,JDK1.7中HashMap的transfer函數如下:
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;}}}- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
這段代碼是HashMap的擴容操作,重新定位每個桶的下標,并采用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點。理解了頭插法后再繼續往下看是如何造成死循環以及數據丟失的。
擴容造成死循環和數據丟失的分析過程
假設現在有兩個線程A、B同時對下面這個HashMap進行擴容操作:
正常擴容后的結果是下面這樣的:
但是當線程A執行到上面transfer函數的第11行代碼時,CPU時間片耗盡,線程A被掛起。即如下圖中位置所示:
此時線程A中:e=3、next=7、e.next=null
當線程A的時間片耗盡后,CPU開始執行線程B,并在線程B中成功的完成了數據遷移
重點來了,根據Java內存模式可知,線程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采用頭插法的方式放入新數組中,并繼續執行完此輪循環,結果如下:
執行下一次循環可以發現,next=e.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在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。
JDK1.8中的線程不安全
根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到transfer函數,因為JDK1.8直接在resize函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。
為什么說JDK1.8會出現數據覆蓋的情況喃,我們來看一下下面這段JDK1.8中的put操作代碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null) // 如果沒有hash碰撞則直接插入元素tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
其中第六行代碼是判斷是否出現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的線程不安全主要體現在下面兩個方面:
**1.在JDK1.7中,當并發執行擴容操作時會造成環形鏈和數據丟失的情況。**
**2.在JDK1.8中,在并發執行put操作時會發生數據覆蓋的情況。**
二、并發容器ConcurrentHashMap——JDK1.7與JDK1.8區別
在Java常用的容器HashMap存在著線程不安全的問題,其中JDK1.7與JDK1.8的線程不安全會出現不同的情況:在多線程情況下,JDK1.7在HashMap在擴容時會造成環形;在JDK1.8中可能會發生數據覆蓋。
1、JDK1.7下的ConcurrentHashMap
ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment實際繼承自可重入鎖(ReentrantLock),在ConcurrentHashMap里扮演鎖的角色;HashEntry則用于存儲鍵值對數據。一個ConcurrentHashMap里包含一個Segment數組,每個Segment里包含一個HashEntry數組,我們稱之為table,每個HashEntry是一個鏈表結構的元素。
1.1 初始化
初始化有三個參數
initialCapacity:初始容量大小 ,默認16。
loadFactor, 擴容因子,默認0.75,當一個Segment存儲的元素數量大于initialCapacity* loadFactor時,該Segment會進行一次擴容。
concurrencyLevel 并發度,默認16。并發度可以理解為程序運行時能夠同時更新ConccurentHashMap且不產生鎖競爭的最大線程數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的數組長度。如果并發度設置的過小,會帶來嚴重的鎖競爭問題;如果并發度設置的過大,原本位于同一個Segment內的訪問會擴散到不同的Segment中,CPU cache命中率會下降,從而引起程序性能下降。
構造方法:
保證Segment數組的大小,一定為2的冪,例如用戶設置并發度為17,則實際Segment數組大小則為32。
保證每個Segment中tabel數組的大小,一定為2的冪,初始化的三個參數取默認值時,table數組大小為2
初始化Segment數組,并實際只填充Segment數組的第0個元素
用于定位元素所在segment。segmentShift表示偏移位數,通過前面的int類型的位的描述我們可以得知,int類型的數字在變大的過程中,低位總是比高位先填滿的,為保證元素在segment級別分布的盡量均勻,計算元素所在segment時,總是取hash值的高位進行計算。segmentMask作用就是為了利用位運算中取模的操作:a % (Math.pow(2,n)) 等價于 a&( Math.pow(2,n)-1)。
1.2 如何實現高并發下的線程安全
ConcurrentHashMap允許多個修改操作并發進行,其關鍵在于使用了鎖分離技術。它使用了多個鎖來控制對hash表的不同部分進行的修改。內部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的hash table,只要多個修改操作發生在不同的段上,它們就可以并發進行。
1.3 如何快速定位元素
對于某個元素而言,一定是放在某個segment元素的某個table元素中的。
定位segment:取得key的hashcode值進行一次再散列(通過Wang/Jenkins算法),拿到再散列值后,以再散列值的高位進行取模得到當前元素在哪個segment上。
定位table:同樣是取得key的再散列值以后,用再散列值的全部和table的長度進行取模,得到當前元素在table的哪個元素上。
1.4 get方法
定位segment和定位table后,依次掃描這個table元素下的的鏈表,要么找到元素,要么返回null。
在高并發下的情況下如何保證取得的元素是最新的?
用于存儲鍵值對數據的HashEntry,在設計上它的成員變量value等都是volatile類型的,這樣就保證別的線程對value值的修改,get方法可以馬上看到。
1.5 put方法
- 首先定位segment,當這個segment在map初始化后,還為null,由ensureSegment方法負責填充這個segment。
- 對Segment?加鎖
- 定位所在的table元素,并掃描table下的鏈表,找到時:
沒有找到時:
?
1.6 擴容操作
假設原來table長度為4,那么元素在table中的分布是這樣的:
| Hash值 | 15 | 23 | 34 | 56 | 77 |
| 在table中下標 | 3? = 15%4 | 3 = 23 % 4 | 2 = 34%4 | 0 = 56%4 | 1 = 77 % 4 |
擴容后table長度變為8,那么元素在table中的分布變成:
| Hash值 | 56 | ? | 34 | ? | ? | 77 | ? | 15,23 |
| 下標 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
可以看見 hash值為34和56的下標保持不變,而15,23,77的下標都是在原來下標的基礎上+4即可,可以快速定位和減少重排次數。
1.7 弱一致性
get方法和containsKey方法都是通過對鏈表遍歷判斷是否存在key相同的節點以及獲得該節點的value。但由于遍歷過程中其他線程可能對鏈表結構做了調整,因此get和containsKey返回的可能是過時的數據,這一點是ConcurrentHashMap在弱一致性上的體現。
2、JDK1.8下的ConcurrentHashMap
2.1 與JDK1.7相比的主要變化
- 取消了segment數組,直接用table數組保存數據,鎖的粒度更小,減少并發沖突的概率。
- 存儲數據時采用了鏈表+紅黑樹的形式,純鏈表的形式時間復雜度為O(n),紅黑樹則為O(logn),性能提升很大。什么時候鏈表轉紅黑樹?當key值相等的元素形成的鏈表中元素個數超過8個的時候;當紅黑樹元素個數小于6個時會褪化為鏈表。
2.2 主要數據結構和關鍵變量
Node類:存放實際的key和value值
sizeCtl:
- 負數:表示進行初始化或者擴容,-1表示正在初始化,-N,表示有N-1個線程正在進行擴容
- 正數:0 表示還沒有被初始化,>0的數,初始化或者是下一次進行擴容的閾值
TreeNode:用在紅黑樹,表示樹的節點。
TreeBin:實際放在table數組中的,代表了這個紅黑樹的根
2.3 初始化
只是給成員變量賦值,put時進行實際數組的填充
2.4 定位元素
2.5 get方法
2.6 put方法
2.7 弱一致性
與JDK1.7一樣還是存在弱一致性
總結
以上是生活随笔為你收集整理的JDK1.7和JDK1.8中HashMap是线程不安全的,并发容器ConcurrentHashMap模型的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: centos 没有nmtui命令_Lin
- 下一篇: windows 10字体突然变小变细,模