Java中ConcurrentHashMap底層實現原理(JDK1.8)源碼分析

Concurrent翻譯過來是併發的意思,字面理解它的作用是處理併發情況的 HashMap,在介紹它之前先回顧下之前的知識。

一、JDK1.7與JDK1.8區別

在JDK1.6,JDK1.7中,HashMap採用位桶+鏈表實現,即使用鏈表處理衝突,同一hash值的鏈表都存儲在一個鏈表裏。但是當位於一個桶中的元素較多,即hash值相等的元素較多時,通過key值依次查找的效率較低。而JDK1.8中,HashMap採用位桶+鏈表+紅黑樹實現,當鏈表長度超過閾值(8)時,將鏈表轉換爲紅黑樹,當鏈表長度小於閾值(6)時候將回到鏈表結構,紅黑樹查找高校這樣大大減少了查找時間。

JDK1.8的實現

  1. 取消segments字段,直接採用transient volatile HashEntry<K,V>[] table保存數據,採用table數組元素作爲鎖,從而實現了對每一行數據進行加鎖,進一步減少併發衝突的概率。
  2. 將原先table數組+單向鏈表的數據結構,變更爲table數組+單向鏈表+紅黑樹的結構。對於hash表來說,最核心的能力在於將key hash之後能均勻的分佈在數組中。如果hash之後散列的很均勻,那麼table數組中的每個隊列長度主要爲0或者1。但實際情況並非總是如此理想,雖然ConcurrentHashMap類默認的加載因子爲0.75,但是在數據量過大或者運氣不佳的情況下,還是會存在一些隊列長度過長的情況,如果還是採用單向列表方式,那麼查詢某個節點的時間複雜度爲O(n);因此,對於個數超過8(默認值)的列表,jdk1.8中採用了紅黑樹的結構,那麼查詢的時間複雜度可以降低到O(logN),可以改進性能。
  3. JDK1.8的實現已經摒棄了Segment的概念,而是直接用Node數組+鏈表+紅黑樹的數據結構來實現,併發控制使用Synchronized和CAS來操作,整個看起來就像是優化過且線程安全的HashMap,雖然在JDK1.8中還能看到Segment的數據結構,但是已經簡化了屬性,只是爲了兼容舊版本

put源碼

  1. 如果沒有初始化就先調用initTable()方法來進行初始化過程
  2. 如果沒有hash衝突就直接CAS插入
  3. 如果還在進行擴容操作就先進行擴容
  4. 如果存在hash衝突,就加鎖來保證線程安全,這裏有兩種情況,一種是鏈表形式就直接遍歷到尾端插入,一種是紅黑樹就按照紅黑樹結構插入
  5. 最後一個如果該鏈表的數量大於閾值8,就要先轉換成黑紅樹的結構,break再一次進入循環(阿里面試官問題,默認的鏈表大小,超過了這個值就會轉換爲紅黑樹);
  6. 如果添加成功就調用addCount()方法統計size,並且檢查是否需要擴容
  7. 併發的時候,會有一個鎖升級過程

鎖升級

鎖的4中狀態:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態(級別從低到高)

(1)偏向鎖:

爲什麼要引入偏向鎖?

因爲經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競爭的,常常是一個線程多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,爲了降低獲取鎖的代價,才引入的偏向鎖。

偏向鎖的升級

當線程1訪問代碼塊並獲取鎖對象時,會在java對象頭和棧幀中記錄偏向的鎖的threadID,因爲偏向鎖不會主動釋放鎖,因此以後線程1再次獲取鎖的時候,需要比較當前線程的threadID和Java對象頭中的threadID是否一致,如果一致(還是線程1獲取鎖對象),則無需使用CAS來加鎖、解鎖;如果不一致(其他線程,如線程2要競爭鎖對象,而偏向鎖不會主動釋放因此還是存儲的線程1的threadID),那麼需要查看Java對象頭中記錄的線程1是否存活,如果沒有存活,那麼鎖對象被重置爲無鎖狀態,其它線程(線程2)可以競爭將其設置爲偏向鎖;如果存活,那麼立刻查找該線程(線程1)的棧幀信息,如果還是需要繼續持有這個鎖對象,那麼暫停當前線程1,撤銷偏向鎖,升級爲輕量級鎖,如果線程1 不再使用該鎖對象,那麼將鎖對象狀態設爲無鎖狀態,重新偏向新的線程。

