清晰解題: 談談你對 HashMap, ConcurrentHashMap 的理解

參考文章:

Wiki: Hash table
java 8 Hashmap深入解析 —— put get 方法源碼
What’s Wrong With Hashcode in java.lang.String?
JDK1.8 HashMap源碼分析
HashMap1.8的擴容機制
Java 源碼研究之 HashMap
Java HashMap.get(Object) infinite loop
How ConcurrentHashMap works and ConcurrentHashMap interview questions

JAVA 面試的暖場題

Java 開發中用的比較多的集合類有哪些?

  • 如果答案中包含了 HashMap, 那很自然地引到下一個問題

談談你對 HashMap 的理解, 底層的基本實現。

  • HashMap 是計算機數據結構哈希表 ( hash table ) 的一種實現。
    • Tips: java 中的另一個哈希表實現類的名稱就是 Hashtable, 由於歷史原因, 命名還不是駝峯的, 後續閱讀中需要注意區分我們所談論的是數據結構的概念 hash table, 還是 JAVA 的實現類 Hashtable

HashMap, Hashtable, TreeMap, HashSet 有什麼區別 (這裏最重要的是區分底層數據結構的差異)

  • Hashtable 和 HashMap 是可比的, 因爲底層數據結構都是哈希表。
    • Hashtable 是一個 java 早期版本的哈希表實現
      • 不支持 null 鍵和值
      • 線程安全
      • 同步開銷較大, 現已很少被推薦使用
    • HashMap 是應用更加廣發的哈希表實現
      • 支持 null 鍵和值
      • 線程不安全
  • TreeMap 底層數據結構是紅黑樹
    • get, put, remove 等操作時間複雜度是 Olog(n)O(log(n))
    • 支持元素的順序訪問
  • HashSet 要實現的數據結構是集合(Set), 但是底層實現使用的是 HashMap
    • 沒想到吧, 看下源碼就會發現, HashSet 基本上就是一個限制了功能的 HashMap

既然 HashMap 不是線程安全的, 多線程訪問 HashMap 會出現什麼問題

  • JDK1.8 以前(JDK1.8 對 HashMap 的擴容機制進行了優化), 如果同時有兩個線程對 HashMap 進行寫入操作, 引發 HashMap 的擴容操作, 可能會導致桶數組後的鏈表出現環, 繼而導致再有線程調用 get(key) 時候陷入死循環,耗盡 CPU 資源
  • 後文會進行詳細說明

用什麼方案解決線程安全需求

  • ConcurrentHashMap

ConcurrentHashMap 如何高效實現線程安全

  • 後文展開敘述

先修知識

首先, HashMap 所對應的數據結構的學術名稱是哈希表(Hash Table) , 其基本要素包含

  • 哈希函數(Hash Function )
  • 桶數組( Array Of Bucket), 其實就是一個數組, 桶是已經約定俗稱的名稱
  • 哈希表就是一個將數據的鍵(Key), 進行哈希計算(Hash Function)並對數組長度取模, 獲得索引 , 存儲到相應的數組位置中。 如下圖就展示了哈希表如何存儲一個電話號碼本, 通過這個數據結構, 我們可以快速通過姓名檢索到其對應的電話號碼。
    在這裏插入圖片描述

細心的同學必須要罵了, HashMap 的鏈表哪去了? HashMap 底層不應該是下圖這樣的嗎?
在這裏插入圖片描述

