天天看點

Java8開始ConcurrentHashMap,為什麼舍棄分段鎖概述棄用原因新的同步方案

概述

我們知道, 在 Java 5 之後,JDK 引入了 java.util.concurrent 并發包 ,其中最常用的就是 ConcurrentHashMap 了, 它的原理是引用了内部的 Segment ( ReentrantLock )  分段鎖,保證在操作不同段 map 的時候, 可以并發執行, 操作同段 map 的時候,進行鎖的競争和等待。進而達到線程安全, 且效率大于 synchronized。

但是在 Java 8 之後, JDK 卻棄用了這個政策,重新使用了 synchronized+cas。

棄用原因

通過  JDK 的源碼和官方文檔看來, 他們認為的棄用分段鎖的原因由以下幾點:

  1. 加入多個分段鎖浪費記憶體空間。
  2. 生産環境中, map 在放入時競争同一個鎖的機率非常小,分段鎖反而會造成更新等操作的長時間等待。
  3. 為了提高 GC 的效率

新的同步方案

既然棄用了分段鎖, 那麼一定由新的線程安全方案, 我們來看看源碼是怎麼解決線程安全的呢?(源碼保留了segment 代碼, 但并沒有使用)

put

首先通過 hash 找到對應連結清單過後, 檢視是否是第一個object, 如果是, 直接用cas原則插入,無需加鎖。

Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
    tab = initTable(); // 這裡在整個map第一次操作時,初始化hash桶, 也就是一個table
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果是第一個object, 則直接cas放入, 不用鎖
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
        break;                   
}
           

然後, 如果不是連結清單第一個object, 則直接用連結清單第一個object加鎖,這裡加的鎖是synchronized,雖然效率不如 ReentrantLock, 但節約了空間,這裡會一直用第一個object為鎖, 直到重新計算map大小, 比如擴容或者操作了第一個object為止。

synchronized (f) {// 這裡的f即為第一個連結清單的object
    if (tabAt(tab, i) == f) {
        if (fh >= 0) {
            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);
                    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");
    }
}
           

分段鎖技術是在java8以前使用的,在java8已經棄用了,更新為synchronized+cas

ConcurrentHashMap(JDK1.8)為什麼要使用synchronized而不是如ReentranLock這樣的可重入鎖?

我想從下面幾個角度讨論這個問題:

鎖的粒度

首先鎖的粒度并沒有變粗,甚至變得更細了。每當擴容一次,ConcurrentHashMap的并發度就擴大一倍。

Hash沖突

JDK1.7中,ConcurrentHashMap從過二次hash的方式(Segment -> HashEntry)能夠快速的找到查找的元素。在1.8中通過連結清單加紅黑樹的形式彌補了put、get時的性能差距。

擴容

JDK1.8中,在ConcurrentHashmap進行擴容時,其他線程可以通過檢測數組中的節點決定是否對這條連結清單(紅黑樹)進行擴容,減小了擴容的粒度,提高了擴容的效率。

下面是我對面試中的那個問題的一下看法:

為什麼是synchronized,而不是ReentranLock

1. 減少記憶體開銷

假設使用可重入鎖來獲得同步支援,那麼每個節點都需要通過繼承AQS來獲得同步支援。但并不是每個節點都需要獲得同步支援的,隻有連結清單的頭節點(紅黑樹的根節點)需要同步,這無疑帶來了巨大記憶體浪費。

2. 獲得JVM的支援

可重入鎖畢竟是API這個級别的,後續的性能優化空間很小。

synchronized則是JVM直接支援的,JVM能夠在運作時作出相應的優化措施:鎖粗化、鎖消除、鎖自旋等等。這就使得synchronized能夠随着JDK版本的更新而不改動代碼的前提下獲得性能上的提升。