談談Map

作為Javaer,對于Map這個單詞絕對不會陌生,無論是開發過程中還是出去面試的時候,都會經常遇到,而最頻繁使用和面試提問的無非這么幾個,HashMap, HashTable, ConcurrentHashMap。那么本文就針對這幾個知識點做一個歸納和總結。

從HashMap說起

HashMap是上面提到的幾個Map中使用頻率最高的了,畢竟需要考慮到多線程并發的場景并不算太多。下面是Map的一個關系圖,大家了解一下即可。

Map

HashMap在Java8之前和之后有很大差別,在Java8以前,它的數據結構是數組+鏈表的形式,8以后就變成了數組+鏈表+紅黑樹的結構。它的key是保存在一個Set里面的,也就是有去重的功能,values是存在一個Collections里面。

HashMap_Java7
HashMap_Java8

HashMap里的數組每個元素存放的是key-value形式的實例,Java7里面叫做Entry,8里面叫Node。這個Node里面包含了hash值,鍵值對,下一個節點next這幾個屬性組成。數組被分為一個個bucket,也就是桶,通過hash值決定了鍵值對在這個數組中的尋址,hash值相同的則以鏈表的形式存儲,鏈表長度超過閾值就轉成紅黑樹。那么先來看看HashMap的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;
    // 算出鍵值對在table中的具體位置,沒有就new一個node
    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();
    // 這是為了繼承HashMap的LinkedHashMap類服務的,用來回調移除最早放入Map的對象
    afterNodeInsertion(evict);
    return null;
}

那么總結一下就是:

  1. 若HashMap未被初始化,則進行初始化操作
  2. 對Key求Hash值,依據Hash值計算下標
  3. 若未發生碰撞,則直接放入桶中
  4. 若發生碰撞,則以鏈表的方式鏈接到后面
  5. 若鏈表長度超過閾值,且HashMap元素超過最低樹化容量,則將鏈表轉成紅黑樹
  6. 若節點已經存在,則用新值替換舊值
  7. 若桶滿了,就需要resize(擴容2倍后重排)

這個put的操作引申出幾個知識點,首先,

HashMap的初始容量是多少?為什么設置成這個值呢?

翻看源碼我們可以看到有這么一個變量DEFAULT_INITIAL_CAPACITY,

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

這個就是HashMap的初始容量,也就是16,為啥用位運算這么騷的寫法是因為位運算比算數計算的效率要高。那么為啥用16?看看上面說的下標計算的公式: index = HashCode(Key) & (Length- 1),當長度為16時候,Length-1的二進制就是1111,是一個所有位都為1的數,而且看上述注釋,建議的HashMap的初始長度都是2的冪次方,這種情況下,index的結果等同于HashCode后幾位的值。那么只要輸入的HashCode本身分布均勻,Hash算法的結果就是均勻的

另一個問題,Java8里面引入了紅黑樹,當鏈表達到一定長度的時候會轉換成紅黑樹,引入紅黑樹的好處是什么?這個變換的閾值是多少,為什么是這個值?

當元素put的時候,首先是要根據哈希函數和長度計算下標的,但即使哈希函數取得再好,也很難達到元素百分百均勻分布,那么就有可能導致 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當于一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間復雜度就是 O(n),完全失去了它的優勢。

引入紅黑樹后,但鏈表長度大于8時,就會轉換成紅黑樹,若鏈表元素個數小于等于6時,樹結構還原成鏈表。至于為什么是8,我看到過兩個說法,一個是因為紅黑樹的平均查找長度是log(n),長度為8的時候,平均查找長度為3,如果繼續使用鏈表,平均查找長度為8/2=4,這才有轉換為樹的必要。鏈表長度如果是小于等于6,6/2=3,雖然速度也很快的,但是轉化為樹結構和生成樹的時間并不會太短。另一個說法是根據泊松分布,在負載因子默認為0.75的時候,單個hash槽內元素個數為8的概率小于百萬分之一,所以將7作為一個分水嶺,等于7的時候不轉換,大于等于8的時候才進行轉換,小于等于6的時候就化為鏈表。兩種都有道理我覺得哪一種都是可以的。