HashMap 確實大致類似(細節差異在後文中可以看到)上圖這樣, 在桶數組的後面追加了鏈表, 但是這其實不是數據結構哈希表(hash table) 的固定實現, 這種做法嚴格意義上來說只是解決哈希衝突(hash collision) 的一種方法而已。 具體而言, 哈希衝突解決主要有如下 2 大類策略:

  • 獨立成鏈法

    • 獨立成鏈法中, 相同的索引上有往往有某種數據結構串聯起來的多個數據項(Entry)
    • 桶的後面可以跟的數據結構有
      • 鏈表(最常見)
      • 自平衡二叉樹(Java 8 中的 HashMap 實現就採用了這種方案
      • 其他數據結構
  • 開放定址法

    • 在這種策略下, 所有的數據項都存儲在桶數組本身。
    • 在這種策略下, 當一個衝突發生時, 由於原本應該插入的桶位置已經被佔用, 新進的元素需要以已被佔用位置爲起始點, 用某種方法,再次找到一個空置的位置插入。
      • 線性探測法 : 從被佔用的桶位置開始, 以固定間隔(通常是 1 ) 向後尋找空餘位置
      • 平方探測法 : 從被佔用的桶位置開始, 以固定間隔(k2k^2 ) 向後尋找空餘位置
      • 雙重哈希法:第 i 次衝突發生以後,通過另一個hash 函數 h2h_2 計算出的間隔(ih2(k))mod  Ti \cdot h_2(k) ) \mod |T|)尋找空餘位置
    • 這種策略有一個明顯的缺點是: 存儲的數據項數量無法超過桶數組的長度。 所以在應用中往往會伴隨着強制擴容(resizing), 帶來相應的開銷

HashMap 的實現細節(Java 8版本)

首先 HashMap 的桶結構的聲明代碼如下:

    transient Node<K,V>[] table;

示意圖如下
在這裏插入圖片描述

Node 元素的聲明如下

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
		// ... 省略
}

示意圖
在這裏插入圖片描述

HashMap 的構造函數

public HashMap(int initialCapacity, float loadFactor){  
    // ... 省略
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

可以看到 HashMap 構造函數中沒有立刻初始化 Node<K,V>[] table , 採用了延遲加載的策略。

這裏值得注意的是, 初始容量(initialCapacity) 和 負載係數(loadFactor)是影響 HashMap 性能的兩個參數。

  • 初始容量(initialCapacity)
    • 桶數組創建時的大小
  • 裝填係數(loadFactor)
    • 一種衡量何時需要重新調整 HashMap 大小, 並進行再散列的參數, 後面展開描述

HashMap 的 put 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);// 注意這裏已經對 key 調用了 hash 方法, 計算了對應的 hashcode
}

可以看到有兩個參數被默認設置成了 false 和 true, 分別是 onlyIfAbsent, evict .

  • onlyIfAbsent 爲 false 表示, 如果放置的元素已經存在, 就予以替換
  • evict 參數在 HashMap 類中無意義, 因爲搜索一下可以發現, 只有一個方法 void afterNodeInsertion(boolean evict) { } 使用了這個參數, 而這個方法體是空的。 LinkedHashMap 繼承了 HashMap 實現了這個方法體, 這裏不做展開敘述。

