HashMap、HashTable 和 ConcurrentHashMap 線程安全問題


一、HashMap

HashMap 是線程不安全的。

JDK 1.7 HashMap 采用數組 + 鏈表的數據結構,多線程背景下,在數組擴容的時候,存在 Entry 鏈死循環和數據丟失問題。

JDK 1.8 HashMap 采用數組 + 鏈表 + 紅黑二叉樹的數據結構,優化了 1.7 中數組擴容的方案,解決了 Entry 鏈死循環和數據丟失問題。但是多線程背景下,put 方法存在數據覆蓋的問題。

1.7 中擴容引發的線程不安全分析

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

這段代碼是 HashMap 的擴容操作,重新定位每個桶的下標,並采用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點。

假設現在有兩個線程A、B同時對下面這個 HashMap 進行擴容操作:

正常擴容后的結果是下面這樣的:

但是當線程A執行到上面 transfer 函數的第11行代碼時,CPU 時間片耗盡,線程A被掛起。即如下圖中位置所示:

此時線程A中:e=3、next=7、e.next=null

當線程A的時間片耗盡后,CPU 開始執行線程B,並在線程B中成功的完成了數據遷移:

重點來了,根據 Java 內存模式可知,線程B執行完數據遷移后,此時主內存中 newTable 和 table 都是最新的,也就是說:7.next=3、3.next=null

隨后線程A獲得CPU時間片繼續執行 newTable[i] = e,將3放入新數組對應的位置,執行完此輪循環后線程A的情況如下:

接着繼續執行下一輪循環,此時 e=7,從主內存中讀取 e.next 時發現主內存中 7.next=3,於是乎next=3,並將 7 采用頭插法的方式放入新數組中,並繼續執行完此輪循環,結果如下:

執行下一次循環可以發現,next=e.next=null,所以此輪循環將會是最后一輪循環。接下來當執行完e.next=newTable[i]即3.next=7后,3和7之間就相互連接了,當執行完newTable[i]=e后,3被頭插法重新插入到鏈表中,執行結果如下圖所示:

上面說了此時 e.next=null 即 next=null,當執行完 e=null 后,將不會進行下一輪循環。到此線程A、B的擴容操作完成,很明顯當線程A執行完后,HashMap 中出現了環形結構,當在以后對該 HashMap 進行操作時會出現死循環。

並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。

1.8 中 put 方法數據覆蓋問題分析

根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到 transfer 函數,因為 JDK1.8 直接在 resize 函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。

為什么說JDK1.8會出現數據覆蓋的情況喃,我們來看一下下面這段JDK1.8中的put操作代碼:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) // 如果沒有hash碰撞則直接插入元素
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

其中第六行代碼是判斷是否出現 hash 碰撞,假設兩個線程A、B都在進行 put 操作,並且 hash 函數計算出的插入下標是相同的,當線程A 執行完第六行代碼后由於時間片耗盡導致被掛起,而線程B得到時間片后在該下標處插入了元素,完成了正常的插入,然后線程A獲得時間片,由於之前已經進行了 hash 碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。

除此之前,還有就是代碼的第38行處有個 ++size,我們這樣想,還是線程A、B,這兩個線程同時進行 put 操作時,假設當前 HashMap 的zise大小為10,當線程A執行到第38行代碼時,從主內存中獲得size的值為10后准備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主內存中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主內存,然后線程A再次拿到CPU並繼續執行(此時size的值仍為10),當執行完put操作后,還是將size=11寫回內存,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。

二、HashTable

HashTable 是線程安全的。

HashTable 容器使用 synchronized 來保證線程安全,但在線程競爭激烈的情況下 HashTable 的效率非常低下。因為當一個線程訪問 HashTable 的同步方法,其他線程也訪問 HashTable 的同步方法時,會進入阻塞或輪詢狀態。如線程1使用 put 進行元素添加,線程2不但不能使用 put 方法添加元素,也不能使用 get 方法來獲取元素,所以競爭越激烈效率越低。

    public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
    }

三、ConcurrentHashMap

ConcurrentHashMap 線程安全的。

JDK 1.7 ConcurrentHashMap 采用Segment數組 + HashEntry數組實現。 Segment 是一種可重入鎖(ReentrantLock),在 ConcurrentHashMap 里扮演鎖的角色;HashEntry 則用於存儲鍵值對數據。一個 ConcurrentHashMap 里包含一個 Segment 數組,一個 Segment 里包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素。

分段鎖技術將數據分成一段一段的存儲,然后給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問,能夠實現真正的並發訪問。

從上面的結構我們可以了解到,ConcurrentHashMap 定位一個元素的過程需要進行兩次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的鏈表的頭部。

這一種結構寫操作的時候只對元素所在的Segment進行加鎖即可,不會影響到其他的 Segment,這樣,在最理想的情況下,ConcurrentHashMap 可以最高同時支持Segment數量大小的寫操作(剛好這些寫操作都非常平均地分布在所有的Segment上)。

這一種結構的帶來的副作用是Hash的過程要比普通的 HashMap 要長。

JDK 1.8 ConcurrentHashMap 采用數組 + 鏈表 + 紅黑樹的方式實現,結構基本上和 1.8 中的 HashMap 一樣,不過大量的利用了 volatile,final,CAS 等 lock-free 技術來減少鎖競爭對於性能的影響,從而保證線程安全性。

附錄


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM