Hashtable, Collections.SynchronizedMap和ConcurrentHashMap線程安全實現原理的區別以及性能測試

Hashtable,Collections.SynchronizedMap和ConcurrentHashMap線程安全實現原理的區別以及性能測試

這三種 Map 都是 Java 中比較重要的集合類,雖然前兩個不太常用,但是因爲與多線程相關,所以關於這幾種 Map 的對比已經成爲了 Java 面試時的高頻考點。首先要說明的是,其中每一個單獨拎出來都足夠支撐一篇長篇大論的技術文章,所以本文把重點放在了這三種集合類的線程安全實現原理的對比以及性能測試上,其他細節不做深入探討。

一、線程安全原理對比

1. Hashtable

首先必須吐槽一下這個類名,作爲官方工具類竟然不符合駝峯命名規則,怪不得被棄用了,開玩笑哈哈,主要原因還是性能低下,那 Hashtable 的性能爲什麼低下呢,這個嘛只需要看一下它的源碼就一目瞭然了,以下是 Hashtable 中幾個比較重要的方法:

public synchronized V put(K key, V value) {...}
public synchronized V get(Object key) {...}
public synchronized int size() {...}
public synchronized boolean remove(Object key, Object value) {...}
public synchronized boolean contains(Object value) {...}
... ...

查看源碼後可以看出,Hashtable 實現線程安全的原理相當簡單粗暴,直接在方法聲明上使用 synchronized 關鍵字。這樣一來,不管線程執行哪個方法,即便只是讀取數據,都需要鎖住整個 Hashtable 對象,可想而知其併發性能必然不會太好。

2. Collections.SynchronizedMap

SynchronizedMapCollections 集合類的私有靜態內部類,其定義和構造方法如下:

private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
        private static final long serialVersionUID = 1978198479659022715L;
    	  // 用於接收傳入的Map對象,也是類方法操作的對象
        private final Map<K,V> m;     
    	  // 鎖對象
        final Object mutex;   
  
  			// 以下是SynchronizedMap的兩個構造方法
        SynchronizedMap(Map<K,V> m) {
            this.m = Objects.requireNonNull(m);
            mutex = this;
        }
        SynchronizedMap(Map<K,V> m, Object mutex) {
            this.m = m;
            this.mutex = mutex;
        }
}
  • SynchronizedMap 一共有三個成員變量,序列化ID拋開不談,另外兩個分別是 Map 類型的實例變量 m,用於接收構造方法中傳入的 Map 參數,以及 Object 類型的實例變量 mutex,作爲鎖對象使用。

  • 再來看構造方法,SynchronizedMap 有兩個構造方法。第一個構造方法需要傳入一個 Map 類型的參數,這個參數會被傳遞給成員變量 m,接下來 SynchronizedMap 所有方法的操作都是針對 m 的操作,需要注意的是這個參數不能爲空,否則會由 Objects 類的 requireNonNull() 方法拋出空指針異常,然後當前的 SynchronizedMap 對象 this 會被傳遞給 mutex 作爲鎖對象;第二個構造方法有兩個參數,第一個 Map 類型的參數會被傳遞給成員變量 m,第二個 Object 類型的參數會被傳遞給 mutex 作爲鎖對象。

  • 最後來看 SynchronizedMap 的部分主要方法:

    public int size() {
        synchronized (mutex) {return m.size();}
    }
    public boolean isEmpty() {
        synchronized (mutex) {return m.isEmpty();}
    }
    public boolean containsKey(Object key) {
        synchronized (mutex) {return m.containsKey(key);}
    }
    public V get(Object key) {
        synchronized (mutex) {return m.get(key);}
    }
    public V put(K key, V value) {
        synchronized (mutex) {return m.put(key, value);}
    }
    public V remove(Object key) {
        synchronized (mutex) {return m.remove(key);}
    }
    