下面爲 putVal 的方法體源碼,添加了註釋用於說明代碼邏輯。


    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)
        	// table 爲空時, 通過 resize() 方法進行初始化
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)// 注意到此處指針 p 已經被指向了桶中的一個元素
        	// 此處通過(n - 1) & hash 計算出該元素在桶數組中的下標, 如果此位置爲空,則可以直接放置該元素
        	// 爲什麼通過 (n - 1) & hash 計算下標在文章後面詳細解釋
            tab[i] = newNode(hash, key, value, null);
        else {
        	// 下面對應桶的位置已經被佔用的情況, 屬於 hash 取模後索引·衝突解決的部分
            Node<K,V> e; K k;// 初始化 element 指針 e, 如果當前待插入的 key 值經過後續的搜索後, 發現已經存在, 該指針會已經存在的元素位置, 否則爲空
            if (p.hash == hash && // 桶中已經放置的元素hash值是否和當前待放置的元素hash值相等
                ((k = p.key) == key || (key != null && key.equals(k))))// 且桶中已經放置的元素 key 值和當前待放置的元素 key 值相同
                e = p; // 指向已經存在的元素位置 
            else if (p instanceof TreeNode)
            // 如果桶中已經放置的元素是一個樹節點,說明這個桶的位置上已經發生多次衝突, 屬於這個位置的多個元素以自平衡二叉樹的結構, 連接在這個桶的後面了所以新的待放置的元素需要插入到這顆樹中,故調用 putTreeVal
            // 此處傳入當前 hashMap 的引用 this 的原因是, putTreeVal() 是一個定義在靜態內部類 TreeNode 的方法, 該方法內部需要調用一個定義在 HashMap 類的非靜態方法 newTreeNode() , 而靜態內部類是不能直接訪問外部類的非靜態成員的, 所以需要傳入引用
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)    
            else {
            // 桶的位置上是一個鏈表頭
                for (int binCount = 0; ; ++binCount) {// binCount 用於計數鏈表中的元素個數
                    if ((e = p.next) == null) {// p.next 爲空說明到達鏈表尾
                        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))))
                        // 如果在遍歷的過程中發現鏈表中已經存在該 key 值相同的元素,跳出循環 
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
            // 這個地方針對桶中或桶後鏈表中發現key值相同元素的情形
            // 根據onlyIfAbsent 參數決定是否對已有元素的值進行替換
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);// 用於LinkedHashMap 的方法, 對於HashMap無意義
                return oldValue;
            }
        }
        ++modCount; //HashMap的數據被修改的次數,這個變量用於迭代過程中的Fail-Fast機制,其存在的意義在於保證發生了線程安全問題時,能及時的發現(操作前備份的count和當前modCount不相等)並拋出異常終止操作。
        if (++size > threshold)// hashMap 節點數目大於閾值, 進行擴容
            resize();
        afterNodeInsertion(evict);// 用於LinkedHashMap 的方法, 對於HashMap無意義
        return null;
    }

上述代碼實現的示意圖如下
在這裏插入圖片描述

結合註釋通讀代碼後, 我們先回答註釋中沒有解決的問題:

  • 桶的索引計算過程爲什麼是 (n - 1) & hash
    • 答: 這就是一個運算技巧, 當 length=2nlength = 2^n 時, X%length=X&amp;(length1)X \% length = X \&amp; (length - 1) , 實際上就是簡單的對 hash 值以數組長度取模
    • HashMap 的桶數組大小永遠都是 2n2^n, 擴容也是翻倍當前的大小

這個問題進一步拓展:

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

注意, 這裏獲取 hash 值的計算調用的是HashMap 中的一個方法 hash(key) , 並沒有直接調用 key 的 hashCode()方法來直接產生hash值。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 這裏的運算邏輯是, 將 key 的 hashCode 方法返回值 與 其本身右移16位的值作與操作。
  • 這樣做的效果是, hashCode 的高位數據被右移到了低位, 與原有的低位數據做了異或運算, 這樣是爲了解決有些數據的 key 值計算後的 hash 差異主要在高位, 如果將這種數據取餘後, 很容易會發生 hash 碰撞。(例如 100000001 和900000001 對 16 取餘結果都是 1 ) , 進行這種運算後, 高位的差異就會在低位得到體現, 減小發生碰撞的概率。

引申問題: 考慮到 HashMap 最常見的 key 類型是 StringString 類的 hashCode() 是怎樣實現的呢

下面是 String 類 hashCode() 的源碼

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

上面的計算邏輯是:String[0]31n1+String[1]31n2++String[n1]String [0]*31^{n-1} + String[1]*31^{n-2} + \cdots +String[n-1]

  • 這就是簡單的一個多項式類乘公式。 乘數被選爲 31 的原因有一些內在的原因
  • Joshua Bloch 在 《Effective Java》 中解釋:
    • “選 31 作爲乘數是因爲它是一個素數, 如果是一個偶數的話, 當乘數溢出以後, 這個數的部分信息就被丟失了, 因爲乘 2 就等同於二進制的左移操作。 但爲什麼不選用奇數而是偶數做乘數的原因就沒那麼清晰了, 但是這是一個傳統。 31 有一個很好的性質, 就是乘以 31 可以通過二進制位移以及一次減法操作快速實現 , 因爲: 31i==(i&lt;&lt;5)31 * i == (i &lt;&lt; 5), 現代的虛擬機實現可以自動對這個運算進行優化”
    • 這種計算方法會導致前綴相同的字符串很容易得到相同的 hashCode, 例如字符串a 與字符串 b 有相同的前綴, 要得到相同的 hashCode, 只需要滿足下列條件:
      • 31(b[n2]a[n2])==(a[n1]b[n1])31*(b[n-2] - a[n-2]) == (a[n-1] - b[n-1])
 String a = "Aa";
 String b = "BB";
 System.out.println(a.hashCode());
 System.out.println(b.hashCode());
 System.out.println(31 * ('C' - 'D') == ('B' - 'a'));
 System.out.println(31 * ('B' - 'A') == ('a' - 'B'));
 System.out.println("common_prefixDB".hashCode());
 System.out.println("common_prefixCa".hashCode());
  • 瞭解 String 默認的 hashCode 這個特點以後, 就會發現, HashMap 以 String 作爲Key , 其實出現 hash 碰撞還挺容易的,這裏就可以看到 Java 8 中爲 HashMap 添加樹化機制的深意了

HashMap 的小結

粗略瞭解了 HashMap 的底層實現後, 我們可以總結如下:

  • HashMap 解決衝突的方法是獨立成鏈法, 而非開放定址法
  • jdk1.8 中的 HashMap 在桶後面追加的數據結構既有可能是鏈表, 也有可能從鏈表轉化爲樹。
  • 從 String 的 hashCode 實現算法可以發現, 對於使用 String 作爲 key 的 HashMap, 衝突並不難構造, 當 HashMap 的衝突過多, 如果沒有樹化機制, 鏈表元素過長時, 會嚴重影響檢索效率。 一線互聯網公司就發生共利用這種原理進行拒絕服務攻擊的案例。

HashMap 的線程安全問題

HashMap爲什麼不是線程安全,併發操作Hashmap會帶來什麼問題 ?

  • 我覺得如果是非算法崗位, 面試這種細節的面試官就有點沒意思了, 對於一個有計算機理論基礎的同學來講, 肯定了解併發操作一個數據結構會引發線程安全問題, 要想讓一個數據結構線程安全, 需要考慮很多問題, 細究一個在設計之初就沒把線程安全當目標的數據結構如何產生問題, 實在是有點學究了。 但是出於不平等關係, 還是展開學習一下這塊內容
  • HashMap 在併發環境可能出現死循環佔用 CPU 的問題(java 8 之後, 不會再出現死循環問題

由於死循環的引發是HashMap 擴容機制導致的, 而jdk1.8 又在擴容機制上進行了優化, 所以不得不先了解一下 HashMap 的擴容機制

HashMap ( JDK1.7 ) 的擴容機制

看一下 jdk1.7 中 resize() 方法

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) { //(1)
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity]; //(2)
    transfer(newTable, initHashSeedAsNeeded(newCapacity)); //(3)
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

這個方法可讀性還是蠻強的, 創建新的桶數組, 然後調用 transfer 方法, 將舊數組的元素遷移到新數組中去間

具體邏輯如下

/**
 * Transfers all entries from current table to newTable.
 */
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; // 構造一個 next 指針指向 e 的下一個元素
            if (rehash) {// 如果需要重新計算 hash
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity); // 根據新的容量以及該元素的hash值, 計算它在新數組中對應的索引
            // 下面的操作完成鏈表“頭插法”
            // 因爲新數組的對應索引處 newTable[i] 處可能存在已遷移的元素或鏈表
            // 使用頭插法, 不管newTable[i] 處是否已經存在元素, 都可以正確的把元素遷移過
            e.next = newTable[i]; 
            newTable[i] = e;
            e = next;
        }
    }
}
}

