Map、HashMap源碼分析、ConcurrentHashMap內部實現

Collection集合的最大特點是每次進行單個對象的存儲,而Map是進行一對對象的保存,並且這兩個對象之間的關係是key=value的關係。這種結構最大的特點是可以通過key值找到value值。

Map接口常用的方法如下:
在這裏插入圖片描述
當然,Map也是一個接口,要想實例化,也需要子類,Map有四個子類:HashMapHashtableTreeMapConcurrentHashMap

HashMap

範例:

 Map<Integer,String> map=new HashMap<Integer, String>();
        map.put(1,"hello");
        map.put(1,"hello");
        map.put(2,"Java");
        map.put(3,"is");
        map.put(4,"best");
        map.put(null,"!!!");
        map.put(null,"...");
        map.put(5,null);
        map.put(6,null);
        System.out.println(map);
        System.out.println(map.get(1));
        System.out.println(map.get(10));

在這裏插入圖片描述

HashMap的內部實現原理

對於HashMap最重要的是理解內部實現原理!!!!!
先看兩個圖
在這裏插入圖片描述
在這裏插入圖片描述

HashMap內部可以看作是數組(Node[] table)和鏈表結合組成的複合結構,數組被分爲一個個桶(bucket),通過哈希值決定了鍵值對在這個數組的尋址;哈希值相同的鍵值對,則以鏈表形式存儲。如果鏈表大小超過閾值(TREEIFY_THRESHOLD, 8),圖中的鏈表就會被改造爲樹形結構。

這裏我們需要看着源碼來解析:
在這裏插入圖片描述
在這裏插入圖片描述
這是構造函數,從這個構造函數的源碼中我們可以知道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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            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;
    }

從 putVal 方法最初的幾行,我們就可以發現幾個有意思的地方:
如果表格是 null,resize 方法會負責初始化它,這從 tab = resize() 可以看出。
resize 方法兼顧兩個職責,創建初始存儲表格,或者在容量不滿足需求的時候,進行擴容(resize)

具體鍵值對在哈希表中的位置(數組 index)取決於下面的位運算:

i=(n-1)&hash

我們會發現,它並不是 key 本身的 hashCode,而是來自於 HashMap內部的另外一個 hash 方法。注意,爲
什麼這裏需要將高位數據移位到低位進行異或運算呢?這除是因爲有些數據計算出的哈希值差異主要在高
位,而 HashMap 裏的哈希尋址是忽略容量以上的高位的,那麼這種處理就可以有效避免類似情況下的哈希
碰撞。

再看看realize()方法:
在這裏插入圖片描述
依據 resize 源碼,不考慮極端情況(容量理論最大極限由 MAXIMUM_CAPACITY 指定,數值爲 1<<30,也就是 2的 30 次方),我們可以歸納爲:

  • 門限值等於(負載因子)*(容量),如果構建 HashMap 的時候沒有指定它們,那麼就是依據相應的
    默認常量值。
  • 門限通常是以倍數進行調整 (newThr = oldThr << 1),我前面提到,根據 putVal 中的邏輯,當元素
    個數超過門限大小時,則調整 Map 大小。
  • 擴容後,需要將老的數組中的元素重新放置到新的數組,這是擴容的一個主要開銷來源

在上面的討論中,我們離不開的兩個詞是負載因子和容量,爲什麼在這麼在乎負載因子和容量呢?

這是因爲負載因子和容量關乎可用桶的數量,如果空桶太多會浪費空間,使用的太滿則會影響操作的性能。可以假想極端情況下,只有一個桶,那就變成了鏈表,性能大大降低。可以考慮預先設置合適的容量大小。具體數值我們可以根據擴容發生的條件來做簡單預估,根據前面的代碼分析,我們知道它需要符合計算條件:

負載因子 * 容量 > 元素數量
所以,預先設置的容量需要滿足,大於“預估元素數量 / 負載因子”,同時它是 2 的冪數
當桶的容量小於等於64,鏈表長度大於8,進行擴容
當桶的容量大於64並且鏈表長度大於8的時候,進行樹化
那麼,爲什麼 HashMap 要樹化呢?

本質上這是個安全問題。因爲在元素放置過程中,如果一個對象哈希衝突,都被放置到同一個桶裏,則會形成一個鏈表,我們知道鏈表查詢是線性的,會嚴重影響存取的性

