1. 程式人生 > >面試必問之 ConcurrentHashMap 執行緒安全的具體實現方式

面試必問之 ConcurrentHashMap 執行緒安全的具體實現方式

作者:炸雞可樂
原文出處:www.pzblog.cn

一、摘要

在之前的集合文章中,我們瞭解到 HashMap 在多執行緒環境下操作可能會導致程式死迴圈的線上故障!

既然在多執行緒環境下不能使用 HashMap,那如果我們想在多執行緒環境下操作 map,該怎麼操作呢?

想必閱讀過小編之前寫的《HashMap 在多執行緒環境下操作可能會導致程式死迴圈》一文的朋友們一定知道,其中有一個解決辦法就是使用 java 併發包下的 ConcurrentHashMap 類!

今天呢,我們就一起來聊聊 ConcurrentHashMap 這個類!

二、簡介

眾所周知,在 Java 中,HashMap 是非執行緒安全的,如果想在多執行緒下安全的操作 map,主要有以下解決方法:

  • 第一種方法,使用Hashtable執行緒安全類;
  • 第二種方法,使用Collections.synchronizedMap方法,對方法進行加同步鎖;
  • 第三種方法,使用併發包中的ConcurrentHashMap類;

在之前的文章中,關於 Hashtable 類,我們也有所介紹,Hashtable 是一個執行緒安全的類,Hashtable 幾乎所有的新增、刪除、查詢方法都加了synchronized同步鎖!

相當於給整個雜湊表加了一把大鎖,多執行緒訪問時候,只要有一個執行緒訪問或操作該物件,那其他執行緒只能阻塞等待需要的鎖被釋放,在競爭激烈的多執行緒場景中效能就會非常差,所以 Hashtable 不推薦使用!

再來看看第二種方法,使用Collections.synchronizedMap方法,我們開啟 JDK 原始碼,部分內容如下:

可以很清晰的看到,如果傳入的是 HashMap 物件,其實也是對 HashMap 做的方法做了一層包裝,裡面使用物件鎖來保證多執行緒場景下,操作安全,本質也是對 HashMap 進行全表鎖!

使用Collections.synchronizedMap方法,在競爭激烈的多執行緒環境下效能依然也非常差,所以不推薦使用!

上面2種方法,由於都是對方法進行全表鎖,所以在多執行緒環境下容易造成效能差的問題,因為** hashMap 是陣列 + 連結串列的資料結構,如果我們把陣列進行分割多段,對每一段分別設計一把同步鎖,這樣在多執行緒訪問不同段的資料時,就不會存在鎖競爭了,這樣是不是可以有效的提高效能?**

再來看看第三種方法,使用併發包中的ConcurrentHashMap類!

ConcurrentHashMap 類所採用的正是分段鎖的思想,將 HashMap 進行切割,把 HashMap 中的雜湊陣列切分成小陣列,每個小陣列有 n 個 HashEntry 組成,其中小陣列繼承自ReentrantLock(可重入鎖),這個小陣列名叫Segment, 如下圖:

當然,JDK1.7 和 JDK1.8 對 ConcurrentHashMap 的實現有很大的不同!

JDK1.8 對 HashMap 做了改造,當衝突連結串列長度大於8時,會將連結串列轉變成紅黑樹結構,上圖是 ConcurrentHashMap 的整體結構,參考 JDK1.7!

我們再來看看 JDK1.8 中 ConcurrentHashMap 的整體結構,內容如下:

JDK1.8 中 ConcurrentHashMap 類取消了 Segment 分段鎖,採用 CAS + synchronized 來保證併發安全,資料結構跟 jdk1.8 中 HashMap 結構類似,都是陣列 + 連結串列(當連結串列長度大於8時,連結串列結構轉為紅黑二叉樹)結構。

ConcurrentHashMap 中 synchronized 只鎖定當前連結串列或紅黑二叉樹的首節點,只要節點 hash 不衝突,就不會產生併發,相比 JDK1.7 的 ConcurrentHashMap 效率又提升了 N 倍!

說了這麼多,我們再一起來看看 ConcurrentHashMap 的原始碼實現。

三、JDK1.7 中的 ConcurrentHashMap

JDK 1.7 的 ConcurrentHashMap 採用了非常精妙的分段鎖策略,開啟原始碼,可以看到 ConcurrentHashMap 的主存是一個 Segment 陣列。

我們再來看看 Segment 這個類,在 ConcurrentHashMap 中它是一個靜態內部類,內部結構跟 HashMap 差不多,原始碼如下:

存放元素的 HashEntry,也是一個靜態內部類,原始碼如下:

