HashMap、HashTable 和 ConcurrentHashMap 線程安全問題

来源:https://www.cnblogs.com/jmcui/archive/2019/08/28/11422083.html
-Advertisement-
Play Games

一、HashMap HashMap 是線程不安全的。 JDK 1.7 HashMap 採用數組 + 鏈表的數據結構,多線程背景下,在數組擴容的時候,存在 Entry 鏈死迴圈和數據丟失問題。 JDK 1.8 HashMap 採用數組 + 鏈表 + 紅黑二叉樹的數據結構,優化了 1.7 中數組擴容的方 ...


一、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 技術來減少鎖競爭對於性能的影響,從而保證線程安全性。

附錄


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 根據我的理解和搜集的資料,儘可能清晰完整的回答(逐步完善,持續更新) 1、String類為什麼是final的 首先分析String的源碼: 類被final關鍵字限定,說明它不可以被繼承,沒有子類。即持有一個String對象的引用,它必然是String類,而不會是其他的類。 value是用來存儲值的, ...
  • ★、本實例使用百度智能雲-人工智慧-人臉識別API實現。 ★、樓下安裝了刷臉進門。閑暇時無聊寫了個Demo 主界面顯示如下圖: 本實例,包括了所有人臉識別API的調用。 1、 創建樓號,對應API中創建用戶組,詳見: https://ai.baidu.com/docs#/Face-Set-V3/58 ...
  • python中有 try——except 的方法捕獲異常,可以獲取到異常的種類以及自定義異常, 但是有時候對於debug測試來說,信息不全,比如說 觸發異常的具體位置在哪: import traceback try: num= int('abc')except Exception: tracebac ...
  • Spring Security 解析(三) —— 個性化認證 以及 RememberMe 實現   在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 ...
  • 前面介紹了JavaFX的常見控制項用法,雖然JavaFX控制項比起AWT與Swing要好用些,但是一樣通過代碼編寫控制項界面,並沒有提高什麼開發效率。要想瀏覽界面的展示效果,都必須運行測試程式才能觀看,即使只是微調控制項的大小,也得重新運行程式查看效果,顯然既費時又費力。為此JavaFX提供了另一種給界面排 ...
  • 1.1 介紹 開發具有一定價值的符號是人類特有的特征。對於人們來說識別這些符號和理解圖片上的文字是非常正常的事情。與電腦那樣去抓取文字不同,我們完全是基於視覺的本能去閱讀它們。 另一方面,電腦的工作需要具體的和有組織的內容。它們需要數字化的表示,而不是圖形化的。 有時候,這是不可能的。有時,我們 ...
  • 概念 (這是我學習過程中的一些總結) JAVA虛擬機記憶體模型 從屬於線程的記憶體區域 JVM的記憶體劃分中,有部分區域是線程私有的,有部分是屬於整個JVM進程;我們將這部分歸為一類。 1.程式計數器(Program Counter Register) 在JVM規範中,每個線程都有自己的程式計數器。這是一 ...
  • 協程 Event事件 python 添加全局變數,修改全局變數,實現一個線程在某一個節點讓下一個線程繼續工作 import time from threading import Thread from threading import current_thread flag = False def ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...