ConcurrentHashMap簡介(jdk1.7)

seeAll發表於2024-03-13

一、前言

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操作成功,如下圖所示