Hashtable

        Map<Integer,String > map=new Hashtable<Integer, String>();
        map.put(1,"hello");
        map.put(1,"hello");
        map.put(2,"Java");
        map.put(3,"is");
        map.put(4,"best");
//        map.put(null,"!!!");//異常
//        map.put(null,"...");//異常
//        map.put(5,null);//異常
//        map.put(6,null);//異常
        System.out.println(map.get(1));
        System.out.println(map.get(10));

事實是,Hashtable裏面的key值和value值都不允許爲空,否則會拋出異常。

HashMap與Hashtable的區別:

在這裏插入圖片描述

ConcurrentHashMap

Hashtable本身比較低效,因爲它的實現基本上是將put(),get(),size()方法加上了synchronized,這就導致了所有併發操作都要競爭同一把鎖,也就是說一個線程獲得這個鎖的時候,其他線程都在等待,這就是能效率大大降低。
早期 ConcurrentHashMap,其實現是基於:

分離鎖,也就是將內部進行分段(Segment),裏面則是HashEntry 的數組,和 HashMap類似,哈希
相同的條目也是以鏈表形式存放。
HashEntry 內部使用 volatile 的 value 字段來保證可見性,也利用了不可變對象的機制以改進利用Unsafe 提供的底層能力,比如 volatile access,去直接完成部分操作,以最優化性能,畢竟 Unsafe 中的很多操作都是 JVM intrinsic 優化過的。

可以參考下面這個早期 ConcurrentHashMap 內部結構的示意圖,其核心是利用分段設計,在進行併發操作的時
候,只需要鎖定相應段,這樣就有效避免了類似 Hashtable 整體同步的問題,大大提高了性能:
在這裏插入圖片描述
在構造的時候,Segment 的數量由所謂的 concurrentcyLevel 決定,默認是 16,也可以在相應構造函數直接指
定。注意,Java 需要它是 2 的冪數值,如果輸入是類似 15 這種非冪值,會被自動調整到 16 之類 2 的冪數值。

  • ConcurrentHashMap 會獲取再入鎖,以保證數據一致性,Segment 本身就是基於ReentrantLock 的
    擴展實現,所以,在併發修改期間,相應 Segment 是被鎖定的。
  • 在最初階段,進行重複性的掃描,以確定相應 key 值是否已經在數組裏面,進而決定是更新還是放置操
    作。重複掃描、檢測衝突是ConcurrentHashMap 的常見技巧。
  • 在 ConcurrentHashMap中擴容同樣存在。不過有一個明顯區別,就是它進行的不是整體的擴容,而是
    單獨對 Segment 進行擴容

另外一個 Map 的 size 方法同樣需要關注,它的實現涉及分離鎖的一個副作用。

試想,如果不進行同步,簡單的計算所有 Segment 的總值,可能會因爲併發 put,導致結果不準確,但是直接鎖定所有 Segment 進行計算,就會變得非常昂貴。其實,分離鎖也限制了 Map的初始化等操作。所以,ConcurrentHashMap 的實現是通過重試機制(RETRIES_BEFORE_LOCK,指定重試次數 2),來試圖獲得可靠值。如果沒有監控到發生變化(通過對比 Segment.modCount),就直接返回,否則獲取鎖進行操作。

下面來對比一下,在 Java 8 和之後的版本中,ConcurrentHashMap 發生了哪些變化呢?

  • 總體結構上,它的內部存儲變得和HashMap 結構非常相似,同樣是大的桶(bucket)數組,然後內部
    也是一個個所謂的鏈表結構(bin),同步的粒度要更細緻一些。
  • 其內部仍然有 Segment 定義,但僅僅是爲了保證序列化時的兼容性而已,不再有任何結構上的用處。
  • 因爲不再使用 Segment,初始化操作大大簡化,修改爲 lazy-load 形式,這樣可以有效避免初始開銷,解決了老版本很多人抱怨的這一點。
  • 數據存儲利用 volatile 來保證可見性。
  • 使用 CAS 等操作,在特定場景進行無鎖併發操作。
  • 使用 Unsafe、LongAdder 之類底層手段,進行極端情況的優化。

TreeMap

TreeMap是一個可以排序的子類,它是按照key的內容進行排序的。

Map<Integer,String> map=new TreeMap<Integer, String>();
        map.put(2,"is");
        map.put(1,"Java");
        map.put(3,"best");
        map.put(0,"hello");
        System.out.println(map);

在這裏插入圖片描述

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