【原創】Java併發編程系列26 | ConcurrentHashMap(上)

【原創】Java併發編程系列26 | ConcurrentHashMap(上)

收錄於話題
#進階架構師 | 併發編程專題
12個

點擊上方“java進階架構師”,選擇右上角“置頂公衆號”
20大進階架構專題每日送達

【原創】Java併發編程系列26 | ConcurrentHashMap(上)
【原創】Java併發編程系列26 | ConcurrentHashMap(上)
終於輪到ConcurrentHashMap了,併發編程必備,也是面試必備。先說明兩點:
本篇文章篇幅較長,考慮到閱讀體驗,分爲上下兩篇;
所有源碼基於 JDK1.8。
本篇是ConcurrentHashMap上篇,主要介紹HashMap:
爲什麼先講 HashMap
HashMap 數據結構
put()方法
get()方法
擴容
面試必備細節










1. 爲什麼講 HashMap?


本來是講解ConcurrentHashMap的文章,爲什麼要單獨一篇介紹 HashMap 呢?
我們先來弄清楚爲什麼需要用到ConcurrentHashMap。HashMap 作爲使用最頻繁的集合之一,在多線程環境下是不能用的,因爲 HashMap 的設計上就沒有考慮併發環境,極易導致線程安全問題。爲了解決該問題,提供了 Hashtable 和 Collections.synchronizedMap(hashMap)兩種解決方案,但是這兩種方案都是對讀寫加獨佔鎖,一個線程在讀時其他線程必須等待,吞吐量和性能都較低。故而 Doug Lea 大神給我們提供了高性能的線程安全 HashMap:ConcurrentHashMap。
所以,ConcurrentHashMap是爲了解決 HashMap 的線程安全問題的,我們要先了解 HashMap 到底有什麼問題才能理解ConcurrentHashMap是如何解決這些問題的。此外,ConcurrentHashMap和HashMap有相同的數據結構,在理解HashMap的基礎上學習ConcurrentHashMap就可以把重點放在解決併發問題上。

2. 數據結構


HashMap 採用“數組+鏈表+紅黑樹”的數據結構,如下圖:
【原創】Java併發編程系列26 | ConcurrentHashMap(上)
對應源碼:

Node<K,V>[] table;// table數組存儲結點

/**
 * 結點
 */
Node {
    int hash;
    K key;
    V value;
    Node<K,V> next;
}

3. put()方法


數組下標沒有對應 hash 值,直接 newNode()添加
數組下標有對應 hash 值,添加到鏈表最後
鏈表超過最大長度(8),將鏈表改爲紅黑樹再添加元素
結點在 table 數組中的位置計算:table[(length - 1) & hash] 。


public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
        boolean evict) {
     Node<K,V>[] tab; Node<K,V> p; int n, i;
     // 如果table爲空則擴容,擴容在下面單獨講解
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // table數組中hash值對應位置爲空,直接構造成結點放入該位置
     if ((p = tab[i = (n - 1) & hash]) == null)
         tab[i] = newNode(hash, key, value, null);
     // table數組中hash值對應位置有數據
     else {
         Node<K,V> e; K k;
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // hash值對應位置結點爲TreeNode,調用紅黑樹的插值方法,本文不展開說紅黑樹
         else if (p instanceof TreeNode)
             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
         // hash值對應位置結點爲Node,將數據插入鏈表
         else {
             for (int binCount = 0; ; ++binCount) {
                 if ((e = p.next) == null) {
                     p.next = newNode(hash, key, value, null);
                     // 如果鏈表結點數達到8個,將鏈表轉換爲紅黑樹
                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                         treeifyBin(tab, hash);
                     break;
                 }
                 if (e.hash == hash &&
                     ((k = e.key) == key || (key != null && key.equals(k))))
                     break;
                 p = e;
             }
         }
         if (e != null) { // existing mapping for key
             V oldValue = e.value;
             if (!onlyIfAbsent || oldValue == null)
                 e.value = value;
             afterNodeAccess(e);
             return oldValue;
         }
     }
     ++modCount;
     // 如果size超過了閾值,擴容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
}

多線程環境下,put()方法會有是什麼問題呢?

tab[i] = newNode(hash, key, value, null);,首當其衝的就是這種賦值操作,很容易丟數據。如因爲沒有鎖,線程 A 線程 B 可以同時檢查得到tab[1]==null,然後線程 A 設置了tab[1]=Node(A),馬上線程 B 又設置tab[1]=Node(B),那麼線程 A 設置的數據就丟失了,而正確的操作應該是將 Node(B)插入鏈表中Node(A).next=Node(B)。
類似的賦值操作p.next = newNode(hash, key, value, null);,也有同樣的問題。
++size,普通變量 size 沒有可見性保證,++操作也沒有保證原子性,這個計算在多線程環境下一定是有問題的。
在一個線程 put()過程中,可能有其他線程有 put 和 remove,導致當前線程 put 失敗。