當桶滿了的時候,HashMap會進行擴容resize,它是何時并且如何擴容的呢?

當桶的容量達到長度乘以負載因子的時候就會進行擴容,默認的負載因子為0.75。

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

首先,它會創建一個新的Entry空數組,長度是原數組的2倍。然后遍歷原Entry數組,把所有的Entry重新Hash到新數組。這里要進行ReHash的原因是我們知道下標的計算是跟長度有關的,長度不一樣了,那么index計算的結果自然也不一樣,因此需要重新Hash到新數組,rehash是一個比較耗時的過程。

接下來還是插入相關的問題,新的Entry節點在插入鏈表的時候,是怎么插入的?

這個問題我是在一篇博客上看到的,之前的確從未考慮過這個問題。Java8之前是頭插法,就是說新來的值會取代原有的值,原有的值就順推到鏈表中去,就像上面的例子一樣,因為寫這個代碼的作者認為后來的值被查找的可能性更大一點,提升查找的效率,在Java8之后,都是所用尾部插入了。 由于在擴容的時候會存在條件競爭,如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試著調整大小。用頭插法的話,假設原來鏈表是A指向B指向C,新的鏈表可能出現B指向A但A同時也指向B。用尾插的方法擴容保持鏈表元素原油的順序,就不會出現這種鏈表成環的問題了。

put的時候會先判斷是否碰撞,那么如何減少碰撞呢?

一般有兩個方法,一個是使用擾動函數,讓不同對象返回不同hashcode;一個是使用final對象,防止鍵值改變,并采用合適的equeals方法和hashCode方法,減少碰撞的發生。

那么對于get方法因為比較簡單就不做太多詳細解釋,其實就是根據key的hashcode算出元素在數組中的下標,之后遍歷Entry對象鏈表,直到找到元素為止。

SynchronizedMap

這里額外再提一個Map,也是解決HashMap多線程安全的一種方案。那就是Collections.synchronizedMap(Map)。它會返回一個線程安全的SynchronizedMap的實例。它里面維護了一個排斥鎖mutex。對于里面的public方法,使用了synchronized對mutex進行加鎖。多線程環境下串行化執行,效率低下。

SynchronizedMap

上面就是一些關于HashMap的一些簡單的知識點,我這里整理的其實也不算太多但還是很實用的(我就知道這么多)。

HashTable

關于HashTable其實說不了太多,因為說實話反正我是從來沒用過。都知道它線程安全,但它用的手段很簡單粗暴。涉及到修改的地方使用了synchronized修飾,以串行化方式運行,效率比較低下。它和上面說的SynchronizedMap實現線程安全的方式很接近,只是鎖的對象不一樣。

ConcurrentHashMap

那么還是來談談另一個還挺常見的ConcurrentHashMap,它現在的數據結構和原來的也是不一樣的,早期也是數組+鏈表,現在是數組+鏈表+紅黑樹。

在Java8以前,由Segment數組、HashEntry組成,通過分段鎖Segment來實現線程安全,ConcurrentHashMap內部維護了Segment內部類,繼承了RetrantLock。它將鎖一段一段的存儲,給每一段數據分配一個鎖,也就是segment,當一個線程訪問一個鎖時,其他線程也可以訪問其他segment的數據,不會被阻塞,默認分配16個segment。也就是理論上它的效率比HashTable提高了16倍。而HashEntry跟HashMap差不多,只是它用volatile修飾了數據的value還有下一個節點next。

到了Java8,它就不再是使用Segment分段鎖,而是使用了CAS+synchronized來保證線程安全。

ConcurrentHashMap_Java8

synchronized鎖住當前鏈表或者紅黑樹的首節點,這樣只要哈希不沖突,就不會出現并發問題。

/**
 * Table initialization and resizing control.  When negative, the
 * table is being initialized or resized: -1 for initialization,
 * else -(1 + the number of active resizing threads).  Otherwise,
 * when table is null, holds the initial table size to use upon
 * creation, or 0 for default. After initialization, holds the
 * next element count value upon which to resize the table.
 */
