ConcurrentHashMap是如何保證線程安全的

ConcurrentHashMap是如何保證線程安全的

文章已同步發(fā)表于微信公眾號JasonGaoH,ConcurrentHashMap是如何保證線程安全的

之前分析過HashMap的一些實現(xiàn)細節(jié),關于HashMap你需要知道的一些細節(jié), 今天我們從源碼角度來看看ConcurrentHashMap是如何實現(xiàn)線程安全的,其實網上這類文章分析特別多,秉著”紙上得來終覺淺,絕知此事要躬行“的原則,我們嘗試自己去分析下,希望這樣對于ConcurrentHashMap有一個更深刻的理解。

為什么說HashMap線程不安全,而ConcurrentHashMap就線程安全

其實ConcurrentHashMap在Android開發(fā)中使用的場景并不多,但是ConcurrentHashMap為了支持多線程并發(fā)這些優(yōu)秀的設計卻是最值得我們學習的地方,往往”ConcurrentHashMap是如何實現(xiàn)線程安全“這類問題卻是面試官比較喜歡問的問題。

首先,我們嘗試用代碼模擬下HashMap在多線程場景下會不安全,如果把這個場景替換成ConcurrentHashMap會不會有問題。

因為不同于其他的線程同步問題,想模擬出一種場景來表明HashMap是線程不安全的稍微有點麻煩,可能是hash散列有關,在數(shù)據(jù)量較小的情況下,計算出來的hashCode是不太容易產生碰撞的,網上很多文章都是嘗試從源碼角度來分析HashMap可能會導致的線程安全問題。

我們來看下下面這段代碼,我們構造10個線程,每個線程分別往map中put 1000個數(shù)據(jù),為了保證每個數(shù)據(jù)的key不一樣,我們將i+ 線程名字來作為map 的key,這樣,如果所有的線程都累加完的話,我們預期的map的size應該是10 * 1000 = 10000。


import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class HashMapTest {

    public static void main(String[] args) {
        Map<String, String> map = new HashMap<String, String>();
        
//       Map<String, String> map = new ConcurrentHashMap<String, String>();
        for (int i = 0; i < 10; i++) {
            MyThread testThread = new MyThread(map, "線程名字:" + i);
            testThread.start();
        }
        //等待所有線程都結束
        while(Thread.activeCount() > 1)
            Thread.yield();
        
        System.out.println(map.size());
    }
}


class MyThread extends Thread {
    public Map<String, String> map;
    public String name;

    public MyThread(Map<String, String> map, String name) {
      this.map = map;
      this.name = name;
    }
    public void run() {
        for(int i =0;i<1000;i++) {
            map.put(i + name, i + name);
        }
    }
  }


使用HashMap,程序運行,結果如下:

9930

那我們如果把這里的HashMap換成ConcurrentHashMap來試試看看效果如何,輸出結果如下:

10000

我們發(fā)現(xiàn)不管運行幾次,HashMap的size都是小于10000的,而ConcurrentHashMap的size都是10000。從這個角度也證明了ConcurrentHashMap是線程安全的,而HashMap則是線程不安全的。
HashMap在多線程put的時候,當產生hash碰撞的時候,會導致丟失數(shù)據(jù),因為要put的兩個值hash相同,如果這個對于hash桶的位置個數(shù)小于8,那么應該是以鏈表的形式存儲,由于沒有做通過,后面put的元素可能會直接覆蓋之前那個線程put的數(shù)據(jù),這樣就導致了數(shù)據(jù)丟失。

其實列舉上面這個例子只是為了從一個角度來展示下為什么說HashMap線程不安全,而ConcurrentHashMap則是線程安全的,鑒于HashMap線程安全例子比較難列舉出來,所有才通過打印size這個角度來模擬了下。

這篇文章深入解讀HashMap線程安全性問題就詳細介紹了HashMap可能會出現(xiàn)線程安全問題。
文章主要講了兩個可能會出現(xiàn)線程不安全地方,一個是多線程的put可能導致元素的丟失,另一個是put和get并發(fā)時,可能導致get為null,但是也僅是在源碼層面分析了下,因為這中場景想要完全用代碼展示出來是稍微有點麻煩的。

接下來我們來看看ConcurrentHashMap是如何做到線程安全的。

JDK8的ConcurrentHashMap文檔提煉

  • ConcurrentHashMap支持檢索的完全并發(fā)和更新的高預期并發(fā)性,這里的說法很有意思檢索支持完全并發(fā),更新則支持高預期并發(fā)性,因為它的檢索操作是沒有加鎖的,實際上檢索也沒有必要加鎖。
  • 實際上ConcurrentHashMap和Hashtable在不考慮實現(xiàn)細節(jié)來說,這兩者完全是可以互相操作的,Hashtable在get,put,remove等這些方法中全部加入了synchronized,這樣的問題是能夠實現(xiàn)線程安全,但是缺點是性能太差,幾乎所有的操作都加鎖的,但是ConcurrentHashMap的檢測操作卻是沒有加鎖的。
  • ConcurrentHashMap檢索操作(包括get)通常不會阻塞,因此可能與更新操作(包括put和remove)重疊。
  • ConcurrentHashMap跟Hashtable類似但不同于HashMap,它不可以存放空值,key和value都不可以為null。

印象中一直以為ConcurrentHashMap是基于Segment分段鎖來實現(xiàn)的,之前沒仔細看過源碼,一直有這么個錯誤的認識。ConcurrentHashMap是基于Segment分段鎖來實現(xiàn)的,這句話也不能說不對,加個前提條件就是正確的了,ConcurrentHashMap從JDK1.5開始隨java.util.concurrent包一起引入JDK中,在JDK8以前,ConcurrentHashMap都是基于Segment分段鎖來實現(xiàn)的,在JDK8以后,就換成synchronized和CAS這套實現(xiàn)機制了。

JDK1.8中的ConcurrentHashMap中仍然存在Segment這個類,而這個類的聲明則是為了兼容之前的版本序列化而存在的。

   /**
     * Stripped-down version of helper class used in previous version,
     * declared for the sake of serialization compatibility.
     */
    static class Segment<K,V> extends ReentrantLock implements Serializable {
        private static final long serialVersionUID = 2249069246763182397L;
        final float loadFactor;
        Segment(float lf) { this.loadFactor = lf; }
    }

JDK1.8中的ConcurrentHashMap不再使用Segment分段鎖,而是以table數(shù)組的頭結點作為synchronized的鎖。和JDK1.8中的HashMap類似,對于hashCode相同的時候,在Node節(jié)點的數(shù)量少于8個時,這時的Node存儲結構是鏈表形式,時間復雜度為O(N),當Node節(jié)點的個數(shù)超過8個時,則會轉換為紅黑樹,此時訪問的時間復雜度為O(long(N))。

 /**
     * The array of bins. Lazily initialized upon first insertion.
     * Size is always a power of two. Accessed directly by iterators.
     */
    transient volatile Node<K,V>[] table;

數(shù)據(jù)結構圖如下所示:


在這里插入圖片描述

其實ConcurrentHashMap保證線程安全主要有三個地方。

  • 一、使用volatile保證當Node中的值變化時對于其他線程是可見的
  • 二、使用table數(shù)組的頭結點作為synchronized的鎖來保證寫操作的安全
  • 三、當頭結點為null時,使用CAS操作來保證數(shù)據(jù)能正確的寫入。

使用volatile

可以看到,Node中的val和next都被volatile關鍵字修飾。

volatile的happens-before規(guī)則:對一個volatile變量的寫一定可見(happens-before)于隨后對它的讀。

也就是說,我們改動val的值或者next的值對于其他線程是可見的,因為volatile關鍵字,會在讀指令前插入讀屏障,可以讓高速緩存中的數(shù)據(jù)失效,重新從主內存加載數(shù)據(jù)。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
  }
  ...

另外,ConcurrentHashMap提供類似tabAt來讀取Table數(shù)組中的元素,這里是以volatile讀的方式讀取table數(shù)組中的元素,主要通過Unsafe這個類來實現(xiàn)的,保證其他線程改變了這個數(shù)組中的值的情況下,在當前線程get的時候能拿到。

 static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }

而與之對應的,是setTabAt,這里是以volatile寫的方式往數(shù)組寫入元素,這樣能保證修改后能對其他線程可見。

 static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }

我們來看下ConcurrentHashMap的putVal方法:

  /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //當頭結點為null,則通過casTabAt方式寫入
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
              //正在擴容
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //頭結點不為null,使用synchronized加鎖
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            //此時hash桶是鏈表結構
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            //此時是紅黑樹
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {
                    //當鏈表結構大于等于8,則將鏈表轉換為紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                  return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

在putVal方法重要的地方都加了注釋,可以幫助理解,現(xiàn)在我們一步一步來看putVal方法。

使用CAS

當有一個新的值需要put到ConcurrentHashMap中時,首先會遍歷ConcurrentHashMap的table數(shù)組,然后根據(jù)key的hashCode來定位到需要將這個value放到數(shù)組的哪個位置。

tabAt(tab, i = (n - 1) & hash))就是定位到這個數(shù)組的位置,如果當前這個位置的Node為null,則通過CAS方式的方法寫入。所謂的CAS,即即compareAndSwap,執(zhí)行CAS操作的時候,將內存位置的值與預期原值比較,如果相匹配,那么處理器會自動將該位置值更新為新值,否則,處理器不做任何操作。

這里就是調用casTabAt方法來實現(xiàn)的。

     static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }

casTabAt同樣是通過調用Unsafe類來實現(xiàn)的,調用Unsafe的compareAndSwapObject來實現(xiàn),其實如果仔細去追蹤這條線路,會發(fā)現(xiàn)其實最終調用的是cmpxchg這個CPU指令來實現(xiàn)的,這是一個CPU的原子指令,能保證數(shù)據(jù)的一致性問題。

使用synchronized

當頭結點不為null時,則使用該頭結點加鎖,這樣就能多線程去put hashCode相同的時候不會出現(xiàn)數(shù)據(jù)丟失的問題。synchronized是互斥鎖,有且只有一個線程能夠拿到這個鎖,從而保證了put操作是線程安全的。

下面是ConcurrentHashMap的put操作的示意圖,圖片來自于ConcurrentHashMap源碼分析(JDK8)get/put/remove方法分析

concurrenthashmap_put.jpg

參考文章

從ConcurrentHashMap的演進看Java多線程核心技術

ConcurrentHashMap源碼分析(JDK8)get/put/remove方法分析

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容