HashEntryHashMap中的 Entry非常類似,唯一的區別就是其中的核心資料如value ,以及next都使用了volatile關鍵字修飾,保證了多執行緒環境下資料獲取時的可見性!

從類的定義上可以看到,Segment 這個靜態內部類繼承了ReentrantLock類,ReentrantLock是一個可重入鎖,如果瞭解過多執行緒的朋友們,對它一定不陌生。

ReentrantLocksynchronized都可以實現對執行緒進行加鎖,不同點是:ReentrantLock可以指定鎖是公平鎖還是非公平鎖,操作上也更加靈活,關於此類,具體在以後的多執行緒篇幅中會單獨介紹。

因為ConcurrentHashMap的大體儲存結構和HashMap類似,所以就不對每個方法進行單獨分析介紹了,關於HashMap的分析,有興趣的朋友可以參閱小編之前寫的《深入分析 HashMap》一文。

ConcurrentHashMap 在儲存方面是一個 Segment 陣列,一個 Segment 就是一個子雜湊表,Segment 裡維護了一個 HashEntry 陣列,其中 Segment 繼承自 ReentrantLock,併發環境下,對於不同的 Segment 資料進行操作是不用考慮鎖競爭的,因此不會像 Hashtable 那樣不管是新增、刪除、查詢操作都需要同步處理。

理論上 ConcurrentHashMap 支援 concurrentLevel(通過 Segment 陣列長度計算得來) 個執行緒併發操作,每當一個執行緒獨佔一把鎖訪問 Segment 時,不會影響到其他的 Segment 操作,效率大大提升!

上面介紹完了物件屬性,我們繼續來看看 ConcurrentHashMap 的構造方法,原始碼如下:

this呼叫對應的構造方法,原始碼如下:

從原始碼上可以看出,ConcurrentHashMap 初始化方法有三個引數,initialCapacity(初始化容量)為16、loadFactor(負載因子)為0.75、concurrentLevel(併發等級)為16,如果不指定則會使用預設值。

其中,值得注意的是 concurrentLevel 這個引數,雖然 Segment 陣列大小 ssize 是由 concurrentLevel 來決定的,但是卻不一定等於 concurrentLevel,ssize 通過位移動運算,一定是大於或者等於 concurrentLevel 的最小的 2 的次冪!

通過計算可以看出,按預設的 initialCapacity 初始容量為16,concurrentLevel 併發等級為16,理論上就允許 16 個執行緒併發執行,並且每一個執行緒獨佔一把鎖訪問 Segment,不影響其它的 Segment 操作!

從之前的文章中,我們瞭解到 HashMap 在多執行緒環境下操作可能會導致程式死迴圈,仔細想想你會發現,造成這個問題無非是 put 和擴容階段發生的!

那麼這樣我們就可以從 put 方法下手了,來看看 ConcurrentHashMap 是怎麼操作的?

3.1、put 操作

ConcurrentHashMap 的 put 方法,原始碼如下:

從原始碼可以看出,這部分的 put 操作主要分兩步:

  • 定位 Segment 並確保定位的 Segment 已初始化;
  • 呼叫 Segment 的 put 方法;

真正插入元素的 put 方法,原始碼如下:

從原始碼可以看出,真正的 put 操作主要分以下幾步:

  • 第一步,嘗試獲取物件鎖,如果獲取到返回true,否則執行scanAndLockForPut方法,這個方法也是嘗試獲取物件鎖;
  • 第二步,獲取到鎖之後,類似 hashMap 的 put 方法,通過 key 計算所在 HashEntry 陣列的下標;
  • 第三步,獲取到陣列下標之後遍歷連結串列內容,通過 key 和 hash 值判斷是否 key 已存在,如果已經存在,通過識別符號判斷是否覆蓋,預設覆蓋;
  • 第四步,如果不存在,採用頭插法插入到 HashEntry 物件中;
  • 第五步,最後操作完整之後,釋放物件鎖;

我們再來看看,上面提到的scanAndLockForPut這個方法,原始碼如下:

scanAndLockForPut這個方法,操作也是分以下幾步:

  • 當前執行緒嘗試去獲得鎖,查詢 key 是否已經存在,如果不存在,就建立一個HashEntry 物件;
  • 如果重試次數大於最大次數,就呼叫lock()方法獲取物件鎖,如果依然沒有獲取到,當前執行緒就阻塞,直到獲取之後退出迴圈;
  • 在這個過程中,key 可能被別的執行緒給插入,所以在第5步中,如果 HashEntry 儲存內容發生變化,重置重試次數;