private transient volatile int sizeCtl;

ConcurrentHashMap和HashMap的參數差不多,但有些特有的,比如sizeCtl。它是哈希表初始化或擴容時的一個控制位標識量,負數代表正在初始化或正在擴容操作。同樣的,我們也看看它的put操作。

final V putVal(K key, V value, boolean onlyIfAbsent) {
        // 鍵值都不能為null
        if (key == null || value == null) throw new NullPointerException();
        //計算key的hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
        // 數組元素的更新,使用CAS,所以需要不斷失敗重試
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                // 初始化
                tab = initTable();
            //找到f,即鏈表或者紅黑樹的頭節點,沒有就添加
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //CAS添加,失敗break
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            // 如果正在移動元素,就協助擴容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                //發生hash碰撞,鎖定鏈表或者紅黑樹的頭節點f
                V oldVal = null;
                synchronized (f) {
                    // 判斷f是否時鏈表的頭節點
                    // fh就是頭節點的hash值
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            //如果是,初始化鏈表的計數器
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                //節點存在就更新
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                // 不更新就插入
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        //頭節點是紅黑樹的頭,用紅黑樹的方式插入
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    //鏈表長度達到了8,則轉換成樹結構
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // ConcurrentHashMap的size+1
        addCount(1L, binCount);
        return null;
    }

上面是整段代碼的解釋,總結一下就下面幾個步驟:

  1. 判斷Node[]數組是否初始化,沒有則進行初始化操作
  2. 通過hash定位數組的索引坐標,是否有Node節點,如果沒有則使用CAS進行添加(鏈表的頭節點),添加失敗則進入下次循環
  3. 檢查到內部正在擴容,就幫助它一塊擴容
  4. 如果頭節點f!=null,則使用synchronized鎖住f元素(鏈表/紅黑二叉樹的頭元素)
    • 如果是Node(鏈表結構)則進行鏈表的添加操作
    • 如果是TreeNode結構則執行樹添加操作
  5. 判斷鏈表長度已經達到臨界值8,這個8可以自己調整,當節點數超過這個值就把鏈表轉換為樹結構

使用這種方式相對于Segment而言,鎖拆的更細。首先使用無鎖操作CAS插入節點,失敗則循環重試。若頭節點存在,則嘗試獲取頭節點的同步鎖再進行操作。至于get操作也比較簡單,也是根據hashcode尋址,如果就在桶上就直接返回值,不是的話就按照鏈表或者紅黑樹的方式遍歷獲取值。

HashMap、HashTable以及ConcurrentHashMap的區別

大致講述了他們三個的基礎知識,那么來總結下它們區別。這里做了個list大家可以看看。

  • HashMap線程不安全,數組+鏈表+紅黑樹
  • HashTable線程安全,鎖住整個對象,數組+鏈表
  • ConcurrentHashMap線程安全,CAS+同步鎖,數組+鏈表+紅黑樹
  • HashMap的key,value均可為null,其他兩個不可以
    • HashTable使用的是安全失敗機制(fail-safe),這種機制會使你此次讀到的數據不一定是最新的數據。如果你使用null值,就會使得其無法判斷對應的key是不存在還是為空,因為你無法再調用一次contain(key)來對key是否存在進行判斷,ConcurrentHashMap同理
  • HashMap 的初始容量為:16,Hashtable 初始容量為:11,兩者的負載因子默認都是:0.75。
  • 當現有容量大于總容量 * 負載因子時,HashMap 擴容規則為當前容量翻倍,Hashtable 擴容規則為當前容量翻倍 + 1。
  • HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
    • 快速失敗(fail—fast)是java集合中的一種機制, 在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改),則會拋出Concurrent Modification Exception。

以上就是關于Map相關的一些知識點,里面很多引申的知識點我都沒有再往深里說,比如里面使用到的紅黑樹數據結構,volatile關鍵字,CAS等等,這個在后面會針對相應的知識點再繼續梳理。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容