4. get()方法


先從數組中取,取到 hash 值相等且 equals 的,直接返回
取到 hash 值相等且!equals,到鏈表/紅黑樹中取

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) { // 取出數組中hash值對應的Node
        // 先檢查第一個Node是不是要找的Node,如果是就返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 第一個Node不是要找的Node,到後面的鏈表/紅黑樹中找
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 遍歷後面的鏈表,找到key值和hash值都相同的Node返回
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

多線程環境下,get()方法有什麼問題?

由於 table 數組沒有保證內存可見性,在一個線程刪除/添加某個結點後並不能及時刷新到主內存中,另一個線程通過 get()方法獲取該結點就會出錯。

5. 擴容 resize()


newTab = new Node[2*length],創建一個兩倍於原來數組 oldTab 的新數組 newTab,遍歷 oldTable,將 oldTab 中的結點轉移到 newTab 中。
如果桶中 oldTab[i]只有一個元素 node,直接將 node 放入 newTab[node.hash & (newCap - 1)]中。
如果桶中 oldTab[i]是鏈表,分成兩個鏈表分別放入 newTab[i]和 newTab[i+oldTab.length]。
如果桶中 oldTab[i]是樹,樹打散成兩顆樹插入到新桶中去。


擴容操作問題 1:如果桶中 oldTab[i]只有一個元素 node,直接將 node 放入 newTab[j]中,此時 newTab[j]中沒有值嗎?如果有值,原來的數據不就丟了?

此時 newTab[j]一定爲空。oldTab[i]桶中只有 node 一個值說明 node 是沒有 hash 衝突的,也就是不會有其他結點的 hash 值與 node.hash 相等,所以放入 newTab[j]也只會有 node 這一個結點。

擴容操作問題 2:如果桶中 oldTab[i]是鏈表,爲什麼要分成兩個鏈表,這兩個鏈表是如何分的?

同一個鏈表中的結點是因爲哈希衝突導致的。看下這種情況:


oldTab.length=16        oldTab.length-1=15=0000 1111
node1.hash=1111 1001    node1.hash&(length-1)=1001=9
node2.hash=1110 1001    node1.hash&(length-1)=1001=9
所以node1和node2是在table[9]桶中的同一個鏈表裏的。

擴容後 node1 和 node2 在 newTab 中的位置和原來可能不一樣。因爲 node1.hash 和 node2.hash 第 5 位(也就是 oldTab.length 最高位)不同,在 newTable 中計算的位置也就不同。


計算node1在newTab中的位置:
node1.hash&(newTab.length-1)=(1111 1001 & 0001 1111)=0001 1001=25=9+16 (高位)
計算node2在newTab中的位置:
node2.hash&(newTab.length-1)=(1110 1001 & 0001 1111)=0000 1001=9 還是原來的位置(低位)

擴容時,根據(node.hash & oldTab.length)是否爲 0 來區分 node 應該在哪個鏈表,其實就是根據 node 的第 5 位(也就是 oldTab.length 最高位)是 0/1 來區分。

newTab.length=32 newTab.length-1=0001 1111
(node.hash & oldTab.length) == 0 加入低位鏈表
(node.hash & oldTab.length) != 0 加入高位鏈表


來看下源碼:

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // 對應數組擴容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 將數組大小擴大一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 將閾值擴大一倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 對應使用 new HashMap(int initialCapacity) 初始化後,第一次 put 的時候
newCap = oldThr;
else {// 對應使用 new HashMap() 初始化後,第一次 put 的時候
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}




















if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;

// 用新的數組大小初始化新的數組
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 如果是初始化數組,到這裏就結束了,返回 newTab 即可

if (oldTab != null) {
    // 開始遍歷原數組,進行數據遷移。
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            // 如果該數組位置上只有單個元素,直接遷移這個元素
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            // 紅黑樹
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            else {
                // 這塊是處理鏈表的情況,
                // 需要將此鏈表拆成兩個鏈表,放到新的數組中,並且保留原來的先後順序
                // loHead、loTail 對應一條鏈表,hiHead、hiTail 對應另一條鏈表,代碼還是比較簡單的
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    if ((e.hash & oldCap) == 0) {
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    }
                    else {
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    }
                } while ((e = next) != null);
                if (loTail != null) {
                    loTail.next = null;
                    // 第一條鏈表
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                    // 第二條鏈表的新的位置是 j + oldCap
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}
return newTab;

}

> 多線程環境下,因爲 resize()方法更復雜,調用更多,所以它的線程安全問題也更多。同樣是因爲操作共享數據時沒有加鎖,table、size 的操作內存不可見等。

# 6. 一些細節

-----

HashMap 源碼中還有一些細節需要注意,對於我們編碼能力的提升以及面試都很有好處。
6.1 計算 table 中的位置

hash&(table.length-1)
爲了保證 hash 值能在 table 中找到位置,常用取餘的方式hash%(table.length-1)。JDK 源碼肯定是非常注重效率的,所以用位運算代替除法運算,所以hash&(table.length-1)。
爲了保證hash&(table.length-1)得到的結果在 table 的範圍內,就需要保證 table.length 始終是 2 的次冪。源碼中通過 tableSizeFor()方法保證 table.length 始終是 2 的次冪:

static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}








