JAVA基礎——HashMap線程安全

接觸過HashMap的人應該對線程安全問題都不陌生,就算是沒踩過多線程下HashMap的坑,起碼在學習的過程中應該也聽說過是非線程安全的,幾乎你問每一個程序員hashmap是不是線程安全的,大家都會告訴不是的,那麼我來從個人層面探討一下看似人人都懂的線程安全問題。

首先,hashmap線程安全嗎?答案顯然是否定的,在java中如果你想要在多線程中安全的使用map的話,目前我所知道的有四種:

1.juc包下的ConcurrentHashMap,提到ConcurrentHashMap這裏又是涉及了許多思想,線程安全,數據結構等問題的一個map類,後面有機會的話會好好寫文章分析一下這個併發安全的map類。我相信如果有多線程編程經驗的同學應該也大多數是採用了ConcurrentHashMap。

2.調用Collections 工具包下的Collections.synchronizedMap()方法可以返回一個線程安全的map,點進源碼可以方法,這個方法實現線程安全的方法其實是在類內部維護了一個Object mutex,在所有對map的操作方法裏都加入了對mutex這個對象的同步處理進而保證線程安全。

3.使用hashtable。非常不推薦使用hashtable,原因很簡單,看看源碼你就會發現,這個類的實現非常簡單粗暴,就是把所有方法都加入了synchronized關鍵字保證線程安全的,在多線程下效率會受到較大影響。

4.當然了,我們也可以不依靠java內置的類,通過自己開發map類或者編寫線程安全的代碼去保證線程安全。

光知道怎麼解決還不夠,我們更多是應該關注於hashmap爲什麼不能線程安全,究竟是哪一部分導致了多線程下的安全問題。關於線程安全,一直可以說是程序員的痛點,不僅學習消耗精力,調試更是磨練心智,所以我認爲每一名程序員在學習和工作中都不能放過任何一個鍛鍊多線程的機會。

我先說說我對線程安全的理解,首先發生線程安全的前提條件,那肯定是多線程下,而安全的意思是,你所操作的數據不正確了,導致沒有返回正確的結果,然後你的數據再使用的話就不安全了。而之所以數據會不正確,原理也很簡單,就是多個線程同時修改了一個數據嘛,你先改我後改,可是我改之前拿的不是你改之後的數據,咱倆改完了都往原來的數據上覆蓋,必然有一個結果會被另一個結果覆蓋,順着這個思路我們可以想到,hashmap的線程安全肯定也是因爲存在這樣的情況。

  • 一.數據丟失

那麼說回hashmap,map中修改數據的方法那就很明顯,肯定是put方法,所以說線程安全的問題主要就體現再put方法上,我們來看一下put方法的源碼(jdk1.8):

其實都沒有必要一行一行的讀了,大致瀏覽一下就可以看出端倪。我們知道jdk8中hashmap的實現是數組+鏈表+紅黑樹,元素存放在數組中,發生哈希衝突時會在對應的數組位置形成鏈表,當鏈表長度達到8時就會轉化爲紅黑樹,這樣做是因爲鏈表的添加刪除效率高,但是查詢效率低,當鏈表過長時會影響查找效率。其實從上到下看看這個方法你就會發現根本沒有任何鎖機制存在,這其實就已經註定了線程非安全。

當向hashmap中put的時候其實都是在操作底層數組table,對於table對象沒有任何鎖的限制,而整個方法也沒有任何同步的限制存在,那麼在多線程的情況下很容易出現線程1調用了put方法計算出存放位置是index,而線程2此時進入,計算出存放位置也是index,然後put了元素b,此時線程1繼續工作,向index上put了元素a,但是剛剛線程2更新了index位置之後並沒有通知到其他線程,此時線程1拿到的還是未被線程2修改的table[index],那麼結果肯定就是線程2的更新丟失,線程非安全。這就是hashmap線程非安全的基本情況,但是我在以前學習的過程中查找網上的其他文章,很多文章一談到hashmap線程安全就說併發情況下鏈表成環的情況,而對其他情況沒做說明,其實我認爲這是不對的,數據更新丟失,鏈表成環和fail-fast都是hashmap線程非安全的體現。

  • 二.鏈表成環

關於鏈表成環,過程相對還是比較複雜和燒腦的,這裏有一篇文章推薦給大家,http://www.importnew.com/22011.html,圖畫的很詳細,解釋的很清楚,我就不重複造輪子了 ,如果理解了這篇文章,那麼下面的內容也就好理解了,如果鏈表成環還沒理解,建議自己動手畫個圖試試。值得注意的是,所謂的鏈表成環邏輯是根本原因是因爲put時hashmap採用尾插入,而在擴容時選擇了頭插入,而這一點已經在jdk8中得到了解決,我們來看一下源碼:

其實不難理解,首先假設一個元素a的hash值時101101,將a放入map時計算應該存放的位置時(101101)&(size - 1),size默認值爲16=>10000,所以相當於是101101 & 1111,相當於就是元素a的hash值對16取模,而擴容我們知道時擴容爲兩倍,即對32取模,對16取模的話,值範圍:0~15,對32取模的話,值範圍:0~31,在元素a的hash值不變的情況下其實元素a擴容前後的數組位置要麼不變,要麼時原位置+原來數組大小,不知道這個各位能不能理解。

舉個例子,7%16=7,7%32=7,19%16=3,19%32=19=3+16。

轉換成二進制我們看一下,101011 & 1111(16-1) = 1011,101011 & 11111(32-1) = 01011 = 1011,這種情況下屬於數組位置沒有改變,再來看一個例子,111011 & 11111(16-1) = 1011,111011 & 11111(32-1) = 11011 = 10000 + 1011,這種情況屬於原位置+ 原來數組長度。這樣應該就比較清晰了,其實原因就在於擴容成兩倍的話做與運算的位數就多了一位,從1111變成了11111,所以新的結果只需要看多出來的那一位是0還是1就可以了,101011,111011由於新計算位不同導致一個位置不動,一個向後移動原數組長度個位數。

回到代碼中e.hash & oldCap,oldCap就是原數組長度,不過這裏需要注意有一點不一樣,這裏是直接用了size,我們把上面的例子換一下101011 & 10000 = 00000,111011 & 10000 = 10000,然後判斷當結果爲0時,擴容後位置不變,否則向後挪動原數組長度個位置,其實本意都是一樣的。後面的代碼比較好理解了,就是把兩種不同位置的元素按照順序分別存到low和high兩個node節點中。理解了擴容機制後就會發現,按照這個實現算法,put和resize其實都是頭插入的操作,那麼前文所提到的鏈表成環的情況也就不存在了。

  • 三.fail-fast

fail-fast機制大家應該不陌生,這也不是map獨有的東西,就不過多介紹了,在其他的java容器類中都存在相似的機制,也就是在迭代的過程中,其本身不能被修改,否則會引發ConcurrentModificationException異常,這個也是hashmap不能多線程的原因之一,假如線程1正在循環put元素,而線程2從map中remove了一個元素,那麼線程1那邊的下一次迭代肯定就會拋出異常。

 

我個人認爲綜合這三點都是hashmap不能保證線程安全的原因,希望大家不要光記住循環鏈表這麼一點,畢竟這個已經在jdk8中優化了,而且jdk8的使用應該已經非常普遍了,不管公司的技術背景如何,作爲技術人,技術水平應該是跟時代看齊的。

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