下面繪圖展示一下這個頭插法的過程, 假設舊數組 oldTab 長度爲2, 新數組長度爲 4

  • 執行完 Entry<K,V> next = e.next; 後, 效果如下
    在這裏插入圖片描述

  • 執行完e.next = newTable[i]; , 效果如下
    在這裏插入圖片描述

  • 執行完newTable[i] = e;, 效果如下
    在這裏插入圖片描述

  • 然後 e=next , 跳出循環

併發寫入操作可能導致鏈表出現環

瞭解了 JDK1.7 的擴容機制以後, 可以看一下如果有兩個線程併發寫入, 同時執行transfer() 函數會發生什麼

首先假設 Thread1 開始執行 transfer 方法, 執行完 Entry<K,V> next = e.next; 之後停下了, 形成如下效果

在這裏插入圖片描述

然後, Thread 2 得到調度

  • 注意, Thread 2 是意識不到 Thread 1 的存在的, 此時 Thread 1 尚未對 HashMap 的結構做出任何改變

假設 Thead2 一口氣執行完了 transfer 函數,完成了頭插法的全部流程, 就會變成下面這樣
在這裏插入圖片描述

現在 Thread 1 得到調度執行,此時, 數據結構已經發生了變化

在這裏插入圖片描述

由於 Thread 2 的執行, 導致線程1 在什麼都沒做的時候, 所處理的數據結構發生瞭如下變化

  • 原本指向 key = 7 的 next 指針, 現在指向的是null
  • 原本 key = 7 的後繼元素是 null, 現在變成了 key = 3 的元素(頭插法導致的鏈表元素順序顛倒

然後 Thread 1 執行完e.next = newTable[i]; , 變成如下效果
在這裏插入圖片描述

然後Thead1 執行完newTable[i] = e;, 效果如下
在這裏插入圖片描述

  • 然後 e=next , 跳出循環

上述流程完成於以後, 鏈表成功出現你了一個環, 調用get 訪問到這個環時, 就會造成死循環

HashMap(JDK1.8) 的擴容機制

閱讀 resize() 方法前, 值得先看一下 HashMap 構造函數的源碼


    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        
        this.threshold = tableSizeFor(initialCapacity);
        // 小把戲: 初始容量賦值給了 threshold 變量中
        // 這樣做是爲了減少維護的私有變量, 本來 HashMap 就已經維護了變量 transient Node<K,V>[] table
        // 通過 table.length 就可以得到當前 hashMap 桶數組的長度,如果爲了延遲加載機制, 再增加一個臨時存放首次初始化長度的變量會顯得煩瑣和多餘
    }
  • 從無參默認構造函數一路看下來, initialCapacity 代表的並不是最終桶數組的長度, 桶數組的長度通過 tableSizeFor(initialCapacity) 函數計算
  • tableSizeFor(initialCapacity)主要完成的功能是高效地找到一個大於等於 intialCapacity 值的 2 指數。 裏面的具體實現用到了位運算的一些技巧, 有興趣的同學可以自行搜索博文了解, 這裏不做展開
  • 值得注意的是 tableSizeFor 返回的結果被賦值給了threshhold, resize() 方法中有對於這個操作的依賴