通過scanAndLockForPut()方法,當前執行緒就可以在即使獲取不到segment鎖的情況下,完成需要新增節點的例項化工作,當獲取鎖後,就可以直接將該節點插入連結串列即可。

這個方法還實現了類似於自旋鎖的功能,迴圈式的判斷物件鎖是否能夠被成功獲取,直到獲取到鎖才會退出迴圈,防止執行 put 操作的執行緒頻繁阻塞,這些優化都提升了 put 操作的效能。

3.2、get 操作

get 方法就比較簡單了,因為不涉及增、刪、改操作,所以不存在併發故障問題,原始碼如下:

由於 HashEntry 涉及到的共享變數都使用 volatile 修飾,volatile 可以保證記憶體可見性,所以不會讀取到過期資料。

3.3、remove 操作

remove 操作和 put 方法差不多,都需要獲取物件鎖才能操作,通過 key 找到元素所在的 Segment 物件然後移除,原始碼如下:

與 get 方法類似,先獲取 Segment 陣列所在的 Segment 物件,然後通過 Segment 物件去移除元素,原始碼如下:

先獲取物件鎖,如果獲取到之後執行移除操作,之後的操作類似 hashMap 的移除方法,步驟如下:

  • 先獲取物件鎖;
  • 計算key的hash值在HashEntry[]中的角標;
  • 根據index角標獲取HashEntry物件;
  • 迴圈遍歷HashEntry物件,HashEntry為單向連結串列結構;
  • 通過key和hash判斷key是否存在,如果存在,就移除元素,並將需要移除的元素節點的下一個,向上移;
  • 最後就是釋放物件鎖,以便其他執行緒使用;

四、JDK1.8 中的 ConcurrentHashMap

雖然 JDK1.7 中的 ConcurrentHashMap 解決了 HashMap 併發的安全性,但是當衝突的連結串列過長時,在查詢遍歷的時候依然很慢!

在 JDK1.8 中,HashMap 引入了紅黑二叉樹設計,當衝突的連結串列長度大於8時,會將連結串列轉化成紅黑二叉樹結構,紅黑二叉樹又被稱為平衡二叉樹,在查詢效率方面,又大大的提高了不少。

因為 HashMap 並不支援在多執行緒環境下使用, JDK1.8 中的ConcurrentHashMap 和往期 JDK 中的 ConcurrentHashMa 一樣支援併發操作,整體結構和 JDK1.8 中的 HashMap 類似,相比 JDK1.7 中的 ConcurrentHashMap, 它拋棄了原有的 Segment 分段鎖實現,採用了 CAS + synchronized 來保證併發的安全性。

JDK1.8 中的 ConcurrentHashMap 對節點Node類中的共享變數,和 JDK1.7 一樣,使用volatile關鍵字,保證多執行緒操作時,變數的可見行!

其他的細節,與 JDK1.8 中的 HashMap 類似,我們來具體看看 put 方法!

4.1、put 操作

開啟 JDK1.8 中的 ConcurrentHashMap 中的 put 方法,原始碼如下:

當進行 put 操作時,流程大概可以分如下幾個步驟:

  • 首先會判斷 key、value是否為空,如果為空就拋異常!
  • 接著會判斷容器陣列是否為空,如果為空就初始化陣列;
  • 進一步判斷,要插入的元素f,在當前陣列下標是否第一次插入,如果是就通過 CAS 方式插入;
  • 在接著判斷f.hash == -1是否成立,如果成立,說明當前fForwardingNode節點,表示有其它執行緒正在擴容,則一起進行擴容操作;
  • 其他的情況,就是把新的Node節點按連結串列或紅黑樹的方式插入到合適的位置;
  • 節點插入完成之後,接著判斷連結串列長度是否超過8,如果超過8個,就將連結串列轉化為紅黑樹結構;
  • 最後,插入完成之後,進行擴容判斷;

put 操作大致的流程,就是這樣的,可以看的出,複雜程度比 JDK1.7 上了一個臺階。

4.1.1、initTable 初始化陣列

我們再來看看原始碼中的第3步 initTable()方法,如果陣列為空就初始化陣列,原始碼如下:

sizeCtl 是一個物件屬性,使用了volatile關鍵字修飾保證併發的可見性,預設為 0,當第一次執行 put 操作時,通過Unsafe.compareAndSwapInt()方法,俗稱CAS,將 sizeCtl修改為 -1,有且只有一個執行緒能夠修改成功,接著執行 table 初始化任務。

如果別的執行緒發現sizeCtl<0,意味著有另外的執行緒執行CAS操作成功,當前執行緒通過執行Thread.yield()讓出 CPU 時間片等待 table 初始化完成。