6.2 hash 值計算

int hash = (h = key.hashCode()) ^ (h >>> 16);
hash 爲什麼不直接用key.hashCode()呢?計算 hash 值時,考慮的最重要問題就是減少 hash 衝突。讓 key.hashCode()的高 16 位更多的參與到 hash 值的計算中,可以減少哈希衝突。
所以,哈希衝突(hash&(table.length-1)相等)兩種情況:①hash 值相等 ②hash 值不相等 但 hash&(table.length-1)相等
6.3 裝載因子閥值爲什麼用 0.75

這是權衡時間複雜度和空間複雜度的結果。
* 閾值高,空間利用率好,哈希衝突多執行效率低。
* 閾值低,哈希衝突少執行效率高,空間利用率低。
6.4 紅黑樹掃盲

近似平衡的二叉查找樹
① 根結點是黑色的 ② 每個葉子結點都是黑色的空結點(NIL),也就是說,葉子結點不存儲數據;③ 任何相鄰的結點都不能同時爲紅色,也就是說,紅色結點是被黑色結點隔開的;④ 每個結點,從該結點到達其可達葉子結點的所有路徑,都包含相同數目的黑色結點;
查找時間複雜度:O(logn)
插入、刪除操作都需要做平衡,平衡時有可能會改變根結點的位置,顏色轉換,左旋,右旋等。
6.5 爲什麼 hashmap 不直接採用紅黑樹,而是當大於 8 個的時候才轉換紅黑樹?

當結點小於 8 時,直接遍歷鏈表效率並不低,而紅黑樹結構複雜維護成本高
6.6 爲什麼鏈表長度爲 8 時轉爲紅黑樹,而樹結點數爲 6 時才轉爲鏈表?

假設一下,如果設計成鏈表個數超過 8 則鏈表轉換成樹結構,鏈表個數小於 8 則樹結構轉換成鏈表。當一個 HashMap 不停的插入、刪除元素,鏈表個數在 8 左右徘徊,就會頻繁的發生樹轉鏈表、鏈表轉樹,效率會很低。
# 7. 總結

-----

HashMap 採用“數組+鏈表+紅黑樹”的數據結構。
HashMap 的設計上就沒有考慮併發環境,極易導致線程安全問題。
table 數組、size 等共享數據都沒有保證內存可見性,其操作也沒有保證原子性,會導致線程安全問題。
散列桶中的結點、鏈表、紅黑樹的讀寫操作並沒有加鎖保證同步,同樣會導致線程安全問題。
爲了解決 HashMap 的併發問題,JDK 提供了高性能的線程安全 HashMap:ConcurrentHashMap。
# 併發系列文章彙總

-----

【原創】01|開篇獲獎感言
【原創】02|併發編程三大核心問題
【原創】03|重排序-可見性和有序性問題根源
【原創】04|Java 內存模型詳解
【原創】05|深入理解 volatile
【原創】06|你不知道的 final
【原創】07|synchronized 原理
【原創】08|synchronized 鎖優化
【原創】09|基礎乾貨
【原創】10|線程狀態
【原創】11|線程調度
【原創】12|揭祕 CAS
【原創】13|LockSupport
【原創】14|AQS 源碼分析
【原創】15|重入鎖 ReentrantLock
【原創】16|公平鎖與非公平鎖
【原創】17|讀寫鎖八講(上)
【原創】18|讀寫鎖八講(下)
【原創】19|JDK8新增鎖StampedLock
【原創】20|StampedLock源碼解析
【原創】21|Condition-Lock的等待通知
【原創】22|倒計時器CountDownLatch
【原創】22|倒計時器CountDownLatch
【原創】23|循環屏障CyclicBarrier
【原創】24|信號量Semaphore

之前,給大家發過四份Java面試寶典,這次新增了更全面的資料,相信在跳槽前準備準備,基本沒大問題。
《java基礎:設計模式等》(初中級)
《JVM:整理BAT最新題庫》《併發編程》(中高級)
《分佈式微服務架構》《架構|軟技能》(資深)
《一線互聯網公司面試指南》(資深)
學習視頻包含深入運行時數據區、垃圾回收、詳解類裝載過程及類加載機制、手寫Spring-IOC容器、redis入門到高性能緩存組件等等

![](https://s4.51cto.com/images/blog/202011/20/56449cf61fcb2cdf51583eb48b187105.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)

獲取方式:點“在看”,在公衆號後臺打開,回覆 【666】即可領取,資料持續更新。

看到這裏,證明有所收穫
必須點個在看支持呀,喵
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章