從源碼可以看出,SynchronizedMap 實現線程安全的方法也是比較簡單的,所有方法都是先對鎖對象 mutex 上鎖,然後再直接調用 Map 類型成員變量 m 的相關方法。這樣一來,線程在執行方法時,只有先獲得了 mutex 的鎖才能對 m 進行操作。因此,跟 Hashtable 一樣,在同一個時間點,只能有一個線程對 SynchronizedMap 對象進行操作,雖然保證了線程安全,卻導致了性能低下。這麼看來,連 Hashtable 都被棄用了,那性能同樣低下的 SynchronizedMap 還有什麼存在的必要呢?別忘了,後者的構造方法需要傳入一個 Map 類型的參數,也就是說它可以將非線程安全的 Map 轉化爲線程安全的 Map,而這正是其存在的意義,以下是 SynchronizedMap 的用法示例 (這裏並沒有演示多線程操作):

Map<String, Integer> map = new HashMap<>();
//非線程安全操作
map.put("one", 1);
Integer one = map.get("one");
Map<String, Integer> synchronizedMap = Collections.synchronizedMap(map);
//線程安全操作
one = synchronizedMap.get("one");
synchronizedMap.put("two", 2);
Integer two = synchronizedMap.get("two");

3. ConcurrentHashMap

接下來是數據結構和線程安全原理都最複雜的 ConcurrentHashMap。首先必須要感嘆一下,這個類的結構之複雜(包含53個內部類),設計之精妙(不知道怎麼形容,反正就是很精妙),真是令人歎爲觀止。說實話,要想徹底理解 ConcurrentHashMap 的各個細節,還是需要相當紮實的基礎並花費大量精力的。本文對於 ConcurrentHashMap 線程安全的原理只是做了宏觀的介紹,想要深入理解的同學,想要深入理解的同學,可以去文末的傳送門。另外,本文着重介紹 JDK 1.8 版本的 ConcurrentHashMap,不過會對 JDK 1.7 版本做個簡單的回顧。

3.1 JDK 1.7 ConcurrentHashMap鎖實現原理回顧

JDK 1.7 ConcurrentHashMap 結構示意圖

Java 7 ConcurrentHashMap 結構示意圖

如果你有一定基礎的話,應該會知道分段鎖這個概念。沒錯,這是JDK 1.7版本的 ConcurrentHashMap 實現線程安全的主要手段,具體一點就是 Segment + HashEntry + ReentrantLock。簡單來說,ConcurrentHashMap 是一個 Segment 數組(數組默認長度爲16),每個 Segment 又包含了一個 HashEntry 數組,所以可以看做一個 HashMapSegment 通過繼承 ReentrantLock 來進行加鎖,所以每次需要加鎖的操作鎖住的是一個 Segment,這樣只要保證每個 Segment 是線程安全的,也就實現了全局的線程安全。

3.2 JDK 1.8 ConcurrentHashMap 線程安全原理詳解

Java 8 ConcurrentHashMap 結構示意圖

Java 8 ConcurrentHashMap 結構示意圖

JDK 1.8 版本摒棄了之前版本中較爲臃腫的 Segment 分段鎖設計,取而代之的是 Node 數組 + CAS + synchronized + volatile 的新設計。這樣一來,ConcurrentHashMap 不僅數據結構變得更簡單了(與JDK 1.8 的HashMap類似),鎖的粒度也更小了,鎖的單位從 Segment 變成了 Node 數組中的桶(科普:桶就是指數組中某個下標位置上的數據集合,這裏可能是鏈表,也可能是紅黑樹)。說到紅黑樹,必須提一下,在JDK 1.8 的 HashMapConcurrentHashMap 中,如果某個數組位置上的鏈表長度過長(大於等於8),就會轉化爲紅黑樹以提高查詢效率,不過這不是本文的重點。以下是 ConcurrentHashMap 線程安全原理的詳細介紹:

3.2.1 get 操作過程