4.1.2、helpTransfer 幫組擴容

我們繼續來看看 put 方法中第5步helpTransfer()方法,如果f.hash == -1成立,說明當前fForwardingNode節點,意味有其它執行緒正在擴容,則一起進行擴容操作,原始碼如下:

這個過程,操作步驟如下:

  • 第1步,對 table、node 節點、node 節點的 nextTable,進行資料校驗;
  • 第2步,根據陣列的length得到一個識別符號號;
  • 第3步,進一步校驗 nextTab、tab、sizeCtl 值,如果 nextTab 沒有被併發修改並且 tab 也沒有被併發修改,同時 sizeCtl < 0,說明還在擴容;
  • 第4步,對 sizeCtl 引數值進行分析判斷,如果不滿足任何一個判斷,將sizeCtl + 1, 增加了一個執行緒幫助其擴容;
4.1.3、addCount 擴容判斷

我們再來看看原始碼中的第9步 addCount()方法,插入完成之後,擴容判斷,原始碼如下:

這個過程,操作步驟如下:

  • 第1步,利用CAS將方法更新baseCount的值
  • 第2步,檢查是否需要擴容,預設check = 1,需要檢查;
  • 第3步,如果滿足擴容條件,判斷當前是否正在擴容,如果是正在擴容就一起擴容;
  • 第4步,如果不在擴容,將sizeCtl更新為負數,並進行擴容處理;

put 的流程基本分析完了,可以從中發現,裡面大量的使用了CAS方法,CAS 表示比較與替換,裡面有3個引數,分別是目標記憶體地址、舊值、新值,每次判斷的時候,會將舊值與目標記憶體地址中的值進行比較,如果相等,就將新值更新到記憶體地址裡,如果不相等,就繼續迴圈,直到操作成功為止!

雖然使用的了CAS這種樂觀鎖方法,但是裡面的細節設計的很複雜,閱讀比較費神,有興趣的朋友們可以自己研究一下。

4.2、get 操作

get 方法操作就比較簡單了,因為不涉及併發操作,直接查詢就可以了,原始碼如下:

從原始碼中可以看出,步驟如下:

  • 第1步,判斷陣列是否為空,通過key定位到陣列下標是否為空;
  • 第2步,判斷node節點第一個元素是不是要找到,如果是直接返回;
  • 第3步,如果是紅黑樹結構,就從紅黑樹裡面查詢;
  • 第4步,如果是連結串列結構,迴圈遍歷判斷;

4.3、reomve 操作

reomve 方法操作和 put 類似,只是方向是反的,原始碼如下:

replaceNode 方法,原始碼如下:

從原始碼中可以看出,步驟如下:

  • 第1步,迴圈遍歷陣列,接著校驗引數;
  • 第2步,判斷是否有別的執行緒正在擴容,如果是一起擴容;
  • 第3步,用 synchronized 同步鎖,保證併發時元素移除安全;
  • 第4步,因為 check= -1,所以不會進行擴容操作,利用CAS操作修改baseCount值;

五、總結

雖然 HashMap 在多執行緒環境下操作不安全,但是在 java.util.concurrent 包下,java 為我們提供了 ConcurrentHashMap 類,保證在多執行緒下 HashMap 操作安全!

在 JDK1.7 中,ConcurrentHashMap 採用了分段鎖策略,將一個 HashMap 切割成 Segment 陣列,其中 Segment 可以看成一個 HashMap, 不同點是 Segment 繼承自 ReentrantLock,在操作的時候給 Segment 賦予了一個物件鎖,從而保證多執行緒環境下併發操作安全。

但是 JDK1.7 中,HashMap 容易因為衝突連結串列過長,造成查詢效率低,所以在 JDK1.8 中,HashMap 引入了紅黑樹特性,當衝突連結串列長度大於8時,會將連結串列轉化成紅黑二叉樹結構。

在 JDK1.8 中,與此對應的 ConcurrentHashMap 也是採用了與 HashMap 類似的儲存結構,但是 JDK1.8 中 ConcurrentHashMap 並沒有採用分段鎖的策略,而是在元素的節點上採用 CAS + synchronized 操作來保證併發的安全性,原始碼的實現比 JDK1.7 要複雜的多。

本文因為是斷斷續續寫出來,如果有理解不對的地方,歡迎各位網友指出!

六、參考

1、JDK1.7&JDK1.8 原始碼

2、JavaGuide - 容器 - ConcurrentHashMap

3、部落格園 -dreamcatcher-cx - ConcurrentHashMap1.7實現原理及原始碼分析

4、簡書 -佔小狼 - 深入淺出ConcurrentHashMap