偏向鎖的取消:

偏向鎖是默認開啓的,而且開始時間一般是比應用程序啓動慢幾秒,如果不想有這個延遲,那麼可以使用-XX:BiasedLockingStartUpDelay=0;

如果不想要偏向鎖,那麼可以通過-XX:-UseBiasedLocking = false來設置;

(2)輕量級鎖

爲什麼要引入輕量級鎖?

輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因爲阻塞線程需要CPU從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就乾脆不阻塞這個線程,讓它自旋這等待鎖釋放。

輕量級鎖什麼時候升級爲重量級鎖?

線程1獲取輕量級鎖時會先把鎖對象的對象頭MarkWord複製一份到線程1的棧幀中創建的用於存儲鎖記錄的空間(稱爲DisplacedMarkWord),然後使用CAS把對象頭中的內容替換爲線程1存儲的鎖記錄(DisplacedMarkWord)的地址;

如果在線程1複製對象頭的同時(在線程1CAS之前),線程2也準備獲取鎖,複製了對象頭到線程2的鎖記錄空間中,但是在線程2CAS的時候,發現線程1已經把對象頭換了,線程2的CAS失敗,那麼線程2就嘗試使用自旋鎖來等待線程1釋放鎖。

但是如果自旋的時間太長也不行,因爲自旋是要消耗CPU的,因此自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了線程1還沒有釋放鎖,或者線程1還在執行,線程2還在自旋等待,這時又有一個線程3過來競爭這個鎖對象,那麼這個時候輕量級鎖就會膨脹爲重量級鎖。重量級鎖把除了擁有鎖的線程都阻塞,防止CPU空轉。

 

*注意:爲了避免無用的自旋,輕量級鎖一旦膨脹爲重量級鎖就不會再降級爲輕量級鎖了;偏向鎖升級爲輕量級鎖也不能再降級爲偏向鎖。一句話就是鎖可以升級不可以降級,但是偏向鎖狀態可以被重置爲無鎖狀態。

(3)這幾種鎖的優缺點(偏向鎖、輕量級鎖、重量級鎖)

2、鎖粗化
按理來說,同步塊的作用範圍應該儘可能小,僅在共享數據的實際作用域中才進行同步,這樣做的目的是爲了使需要同步的操作數量儘可能縮小,縮短阻塞時間,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。 
但是加鎖解鎖也需要消耗資源,如果存在一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗。 
鎖粗化就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖,避免頻繁的加鎖解鎖操作。

3、鎖消除
Java虛擬機在JIT編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,經過逃逸分析,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間
 

讀寫分離思想

put加鎖、get不加鎖

爲什麼是synchronized,而不是可重入鎖

1. 減少內存開銷
假設使用可重入鎖來獲得同步支持,那麼每個節點都需要通過繼承AQS來獲得同步支持。但並不是每個節點都需要獲得同步支持的,只有鏈表的頭節點(紅黑樹的根節點)需要同步,這無疑帶來了巨大內存浪費。
2. 獲得JVM的支持
可重入鎖畢竟是API這個級別的,後續的性能優化空間很小。
synchronized則是JVM直接支持的,JVM能夠在運行時作出相應的優化措施:鎖粗化、鎖消除、鎖自旋等等。這就使得synchronized能夠隨着JDK版本的升級而不改動代碼的前提下獲得性能上的提升。
 

最後對ConcurrentHashMap 來個大總結:
1、get方法不加鎖;
2、put、remove方法要使用鎖
jdk7使用鎖分離機制(Segment分段加鎖)
jdk8使用cas + synchronized 實現鎖操作
3、Iterator對象的使用,運行一邊更新,一遍遍歷(可以根據原理自己拓展)
4、複合操作,無法保證線程安全,需要額外加鎖保證
5、併發環境下,ConcurrentHashMap 效率較Collections.synchronizedMap()更高

 

 

 

 

 

 

 

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