​ 可以發現發現源碼中完全沒有加鎖的操作,後面會說明原因

  1. 首先計算hash值,定位到該table索引位置,如果是首節點符合就返回
  2. 如果遇到擴容的時候,會調用標誌正在擴容節點ForwardingNode的find方法,查找該節點,匹配就返回
  3. 以上都不符合的話,就往下遍歷節點,匹配就返回,否則最後就返回null
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode()); //計算hash
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {//讀取頭節點的Node元素
        if ((eh = e.hash) == h) { //如果該節點就是首節點就返回
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //hash值爲負值表示正在擴容,這個時候查的是ForwardingNode的find方法來定位到nextTable來
        //eh代表頭節點的hash值,eh=-1,說明該節點是一個ForwardingNode,正在遷移,此時調用ForwardingNode的find方法去nextTable裏找。
        //eh=-2,說明該節點是一個TreeBin,此時調用TreeBin的find方法遍歷紅黑樹,由於紅黑樹有可能正在旋轉變色,所以find裏會有讀寫鎖。
        //eh>=0,說明該節點下掛的是一個鏈表,直接遍歷該鏈表即可。
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {//既不是首節點也不是ForwardingNode,那就往下遍歷
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

可能有同學會提出疑問:爲什麼 get 操作不需要加鎖呢?這個嘛,也需要看一下源碼:

/**
 * The array of bins. Lazily initialized upon first insertion.
 * Size is always a power of two. Accessed directly by iterators.
 * 這是ConcurrentHashMap的成員變量,用volatile修飾的Node數組,保證了數組在擴容時對其他線程的可見性
 * 另外需要注意的是,這個數組是延遲初始化的,會在第一次put元素時進行初始化,後面還會用到這個知識點
 */
transient volatile Node<K,V>[] table;

/**
 * 這是ConcurrentHashMap靜態內部類Node的定義,可見其成員變量val和next都使用volatile修飾,可保證
 * 在多線程環境下線程A修改結點的val或者新增節點的時候是對線程B可見的
 */
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
}

使用 volatile 關鍵字已經足以保證線程在讀取數據時不會讀取到髒數據,所以沒有加鎖的必要。

3.2.2 put 操作過程
  1. 第一次 put 元素會初始化 Node 數組 (initTable)
  2. put 操作又分爲 key (hash 碰撞) 存在時的插入和 key 不存在時的插入
  3. put 操作可能會引發數組擴容 (tryPresize) 和鏈表轉紅黑樹 (treeifyBin)
  4. 擴容會使用到數據遷移方法 (transfer)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 得到 hash 值
    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)
            // 表的初始化,這裏不展開了,核心思想是使用sizeCtl的變量和CAS操作進行控制,保證數組在擴容時
            // 不會創建出多餘的表
            tab = initTable();
        // 找該 hash 值對應的數組下標,得到第一個節點 f
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 如果數組該位置爲空,用一次 CAS 操作將這個新值放入其中即可,這個 put 操作差不多就結束了
            // 如果 CAS 失敗,那就是有併發操作,進到下一個循環就好了(循環的意思是 CAS 在執行失敗後會進行			// 重試)
            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 { // 到這裏就是說,f 是該位置的頭結點,而且不爲空
            V oldVal = null;
            // 獲取數組該位置的頭結點鎖對象
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 頭結點的 hash 值大於 0,說明是鏈表
                        // 用於累加,記錄鏈表的長度
                        binCount = 1;
                        // 遍歷鏈表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 如果發現了"相等"的 key,判斷是否要進行值覆蓋,然後也就可以 break 了
                            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;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 判斷是否要將鏈表轉換爲紅黑樹,臨界值和 HashMap 一樣,也是 8
                if (binCount >= TREEIFY_THRESHOLD)
                    // 這個方法和 HashMap 中稍微有一點點不同,那就是它不是一定會進行紅黑樹轉換,
                    // 如果當前數組的長度小於 64,那麼會選擇進行數組擴容,而不是轉換爲紅黑樹
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

以上是 put 方法的源碼和分析,其中涉及到的其他方法,比如 initTablehelpTransfertreeifyBintryPresize 等方法不再一一展開,有興趣的同學可以去文末傳送門看詳細解析。

3.2.3 CAS 操作簡要介紹

CAS 操作是新版本 ConcurrentHashMap 線程安全實現原理的精華所在,如果說其共享變量的讀取全靠 volatile 實現線程安全的話,那麼存儲和修改過程除了使用少量的 synchronized 關鍵字外,主要是靠 CAS 操作實現線程安全的。不瞭解 CAS 操作的同學看這裏 JAVA CAS原理深度分析

// CAS操作的提供者
private static final sun.misc.Unsafe U;

// 以下是put方法裏用到CAS操作的代碼片段
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null,
                 new Node<K,V>(hash, key, value, null)))
        break;
}