下面閱讀 resize() 源碼

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; // oldTab 指向當前桶數組
        int oldCap = (oldTab == null) ? 0 : oldTab.length;// oldCap 保存擴容前桶數組長度
        int oldThr = threshold;// oldThr 保存原有的擴容觸發閾值
        int newCap, newThr = 0;// 新的容量, 新的閾值初始化爲 0
        if (oldCap > 0) {// 原有桶數組長度大於0, 已經存在元素
            if (oldCap >= MAXIMUM_CAPACITY) {// 原有數組長度已經大於等於最大容量(2的30次方)
                threshold = Integer.MAX_VALUE;// 擴容閾值設置爲 int 最大值
                return oldTab;// 直接返回, 因爲無法再擴了
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&// 舊容量翻倍以後未超過最大容量限制
                     oldCap >= DEFAULT_INITIAL_CAPACITY)// 舊容量大於等於默認的初始容量
                newThr = oldThr << 1; // 舊有容量翻倍作爲新容量
        }
        else if (oldThr > 0) // hashMap 構造後,首次 put 時會走到這個分支
        	// 此時 initalCapacity 是存儲在 threshhold 這個變量中的, 具體可見 hashMap 的構造函數
            newCap = oldThr;// 新容量就爲 oldThr, oldThr 值來源於構造函數中的 tableSizeFor的調用
        else {
        	// 調用 hashMap.reinitialize() 方法後,首次 put 會走到這個分支
        	// 容量和閾值都適用默認值
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) { // hashMap 構造後,首次 put 時會走到這個分支
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];// 以新的容量構建桶數組
        table = newTab;// hashMap 桶數組指針先指向新數組
        if (oldTab != null) { // 如果舊的數組被爲空,就需要將元素轉移
            for (int j = 0; j < oldCap; ++j) {// 遍歷桶的每一個位置
                Node<K,V> e;// 定義指針element
                if ((e = oldTab[j]) != null) { // 如果某個桶元素不爲空, 首先讓指針 e 指向這個元素
                    oldTab[j] = null // 指針oldTab[j]指向空
                    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 { 
                    	// 桶的位置上已經存在了一條鏈表, 容量變了以後, 鏈表中的元素在新數組中的位置可能並不一樣, 現在考慮需要如何要把鏈表遷移到新數組中, 下面解釋摘自博文 https://www.jianshu.com/p/c91b010dc03a
                    	// 由於數組的長度始終是 2^n , 且擴容也是翻倍擴容, 這裏便產生了一些有趣的性質
                    	// 在前文中, 我們已經知道, hashMap 中索引的計算方法是 (n - 1) & hash
                    	// 現假設有一個值爲 111001 的 hash
                        // 擴容前  n=16(10000)  n-1=15(1111)  (n - 1) & hash = 1111 & 111001= 001001
                        // 擴容後 n=32(100000) n-1=31(11111)  (n - 1) & hash = 11111 & 111001= 011001
                        // 發現擴容前  1111 & 101001 = 001001
                        // 擴容後 11111 & 101001 = 001001
                        // 所以可知,擴容後, n-1 的二進制就是多了一個 1
                        // 而 hash 中該對應位置的值只存在倆種可能 0,1
                        // 如果 hash 中對應的位置爲0, 擴容後索引結果不變
                        // 不爲0, 表示索引結果爲原結果(001001)+原數組長度(10000)
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {// 把一條鏈表拆成 A, B兩條鏈表, 
                        	// loHead, loTail 最終指向 A 鏈表的首尾, A鏈表中的元素 e 均滿足
                        		// (e.hash & oldCap) == 0
                        		// 即擴容後,桶數組索引不變
                        	// hiHead,  hiTail 最終指向 B 鏈表的首尾, B鏈表中的元素 e 均滿足
                        		// (e.hash & oldCap) == 0
                        		// (e.hash & newCap-1) == oldIndex + oldCap (先前註釋已經解釋過)
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;// 將元素 e 鏈接到 A 鏈表尾部
                                loTail = e;// lowTail 指針向後移動, 繼續指向 B 鏈表尾
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;// 將元素 e 鏈接到 B 鏈表尾部
                                hiTail = e;// highTail 指針向後移動, 繼續指向 B 鏈表尾
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                        // 新桶數組的對應位置指向拆好的第一個鏈條頭部
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 新桶數組的對應位置指向纔好的第二個鏈條頭部
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

拆鏈表的過程示意圖,左邊擴容前, 右邊擴容後
在這裏插入圖片描述

注意到 HashMap ( JDK1.8 ) 擴容時, 注意保證了原有鏈表中元素的先後關係, 這也就避免了多線程寫入產生環形鏈表的問題

ConcurrentHashMap 概覽

上文展示了 JDK1.8 如何避免環形鏈表產生的問題, 但是它依舊不是線程安全的, 多線程寫入還可能出現 size()
不準確以及其他問題。

  • JDK1.8 之所以修改鏈表的遷移方式, 是爲了減輕程序員對於 HashMap 的錯誤使用的後果, 而不是讓 HashMap 變成線程安全的容器。

線程安全的可選方案:

  • Hashtable

    • Hashtable 是線程安全的, 但它的實現基本就是把 put, get, size 等內部方法加上 sychronized 關鍵字修飾, 這導致所有併發操作都只能競爭一把 HashMap 的對象鎖, 其他線程必須進入 waiting 狀態, 效率較低, 已經不被推薦使用
  • Collections.synchronizedMap(inputMap)

    • 這就是一個 Collections 包提供的包裝器方法, 方法聲明如下
    • public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
    • 觀察一下下面的源碼可以發現, 其本質還是構造了一個 mutex 對象鎖, 並不能解決併發競爭一把鎖的問題
private static class SynchronizedMap<K,V>
    implements Map<K,V>, Serializable {
    private final Map<K,V> m;     // Backing Map
    final Object      mutex;        // Object on which to synchronize
    // …
    public int size() {
        synchronized (mutex) {return m.size();}
    }
 // … 
}
  • ConcurrentHashMap
    • jdk 1.5 爲了提供一個支持併發的 HashMap 容器, 引入了 ConcurrentHashMap, 並且在後續的版本中不斷演化改善其設計, jdk 1.8 中就發生了比較大的變化。
    • 早期 ConcurrentHashMap 高效實現併發的主要原理有兩點
      • 分段鎖
        • 將桶數組進行分段(Segment), 併發操作時只要鎖定元素所在的 Segment 即可
        • Segment 的數量由的 concurrentcyLevel 指定, 默認 16, 可以通過構造函數傳入該參數, 和 HashMap 的桶數組長度一樣, 都需要是 2n2^n, 如果不是, 會自動調整到大於等於指定 concurrentcyLevel 值的 2n2^n
          • ConcurrentHashMap m = new ConcurrentHashMap(initialCapacity, loadFactor, concurrencyLevel)
        • 讀操作不需要同步, 所以無需競爭鎖
        • 寫操作如果發生在同一個 Segment 上, 就需要競爭鎖, 不能併發執行, 如果在不同的 Segment 上, 就無需競爭, 可以併發執行
      • Unsafe 調用
        • 這個類名非常直白, java 在某種意義上, 被稱作一種安全的編程語言, 原因是它向程序員屏蔽了複雜的底層細節(內存管理, 操作系統級別調用), 這有效地避免了程序員可能會犯的一些愚蠢錯誤。 但是也限制了其能力
        • Unsafe 類就提供了一系列更加底層的函數或指令, 使得程序員可以直接訪問或操作內存, 當然, 正如其名, 錯誤使用可能帶來比較嚴重的 bug
        • Unsafe 類甚至沒有官方版本的文檔,也反映了官方不鼓勵 “普通” 程序員使用的態度
        • HashMap 就使用了 Unsafe 中的 api 以提升效率, 本文點到爲止, 不做過多探討, 應用級別程序員只需要知道它提供了一種高效的底層方法或調用即可。
        • 有興趣的同學可以閱讀這兩篇博文了解更多細節:
        • 或者直接閱讀其源碼中的註釋 openjdk-7-sun.misc.Unsafe

在這裏插入圖片描述
在這裏插入圖片描述

ConcurrentHashMap 實現細節(JDK1.7)

public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key.hashCode());
       // 利用位操作替換普通數學運算
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        // 以 Segment 爲單位,進行定位
        // 利用 Unsafe 直接進行 volatile access
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
           // 省略
          }
        return null;
    }

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