一、前言
ConcurrentHashMap相比於HashMap是執行緒安全的,大概可以理解為:插入元素一定會成功,刪除元素也一定會成功,查詢元素也不會出問題。
二、資料結構
如上圖所示,有如下資料元素
1,segments陣列
Segment物件陣列,預設長度16,可以指定長度,最大長度限制為65536,指定長度後不能修改。
2,sync鎖
來自於Segment物件繼承的父類ReentrantLock物件中的元素,為非公平鎖。
3,table陣列
和HashMap中桶陣列類似,每個桶位儲存一條元素連結串列;table陣列長度可以擴容。
三、使用原理
1,概述
ConcurrentHashMap物件中持有一個Segment物件的陣列,每一個Segment物件中又持有一個節點(連結串列)物件的陣列,節點物件為一個單向連結串列的頭部元素,插入ConcurrentHashMap的元素就儲存在這些連結串列中。向ConcurrentHashMap中put()元素時,先在segments陣列中找對應Segment物件的位置,再在table陣列中找到對應連結串列的位置,最後將元素和該連結串列進行連結。ConcurrentHashMap相比於HashMap,“類似於”將一個HashMap物件分裂為多個HashMap物件,每個物件又被Segment物件重新包裝,分佈儲存於segments陣列中。這樣做的目的是為了執行緒安全,HashTable是在每個方法上加了synchronized,等於鎖了整張表,ConcurrentHashMap只鎖了Segment物件。比如一家早餐店,有5個顧客,HashTable等於只開了一個視窗,但是ConcurrentHashMap開了多個視窗,同樣是執行緒安全,效率不一樣。
2,put()方法流程
2.1,初始化鎖
構造ConcurrentHashMap物件時,會初始化segments陣列,並初始化陣列的第一個元素segments[0];
構建Segment物件時,會先執行父類的構造方法,初始化鎖物件;
segments陣列其他位置的初始化,是在需要向該位置插入元素時進行;
2.2,獲取鎖
2.2.1,整體流程
2.2.2,tryLock()
a,CAS簡介
CAS:compare and swap;
CAS(0, 1),0是“期望值”,1是“替換值”;
CAS的當前值:以此處為例,ConcurrentHashMap物件中有某一個Segment物件,Segment物件初始化(執行構造方法)時又先初始化了父類ReentrantLock物件,構造了一個NonfairSync物件,NonfairSync物件初始化時又初始化了父類AbstractQueuedSynchronizer物件,即最終每一個Segment物件都對應含有一個AbstractQueuedSynchronizer物件,AbstractQueuedSynchronizer物件則儲存了“當前值”;
使用時,如果“當前值”和“期望值”一致,都是0,則修改“當前值”為“替換值”1,並返回true;否則什麼也不幹,返回false;
網上說CAS是一個原子操作,即讀取“當前值”、比較“當前值”和“期望值”、替換“當前值”為“替換值”打包成了一個原子操作。參考:【多執行緒】cas_多核cpu cas-CSDN部落格;裡面有說CAS底層是一條cpu指令,即lock cmpxchg,單核的時候自然在一個時刻只能執行一個指令,多核也沒問題,因為cpu對lock指令有特殊處理,會鎖住匯流排。
b,tryLock()方法簡介
採用了非公平的方式獲取鎖,如上圖
1,獲取鎖的狀態state,state=0表示空閒,state=其他表示佔用;
2,如果state=0,則自己CAS;CAS成功了,則佔有鎖,返回true;CAS失敗了,返回false;
3,如果state != 0,則判斷是否是自己之前佔用了鎖,即判斷重入的情況;是重入的話,設定重入次數,也返回true;不是重入的話,返回false。
c,公平鎖和非公平鎖
公平鎖:獲取不到鎖就排隊,阻塞等待;
非公平鎖:獲取不到鎖就拉倒;可透過不停的去獲取,以達到目的;
公平鎖的吞吐量不行,說是因為多個執行緒排隊,加重了作業系統對執行緒的排程壓力;非公平鎖沒這個問題,不需要排隊,就執行緒自個兒管好自個兒就行了。
2.2.3,scanAndLockForPut()
先使用“非公平鎖”的方式,透過while迴圈,不停地重試去tryLock()獲取鎖;超過重試次數後,使用“公平鎖”的方式,先排隊,阻塞在這兒等著,直到獲取鎖,在往下執行。
2.3,執行業務程式碼
經過上面的步驟,獲取到鎖後,表示現在只有自己能操作這個Segment物件,保證了執行緒安全。
2.4,釋放鎖
如上圖所示,釋放鎖時,Segment物件的CAS的“當前值”減1。
對於重入的情況,每次重入都加1,但是每次退出的時候也都會減1。
最終釋放鎖後,Segment物件的CAS的“當前值”變成0。
3,remove()方法流程
如上圖,刪除元素時使用了和插入元素時一樣的鎖,所以remove()和put()的所有操作不分你我,都是有序進行的。
4,get()方法流程
4.1,概述
先查到Segment物件,再查到連結串列物件,最後在連結串列中找到元素。
4.2,再說put()方法
如上圖所示
a,先找到桶位元素,也就是連結串列的頭部元素;
b,將頭部元素掛到新元素的下面,也就是把整條連結串列掛到新元素下面;
c,修改桶位元素(頭部元素)為新元素;
即”頭插法“,那麼get()的時候,不管遍歷尋找到連結串列上的哪個元素了,都不影響,因為查詢是向下找的,新元素都在最上面。
4.3,再說remove()方法
如上圖所示,刪除分為兩種情況
刪除前:
4.3.1,待刪除的元素是頭部元素
比如:待刪除的元素:“春”。
刪除後:
可見,連結串列的結構並沒有改變;比如get()方法走到“春”了,它還能向下找到“花”、找到“秋”;
get()方法走到“春”了,因為get()方法的變數指向“春”,“春”不會被jvm垃圾回收;走到“花”了,就和“春”沒有關係了,“春”被jvm垃圾回收也沒關係了。
4.3.2,待刪除的元素是中間元素
比如:待刪除的元素:“花”。
刪除後:
比如get()方法走到“春”了,如果get()要查詢的是“花”,那麼返回null,沒查到;也能理解,畢竟你查的時候,待查的元素能被另一個執行緒刪除,也是ConcurrentHashMap的優秀功能之一;
get()方法走到“花”了,如果get()要查詢的是“花”,那麼返回“花”;也能理解,雖然“花”已經不在ConcurrentHashMap中了,但是你查的時候“花”是在的,就好像我打電話給小王,問她在哪兒,她說在家,我就去她家找她,結果我到了她卻不在了,我不能怪她,因為我沒讓她等我;
4.4,再說table陣列的擴容方法
如上圖,擴容的邏輯和HashMap(jdk1.7)是一樣的,可參考HashMap執行緒不安全例項(jdk1.7) - seeAll - 部落格園 (cnblogs.com)。
擴容的時候先建立一個新陣列,轉移元素時,也是根據舊的元素new出來一個新元素物件,然後將新物件放進新陣列中。
簡單示意一下:
擴容前:
假如“春”和“秋”需要轉移,那麼效果如下
第一次for迴圈:,
第二次for迴圈:
第三次for迴圈:
第四次for迴圈:
for迴圈結束,執行table=newTable:
分析:
get()方法對方法的區域性變數tab賦值後,tab可能指向老陣列,也可能指向新陣列,但是賦值後,這個get()方法對應的棧裡的tab變數指向的堆裡的物件就不會變了,要麼是老陣列物件,要麼是新陣列物件,且擴容的時候,沒有動一點老陣列物件,且新陣列物件也是在完全構造完成後才賦值給Segment物件的table變數的,不存在指向半成品的情況;
情況1:
指向老陣列,老陣列物件一點沒變,且因為有這個tab變數指向著,沒有被jvm垃圾回收,查詢不受影響;
情況2:
指向新陣列,tab.length也跟著一起變了,其他兩個變數TSHIFT、TBASE至始至終都沒有變化過,所以也能根據待查詢元素key的hash值找到其新位置,則查詢也不受影響。
四、總結
1,概述
put()和remove()都使用了同樣的鎖,所有的操作(包括put()和remove())混合在一起都能有序進行,互不影響;
get()方法上面也分析了,不會對ConcurrentHashMap本身結構產生影響,在get()的同時,進行put()、remove()、擴容都沒關係;只是可能會產生“明明裡面有,為什麼沒查到”、“明明刪除了,為什麼還能查到”的誤解,上面也都解釋了;比如“明明裡面有,為什麼沒查到”,你查的時候,元素被其他人刪除了,自然查不到,除非沒被別人刪除也查不到才有問題;比如“明明刪除了,為什麼還能查到”,你查的時候確實是有的,只是後來被其他人刪除了,除非是你刪除的你再查還能查到才有問題。
2,ConcurrentHashMap的應用場景
多執行緒操作Map結構的資料時,都可以使用ConcurrentHashMap。
3,ConcurrentHashMap的注意點
ConcurrentHashMap本身沒什麼問題,重要的是在業務中使用的時候,思考ConcurrentHashMap能否滿足業務需要,會不會產生偏差。
4,CAS的ABA問題?
網上大部分的解釋是,比如當前值是10,然後有兩個執行緒操作如下:
物件1;
物件2;
物件3;
執行緒1{
CAS(物件1, 物件2);
CAS(物件2, 物件1);
}
執行緒2{
CAS(物件1, 物件3);
}
執行緒1先執行,物件1->物件2->物件1;執行緒2後執行,物件1->物件3;兩個執行緒都能執行成功,然後說執行緒2有問題,他以為的當前值物件1已經不是一開始的物件1了,是物件1->物件2->物件1。
有點百思不得其解,這有什麼問題,是執行緒1是程式設計師A寫的,執行緒2是程式設計師B寫的,兩個人有矛盾?
並且jdk中有專門處理這個問題的類AtomicStampedReference,連jdk都加了版本號來方便程式設計師識別ABA問題,如下程式碼所示
public static void main(String[] args) throws InterruptedException { // 初始值 final String originalStr = "10"; // 初始版本號 final int originalStamp = 3; final AtomicStampedReference<String> atomicStampedReference = new AtomicStampedReference<>(originalStr, originalStamp); final CountDownLatch countDownLatch = new CountDownLatch(2); final CountDownLatch waitThread1 = new CountDownLatch(1); Thread thread1 = new Thread(new Runnable() { @Override public void run() { String str2 = "9999"; boolean aaa; // CAS操作,值由(originalStr = "10")變成"9999",版本號由(originalStamp = 3)變成4 aaa = atomicStampedReference.compareAndSet(originalStr, str2, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1); System.out.println("執行緒1:CAS是否成功" + aaa + ", 當前值:" + atomicStampedReference.getReference()); // CAS操作,值由"9999"變成(originalStr = "10"),版本號由4變成5 aaa = atomicStampedReference.compareAndSet(str2, originalStr, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1); System.out.println("執行緒1:CAS是否成功" + aaa + ", 當前值:" + atomicStampedReference.getReference()); countDownLatch.countDown(); waitThread1.countDown(); } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { try { waitThread1.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } boolean aaa; aaa = atomicStampedReference.compareAndSet(originalStr, "33", originalStamp, atomicStampedReference.getStamp() + 1); System.out.println("執行緒2:CAS是否成功" + aaa + ", 當前值:" + atomicStampedReference.getReference()); countDownLatch.countDown(); } }); thread1.start(); thread2.start(); countDownLatch.await(); System.out.println("當前值:" + atomicStampedReference.getReference()); }
執行以上程式碼後,執行緒2的CAS操作失敗,沒有更新值,因為實際版本號和自己期望的版本號不一致,如下圖所示
將程式碼中執行緒2的CAS的期望版本號改成5後,執行緒2的CAS操作成功,如下圖所示