// tabAt方法通過Unsafe.getObjectVolatile()的方式獲取數組對應index上的元素,getObjectVolatile作用於對
// 應的內存偏移量上,是具備volatile內存語義的。
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);
}
// 如果獲取的是空,嘗試用CAS的方式在數組的指定index上創建一個新的Node。
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);
}

ConcurrentHashMap 中,數組初始化、插入刪除元素、擴容、數據遷移以及鏈表和紅黑樹的轉換等過程都會涉及到線程安全問題,而相關的方法中實現線程安全的思想是一致的:對桶中的數據進行添加或修改操作時會用到 synchronized 關鍵字,也就是獲得該位置上頭節點對象的鎖,保證線程安全,另外就是用到了大量的 CAS 操作。以上就是對這三種 Map 的線程安全原理的簡要介紹。

二、性能測試

直接上代碼

public class MapPerformanceTest {
    private static final int THREAD_POOL_SIZE = 5;
    private static Map<String, Integer> hashtableObject = null;
    private static Map<String, Integer> concurrentHashMapObject = null;
    private static Map<String, Integer> synchronizedMap = null;

    private static void performanceTest(final Map<String, Integer> map) throws InterruptedException {
        System.out.println(map.getClass().getSimpleName() + "性能測試開始了... ...");
        long totalTime = 0;
        // 進行五次性能測試,每次開啓五個線程,每個線程對 map 進行500000次查詢操作和500000次插入操作
        for (int i = 0; i < 5; i++) {
            long startTime = System.nanoTime();
            ExecutorService service = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
            for (int j = 0; j < THREAD_POOL_SIZE; j++) {
                service.execute(() -> {
                    for (int k = 0; k < 500000; k++) {
                        Integer randomNumber = (int)Math.ceil(Math.random() * 500000);
                        // 從map中查找數據,查找結果並不會用到,這裏不能用int接收返回值,因爲Integer可能是
                        // null,賦值給int會引發空指針異常
                        Integer value = map.get(String.valueOf(randomNumber));
                        //向map中添加元素
                        map.put(String.valueOf(randomNumber), randomNumber);
                    }
                });
            }
            //關閉線程池
            service.shutdown();
            service.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);

            long endTime = System.nanoTime();
            // 單次執行時間
            long singleTime = (endTime - startTime) / 1000000;
            System.out.println("第" + (i + 1) + "次測試耗時" + singleTime + "ms...");
            totalTime += singleTime;
        }
        System.out.println("平均耗時" + totalTime / 5 + "ms...");
    }

    public static void main(String[] args) throws InterruptedException {
        //Hashtable性能測試
        hashtableObject = new Hashtable<>();
        performanceTest(hashtableObject);

        //SynchronizedMap性能測試
        Map<String, Integer> map = new HashMap<>(500000);
        synchronizedMap = Collections.synchronizedMap(map);
        performanceTest(synchronizedMap);

        //ConcurrentHashMap性能測試
        concurrentHashMapObject = new ConcurrentHashMap<>(5000000);
        performanceTest(concurrentHashMapObject);
    }
}

在這裏說明一點,這段代碼在不同環境下運行的結果會存在差別,但是結果的數量級對比應該是一致的,以下是我機子上的運行結果:
Map 性能測試結果

Map 性能測試結果,不知道怎麼調整圖片大小,汗…

從運行結果可以看出,在250萬這個數量級別的數據存取上,HashtableSynchronizedMap 的性能表現幾乎一致,畢竟它們的鎖實現原理大同小異,而 ConcurrentHashMap 表現出了比較大的性能優勢,耗時只有前兩者的三分之一多一點兒。嗯… 以後面試再被問到相關問題,可以直接把數據甩給面試官… …

三、結語

好了,以上就是本篇文章的全部內容了,完結撒花,第一次寫博客,竟然嘮叨了這麼多,不足之處還請各位看官老爺不吝賜教。另外想要進一步深入瞭解 ConcurrentHashMap 原理的朋友可以看一下下面兩篇文章,是我看過的講的比較詳細的。

解讀Java8中ConcurrentHashMap是如何保證線程安全的

Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析

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