HashTable、HashMap與ConCurrentHashMap源碼解讀

HashMap 的數據結構

​ hashMap 初始的數據結構以下圖所示,內部維護一個數組,而後數組上維護一個單鏈表,有個形象的比喻就是想掛鉤同樣,數組腳標同樣的,一個一個的節點往下掛。html

hashMap初始數據結構圖

​ 咱們能夠看源碼來驗證下,HashMap 的數據結構是否是真的是像上面所說是數組加鏈表的形式:java

//此處略過其餘代碼,只截取出了hashMap的數組結構相關的數組與鏈表
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;
    
 	/* ---------------- Fields -------------- */

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
     //這個是hashMap內部維護的數組
    transient Node<K,V>[] table;
    
    
    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
     //這個是數組元素的節點類,next的屬性表示下一個節點,即數組的節點元素維護的下一個節點的元素,那不是就是鏈表嗎
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //數組的腳標值,下面會詳細描述這個內容
        final K key; //map的key
        V value; //map的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;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

​ 經過源碼可知,HashMap 的數據結構正如上文所述,是一個數組加鏈表的形式存儲數組,那麼數組的角標是怎麼計算的呢?若是是你來設計,你會怎麼去設計這個角標的計算方式呢?node

​ 在沒看源碼以前,我作了一個猜測,就是數組的角標我猜測是按照下面的計算方式計算的:算法

  • 既然是 HashMap,那確定有個 hashCodebootstrap

  • 而後經過 key 值的 hashCode 與數組的長度取模數組

  • 取模以後,數值同樣的,就往數組的節點上面往下掛安全

    上面是個人猜測,可是 HashMap 的數組角標的實現真的是這樣嗎?咱們進入下一節去探究數據結構

hash 值的計算

​ 既然要看腳標值的計算,那咱們確定要看 HashMap 的 put 方法,由於在 put 方法裏面確定要計算出腳標的值,而後才能把數據存放到數組裏面去嘛,因此咱們直接看 put 的源碼:多線程

/**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
	//此處是HashMap的put方法的源碼,這個put方法又調了另外一個putVal的方法,咱們看一下putVal的方法
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

 	/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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 {

​ 這裏咱們關注 ***tab[i] = newNode(hash, key, value, null);這句代碼,前面咱們看到了tab就是數組,那說明這句代碼就是給節點賦值,那麼i就是數組的角標那這個i***是怎麼計算的呢?併發

​ 看他上面的一句判斷***(p = tab[i = (n - 1) & hash]即這個i是經過(n - 1) & hash計算出來的,n = tab.length這個n是數組的長度,就是說數組的角標是經過數組的長度-1與上這個hash,這個跟咱們以前猜測的而後經過hashCode與數組的長度取模就不一致了,那這裏咱們先保留着這個問題,先看一下hash的計算,從上面代碼中,能夠知道,hash值是經過調用hash(key)***方法調用獲得。

​ 這裏我將計算 ***hash***的方法,單獨抽離出來外面寫,以下:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

​ key 就是 map 調用 put 方法,put 進來的 key 值,看上面這個方法,前面判空以後返回0的你們一眼就看明白了,主要關注後面的內容,***(h = key.hashCode()) ^ (h >>> 16)這句代碼前半部分很明白,就是取key的hashCode值賦給h,(^這個符號表示異或,>>>***表示無符號右移),而後與h右移16位後的值進行異或操做。

爲何要這樣計算去計算 hash 呢?這樣計算 hash 又最終與數組的腳標有什麼聯繫呢?

​ 下面我來畫張圖,來理順這一塊的計算,看下圖:

hashCode異或運算計算hash

這樣作能夠實現 hashCode 的值,高低位更加均勻地混到一塊兒,結合上面數組腳標***(n - 1) & hash的運算,因爲 HashMap 數組的大小老是 2^n,即 (2^n-1) 獲得的值轉化爲二進制,如: 000011十一、00011111 (捨棄前面高位)等,與 hash 的值進行與運算,這樣又保證每個腳標i***值都能在數組的長度內。這裏可能有點難理解,舉個例子來講明一下。

​ 就是 hashMap 的數組初始大小是 16,那 length-1 的值就爲 15,15 的二進制值是.... 0000 1111,此時上面hash 值 363766277 的二進制位 0001 0101 1010 1110 1010 0010 0000 0101,這兩個數進行與運算時,因爲 15 的前面高位都爲 0,因此進行與運算的值最終都不可能大於15,像這個例子,最終的值爲 0101 爲 9,這樣就保證了每個腳標***i***值都能在數組的長度內。

那麼這裏就有一個疑問了,爲何不直接採用與數組長度取模的方式,直接取得腳標值,而是先去異或,再與運算去計算腳標值?

​ 主要有兩個緣由:

1.用位運算,效率更高

2. hashCode 的高低位異或運算,讓高低位更加均勻的混合到一塊兒,可使得在 put 元素時,能夠減小哈希碰撞

減小哈希碰撞纔是最主要的緣由。那什麼是哈希碰撞呢?

​ 咱們知道 HashMap 的數組結構不是數組加鏈表嗎?那數組跟鏈表有什麼特色?咱們都知道數組是查詢快、增刪慢,鏈表是查詢慢、增刪快。

​ 這也很容易理解,鏈表嘛,只記錄着下一個節點的值,又沒有腳標,若是你這個鏈表很長(雖然在這裏最長不會超過8,後面會講到),你查找的一個元素恰好在最後一個,那不是在定位到數組腳標之後找到鏈表的第一個節點,而後往下一直遍歷查找到最後一個才找到咱們要的元素,這樣效率不就很慢了嗎,因此若是咱們直接對 hashCode 跟數組的長度進行取模,計算出的 hash 值可能會碰撞高,就會使得數組單個節點的鏈表很長很長,而這樣子 HashMap 的查詢效率就不好,而 hashCode 的高低位異或運算,可讓高低位更加均勻的混合到一塊兒,減小哈希碰撞,從而提升 HashMap 的查詢效率。

一句話總結,失敗的 hashCode 算法會致使 HashMap 的性能由數組降低爲鏈表,因此想要避免發生碰撞,就要提升 hashCode 結果的均勻性。

數組的擴容

數組的初始化長度

​ 在上一節的時候,咱們講到了 HashMap 的長度老是 2^n 這句話,咱們怎麼知道呢,咱們能夠從源碼中找到這一設定,那麼咱們首先先看一下,HashMap 數組初始的默認大小是多少呢,源碼中有這一句代碼

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

​ 可是,咱們不能光看這個常量值就說HashMap內數組的默認常量值就是 16 啊,咱們要繼續找到初始化的方法代碼,看他是否是初始值爲 16

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
	//上面的英文中說到,初始化或者翻倍數組的大小
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY; //當oldTab即爲table數組的長度,當oldTab長度爲0時,將DEFAULT_INITIAL_CAPACITY賦值給newCap,newCap即爲數組的新長度
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }

​ 咱們能夠查詢下 resize()方法的調用者,發現putVal()方法裏調用了這個方法

putVal方法調用resize()

​ 截圖中的代碼已經很清楚了,就是當 table 數組的長度爲 null 或長度爲 0 時,調用初始化***resize()方法,而後在resize()方法中也作了判斷,當table數組的長度爲 0 時,將新數組的長度賦值爲DEFAULT_INITIAL_CAPACITY***,

​ 因此 HashMap 中數組的初始化長度就是 DEFAULT_INITIAL_CAPACITY,等於***1 << 4***,等於 16

數組擴容的閾值

​ 上一節咱們知道數組的初始長度是 16,然而 16 的長度顯然不能知足咱們普通應用的開發,因此這裏就涉及到了數組的擴容。那要何時擴容,怎麼擴呢?

​ 咱們知道,**鏈表的查詢效率確定比數組的查詢效率低,因此要提升 HashMap 的查詢效率,咱們確定要數據儘量多的往數組上存數據,而不是延長鏈表的長度。**那是否是存滿以後再作擴容呢?比方說數組初始化 16,等到存滿 16 的時候或者第 17 個進來的時候,開始擴容呢?

​ 咱們能夠先分析一下,而後再來看源碼。當數組的元素都放滿了,而後這時候來擴容,擴容以後,數組元素的腳標值就得從新計算,即 rehash ,好比原來是計算hash用的數組長度 16,擴容以後數組長度變成了 32,這時候***(n - 1) & hash計算腳標的值就不正確了,那你數組都存滿了,那不是數組的每一個元素都得從新計算腳標i***值,因此這種作法是否是不合理?

hashMap初始數據結構圖

​ 因此這裏就有一個數組擴容的閾值,就是說,當數組的長度達到某個值或某個條件時,數組就開始擴容,而這裏的某個值或某個條件就是咱們所說的數組擴容的閾值。

​ 那麼這個閾值具體是多少呢?下面咱們來探究源碼,既然要找到擴容的閾值,那咱們不外乎要從兩個方法入手去找,一個就是***put()操做的時候,一個就是擴容resize()的時候。由於我已經找過了,我就直接去put()方法裏面找了,resize()方法後面會細講,這裏就講put()***方法。

//這裏put方法只調用了putVal方法,那咱們就直接看putVal方法
	public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

	//我解釋下這個方法裏面,大概的操做
    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; //這一步以前分析過了,就是判斷數組爲null或長度爲0時,對數組進行擴容
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null); //這一步其實也很清楚了,就是根據hash值計算出數組的腳標,而後判斷數組的該腳標的元素是否爲空,爲空的話就把put進來的數據封裝成節點賦值進數組
        else {
            //根據上面的兩個判斷,那麼走到這裏的代碼就是說,數組不爲空,並且put進來的key計算所得的腳標節點也不爲空,走這一塊邏輯(實際上這塊邏輯也跟擴容的閾值無關,只是單純的判斷而後加節點的操做,可是我仍是解釋下這裏的代碼)
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;//這裏的意思是說,若是hash值相同,key值也相同,那麼就說明此時put操做的元素在數組從存在,這覆蓋該節點
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //這裏是判斷節點類型是不是樹類型,爲何會是樹類型呢?不是說是HashMap是數組加鏈表嗎?後面的章節會詳細講到,這裏暫且跳過
            else {
                //代碼走到這裏,就說明此時put進來的元素,對應的數組腳標是個鏈表
                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;
                    }
                    //這裏判斷hash值與key值是否都相同,若是是即說明map中存在該key-value,此時跳出循環
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //此邏輯是判斷hash值與key值是否都相同跳出循環後,將新值覆蓋舊值,而後將舊值返回出去
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;//hashMap內部維護的一個修改的次數,有興趣瞭解的話能夠看源碼裏面對這個屬性的翻譯
        if (++size > threshold)
            resize();//擴容,在此以前的代碼,都是判斷以後進行添加覆蓋節點的操做,此處是插入新節點以後判斷是否擴容,因此這裏的條件就是咱們找了這麼久的擴容的閾值!!!
        afterNodeInsertion(evict);
        return null;
    }

​ 走讀完上面的代碼,咱們能夠得知 if (++size > threshold),以下代碼可知 ***size***實際上就是HashMap集合的鍵值對數,即長度,因此就是說,當 ***size***的大小超過 ***threshold***時,開始進行擴容,也即 **threshold就是進行擴容的閾值。那麼這個閾值的大小是多少呢?

/**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

 	/**
     * Returns the number of key-value mappings in this map.
     *
     * @return the number of key-value mappings in this map
     */
    public int size() {
        return size;
    }

​ 繼續走讀源碼,找到 ***resize()***方法處,

resize方法初始化數組長度與閾值

/**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
	/**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

​ 當 HashMap 數組爲 null 或長度爲 0 時,初始化***threshold的值,DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY*DEFAULT_INITIAL_CAPACITY爲數組的初始長度,DEFAULT_LOAD_FACTOR**是閾值的計算因子,他的值是 0.75f,意思就是當 HashMap 的 size 超過數組長度的75%的時候,就進行擴容

​ 咱們能夠繼續走讀源碼來驗證是否數組長度超過 75% 就進行擴容,仍是上面那張圖的源碼,我把其中一段給抽離出來,以下:

if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //此處的意思是說,當數組的長度是大於0的時候,並且數組擴容一倍以後,小於默認配置的最大值時,而且大於初始化數組的長度,則執行if下面的代碼,那就是說,擴容以後若是沒超過最大值,就走這個邏輯
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //而這個邏輯的代碼意思,就是閾值threshold增大一倍(左移一位)
                newThr = oldThr << 1; // double threshold
        }

​ 那麼,咱們就知道了,當數組擴容時,**threshold的值也會增大一倍,那麼下一次擴容時,也是HashMap的 size 超過數組長度的 75% 的時候,就進行擴容。

擴容

​ HashMap 內數組的擴容是將數組的長度左移一位,在二進制運算中,左移一位實際上就是將數值擴大一倍。並且咱們也知道,擴容的源碼就是***resize()這個方法,因此這一章節就來重點解讀resize()***方法的源碼

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    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; //聲明newCap-擴容後的數組長度,newThr-擴容後的閾值
        if (oldCap > 0) {
            //這一部分邏輯其實上一節已經講過了,在這裏我就大體說一下,就是若是這是擴容前數組長度已經達到了默認配置的最大值時,那麼就不擴容了,直接返回原數組,不然,數組擴容一倍,閾值也增大一倍
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr; //這個判斷不是正常建立Map集合走的邏輯,這裏能夠跳過這句代碼
        else {               // zero initial threshold signifies using defaults
            //這一步的代碼前面也解釋過了,就是當數組長度爲0,初始化數組長度與擴容的閾值
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //將新的擴容後的閾值賦值給threshold
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //建立新的數組
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                //遍歷舊的數組,下面的內容就是將舊數組從新散列將數據保存到新數組
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //若是節點下沒有下一個節點,就是說不是鏈表僅是單個節點走這個邏輯
                    if (e.next == null) 
                        newTab[e.hash & (newCap - 1)] = e; //根據hash值與新數組的長度進行與操做,獲取新數組的腳標值,將節點存儲到新數組
                    else if (e instanceof TreeNode) //若是節點是樹節點,走這個邏輯,後面講鏈表的紅黑樹的時候會作解釋,這裏先跳過
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { //因此這一部分的邏輯,就是若是節點是鏈表,走這裏
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            //遍歷鏈表
                            next = e.next; //鏈表中的節點
                            if ((e.hash & oldCap) == 0) {
                                //這個判斷是理解這整個鏈表遍歷的關鍵,這裏也涉及到了前面講到的2^n-1對應二進制是0111xxxx的內容,咱們知道數組的長度老是2^n,因此oldCap的值實際上就是1000xxxx,而後hash & oldCap的操做,就是判斷oldCap高位的1與對應hash那一位的值是不是1,若是是0走這個邏輯,若是是1走下面的else代碼
                                //這裏,前面聲明的4個變量loHead, loTail, hiHead, hiTail中,lo的指的是低位,hi的指的是高位,走完這個do裏面的邏輯,就是將oldCap高位的1與對應hash那一位的值是0的存到loTail這個鏈表中,高位是1的存到hiTail這個鏈表中!!!!
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            //將上面遍歷以後低位的loTail存放到新數組的原腳標處
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            //將上面遍歷以後高位的hiTail存放到新數組的擴容後的腳標處
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

​ 在上面的源碼解讀中,咱們可能會留有一個問題,就是爲何擴容後,對數組中的鏈表還要作 (e.hash & oldCap) == 0的判斷?

​ 實際上這部分邏輯是爲了提升HashMap的查詢性能,由於數組擴容後,節點要從新散列,那麼節點上面的鏈表固然也最好要作到均勻的分佈,減小單個數組節點上的鏈表長度,變相的提升了查詢性能。因此,源碼的邏輯是在擴容後將低位的 loTail 存放到新數組的原腳標處,高位的 hiTail 存放到新數組的擴容後的腳標處(jdk1.8新設計)

jdk1.8 hashMap擴容例圖

注:有同窗可能會糾結於,爲何代碼中高位的鏈表是直接 j + oldCap的腳標,不須要從新計算hash與上新數組長度計算嗎?其實這是一個簡單的數學問題而已,你本身舉個例子計算一下就能夠明白,結果是同樣的

鏈表的「擴容」

​ 前面的章節已經對 HashMap 數組的擴容及其從新散列的內容講完了,這一章節的內容來說一講鏈表的"擴容"。根據前面的內容,咱們瞭解到,若是鏈表的長度愈來愈長,HashMap 的查詢效率也會隨之下降。因此單純的對鏈表長度的增長,顯然是不可取的。

​ 因此在 HashMap 中,對於鏈表實際上並無擴容操做。在本文開頭列出的 Node 節點的源碼中也能夠看到,內部並無維護一個size或者length的屬性,也沒有一個去獲取 length 或 size 相關的方法,因此本章節主要闡述的內容,是鏈表結構向樹狀結構的轉化

####單鏈表-->紅黑樹

​ 在前面「數組擴容的閾值」章節的時候,我曾解讀過 putVal 方法的代碼,在解讀過程當中,我跳過了兩次代碼邏輯,在這一章節我就來詳細的解讀這兩處邏輯

putVal方法未解讀的兩處邏輯

​ 咱們先看 for 循環遍歷處的代碼,此處的遍歷的內容是 HashMap 是 put 操做節點是爲鏈表時的邏輯:首先這裏先判斷鏈表的next節點是否爲空,爲空則將 put 操做的 key-value 封裝爲 node 對象,賦值給next節點,而後下一步的判斷if (binCount >= TREEIFY_THRESHOLD - 1)是這裏的關鍵,TREEIFY_THRESHOLD 這個是什麼呢?THRESHOLD 這個單詞是否是看着有點眼熟,在前面將數組擴容的閾值的時候,是否是用的這個單詞,那在這裏的TREEIFY_THRESHOLD 會不會就是鏈表結構轉樹狀結構的閾值呢?

​ 經過上面這段代碼的上下文,咱們知道 binCount就是鏈表的長度**(注意:這裏是從 0 開始的)**,而TREEIFY_THRESHOLD 看下面的源碼,默認值是 8,意思就是說當鏈表的長度,大於等於 8 時,就執行treeifyBin(tab, hash);

/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

treeifyBin(tab, hash);方法的內容是作什麼呢?

/**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
	//翻譯大概的意思就是,在給定hash的節點處替換節點類型,除非是數組的長度過小了,才進行resize操做
	//總結就是說,並非鏈表的長度超過了默認的閾值8時,就必定轉樹狀結構,還要判斷數組的長度是否已經通過了擴容
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //這裏就是上面翻譯說的判斷,MIN_TREEIFY_CAPACITY的值是64,就是說若是你的數組沒有通過擴容操做的狀況下,若是鏈表長度已經超過8了,此時不轉樹狀結構,而是進行數組擴容,數組擴容時會從新散列,將鏈表的節點均勻的分佈,查詢效率對比轉樹狀結構也要好,不得不佩服設計者的設計。
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            //此處代碼就是找到給定的hash的節點,將此節點的鏈表轉爲紅黑樹,下面的代碼主要是數據結構代碼的內容,有興趣的同窗能夠本身解讀,因爲時間緣由,我就不解讀這部分轉紅黑樹的代碼了
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

​ 在上面的源碼解讀中,咱們知道,並非鏈表的長度超過了默認的閾值 8 時,就必定轉樹狀結構,還要判斷數組的長度是否已經通過了擴容,就是說若是你的數組沒有通過擴容操做的狀況下,若是鏈表長度已經超過 8 了,此時不轉樹狀結構,而是進行數組擴容,數組擴容時會從新散列,將鏈表的節點均勻的分佈,查詢效率對比轉樹狀結構也要好。

​ 那麼在數組擴容後,鏈表長度也超過了 8,此時就進行轉紅黑樹的操做,那紅黑樹又是什麼呢?

hashMap鏈表轉紅黑樹

咱們知道鏈表的查詢時間複雜度最壞的狀況有多是 O(n) ,當你想要找到節點恰好是在鏈表的最後一個時,你就必須得遍歷完鏈表中全部的節點才能找到你要的值,查找效率過低。而紅黑樹的本質實際上是一棵平衡二叉查找樹,平衡二叉查找樹的特色就是左子節點小於等於父節點,右子節點大於等於父節點,因此他的查詢時間複雜度是 O(Log2n) ,比鏈表的 O(n) 效率就要高不少了。

​ 本章開頭說到的另外一處未解讀的putVal源碼,其實只是判斷是樹狀結構時,將節點按照紅黑樹的規則,put進樹中而已。

e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

HashTable 的數據結構

​ 在前面解讀的 HashMap 中,已經將HashMap的數據結構,還有put操做、擴容作了詳細的解讀,而其實 HashTable,只是在 HashMap 的基礎上,給各個操做都加上了 synchronized 關鍵字而已,這就是咱們常說的 HashTable 是線程安全的,而 HashMap 是線程不安全的,以下代碼。

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

    private transient Entry<?,?>[] table; //數組
    private int threshold; //數組擴容閾值
    private float loadFactor;
    
//鏈表實體類
private static class Entry<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Entry<K,V> next;
    
//put方法
public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

 //remove方法
 public synchronized V remove(Object key) {
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
 
 //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;
    }

​ **內部也是維護了一個數組與鏈表,而後在 put、get 等方法上都加上 synchronized 關鍵字,**那這樣就能確保 HashTable 在任何場景都是線程安全的嗎?

HashTable 是否在任何場景都是線程安全的?

​ 這裏有幾個場景:

(1)若 key 不存在,則添加元素

(2)若 key 存在,則刪除元素

我在這裏畫張圖來描述下多線程環境下的這兩個場景

hashtable在複合操做下的線程安全問題

存在這樣問題的緣由是複合操做的場景下,HashTable不是線程安全的,由於 HashTable 只是保證單個方法操做是原子性的,但在不保證原子性的複合操做下,HashTable 也存在線程安全問題。

ConCurrentHashMap 的數據結構

​ 咱們知道 HashTable 的性能比 HashMap 的差不少,由於 HashTable 在每一個操做方法上面都加了 synchronized 關鍵字,並且在複合場景下還存在線程安全問題,因此 HashTable 算是舊版本遺留下來的問題了,如今的實際開發中通常也不會去使用到 HashTable,可是在 jdk1.5 之後新增了 java.util.concurrent 包,在這個包下提供了不少線程安全又高性能的集合,其中就包含了本章的主角 ConCurrentHashMap

jdk1.7 分段鎖

​ 在 jdk1.5 之後到 jdk1.7 ,ConCurrentHashMap 在解決多線程場景下的線程安全問題,採用的是分段鎖的技術。

​ 咱們知道 HashTable 之因此性能低下,是由於其在 public 方法的實現上都加上了 synchronized 的關鍵字,即當任意一個 put 或 get 操做,都將整個 map 對象鎖住,只有等待持有鎖的線程操做結束,纔有機會得到鎖進行操做。

​ 這裏有一種場景,在 Map 的數組 table 中,線程1對 table[0] 進行 put 操做,而此時有線程2想對 table[1] 進行 put 操做,實際上二者的 put 操做互不干涉,而在 HashTable 的實現下,線程2只能等待線程1操做完成以後才能執行。那麼,咱們是否能夠這樣實現,當線程1對 table[0] 進行 put 操做時,對 table[0] 下的鏈表進行加鎖,而操做 table[1] 時,對 table[1] 的鏈表進行加鎖,各自那各自的鎖,這樣線程1在操做 table[0] 時,線程2也能夠操做 table[1]。

ConcurrentHashMap分段鎖結構圖

分段鎖採用的就是這種思想,在ConCurrentHashMap中維護着Segment[]的數組,這種實現方式把本來 HashTable 粗粒度的鎖實現,拆分紅一段一段的Segment鎖。

//jdk1.7的ConcurrentHashMap的源碼
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {  
	/**
     * Mask value for indexing into segments. The upper bits of a
     * key's hash code are used to choose the segment.
     */
    final int segmentMask;

    /**
     * Shift value for indexing within segments.
     */
    final int segmentShift;

    /**
     * The segments, each of which is a specialized hash table.
     */
    //Segment是繼承了可重入鎖的子類,因此在Segment的操做方法中,包含了tryLock、unLock等方法
    final Segment<K,V>[] segments;
    
    /**
     * Segments are specialized versions of hash tables.  This
     * subclasses from ReentrantLock opportunistically, just to
     * simplify some locking and avoid separate construction.
     */
    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        
        /**
         * The per-segment table. Elements are accessed via
         * entryAt/setEntryAt providing volatile semantics.
         */
        transient volatile HashEntry<K,V>[] table;
    }
}

​ 簡單理解就是,ConcurrentHashMap 維護一個 Segment 數組,Segment 經過繼承 ReentrantLock 來進行加鎖,因此每次須要加鎖的操做鎖住的是一個 Segment,這樣只要保證每一個 Segment 是線程安全的,也就實現了全局的線程安全。

ConcurrentHashMap的Segment結構

​ 以下,是 ConcurrentHashMap 的各個構造方法,可是實際上只有 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)該構造方法是真正完成初始化的方法,其餘的都是方法重載

public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;
        int ssize = 1;
        // 計算並行級別 ssize,由於要保持並行級別是 2 的 n 次方
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        // 咱們這裏先不要那麼燒腦,用默認值,concurrencyLevel 爲 16,sshift 爲 4
        // 那麼計算出 segmentShift 爲 28,segmentMask 爲 15,後面會用到這兩個值
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;

        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

        // initialCapacity 是設置整個 map 初始的大小,
        // 這裏根據 initialCapacity 計算 Segment 數組中每一個位置能夠分到的大小
        // 如 initialCapacity 爲 64,那麼每一個 Segment 或稱之爲"槽"能夠分到 4 個
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        // 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,這個值也是有講究的,由於這樣的話,對於具體的槽上,
        // 插入一個元素不至於擴容,插入第二個的時候纔會擴容
        int cap = MIN_SEGMENT_TABLE_CAPACITY; 
        while (cap < c)
            cap <<= 1;

        // 建立 Segment 數組,
        // 並建立數組的第一個元素 segment[0]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        // 往數組寫入 segment[0]
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

    public ConcurrentHashMap(int initialCapacity, float loadFactor) {
        this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
    }

  
    public ConcurrentHashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

    public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

    public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY),
             DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
        putAll(m);
    }

​ concurrencyLevel:並行級別、併發數、Segment 數,怎麼翻譯不重要,理解它。默認是 16,也就是說 ConcurrentHashMap 有 16 個 Segments,因此理論上,這個時候,最多能夠同時支持 16 個線程併發寫,只要它們的操做分別分佈在不一樣的 Segment 上。這個值能夠在初始化的時候設置爲其餘值,可是一旦初始化之後,它是不能夠擴容的。

​ 再具體到每一個 Segment 內部,其實每一個 Segment 很像前面介紹的 HashMap,不過它要保證線程安全,因此處理起來要麻煩些。

​ initialCapacity:初始容量,這個值指的是整個 ConcurrentHashMap 的初始容量,實際操做的時候須要平均分給每一個 Segment。

​ loadFactor:負載因子,以前咱們說了,Segment 數組不能夠擴容,因此這個負載因子是給每一個 Segment 內部使用的。

jdk1.8 的新特性:CAS

​ 在 jdk1.8 如下版本的 ConcurrentHashMap 爲了保證線程安全又要提供高性能的狀況下,採用鎖分段的技術,而在java8中對於 ConcurrentHashMap 的實現又變成了另一種方式----CAS

​ CAS的全稱是compare and swap,直譯過來就是比較與替換。CAS的機制就至關於這種(非阻塞算法),CAS是由CPU硬件實現,因此執行至關快.CAS有三個操做參數:內存地址,指望值,要修改的新值,當指望值和內存當中的值進行比較不相等的時候,表示內存中的值已經被別線程改動過,這時候失敗返回,當相等的時候,將內存中的值改成新的值,並返回成功。

​ 這裏也不去細講多線程、鎖、CAS這些內容,後續等有空再整理一篇文檔出來作詳細點的筆記,這裏只當作體會精神,理解思想便可。

​ 下面的代碼是摘自網上一篇文章的對 java8 中 ConcurrentHashMap 的源碼分析,也是爲了方便本身後續當筆記學習來看。

public V put(K key, V value) {
    return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 獲得 hash 值
    int hash = spread(key.hashCode());
    // 用於記錄相應鏈表的長度
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 若是數組"空",進行數組初始化
        if (tab == null || (n = tab.length) == 0)
            // 初始化數組,後面會詳細介紹
            tab = initTable();
 
        // 找該 hash 值對應的數組下標,獲得第一個節點 f
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 若是數組該位置爲空,
            // 用一次 CAS 操做將這個新值放入其中便可,這個 put 操做差很少就結束了,能夠拉到最後面了
            // 若是 CAS 失敗,那就是有併發操做,進到下一個循環就行了
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // hash 竟然能夠等於 MOVED,這個須要到後面才能看明白,不過從名字上也能猜到,確定是由於在擴容
        else if ((fh = f.hash) == MOVED)
            // 幫助數據遷移,這個等到看完數據遷移部分的介紹後,再理解這個就很簡單了
            tab = helpTransfer(tab, f);
 
        else { // 到這裏就是說,f 是該位置的頭結點,並且不爲空
 
            V oldVal = null;
            // 獲取數組該位置的頭結點的監視器鎖
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 頭結點的 hash 值大於 0,說明是鏈表
                        // 用於累加,記錄鏈表的長度
                        binCount = 1;
                        // 遍歷鏈表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 若是發現了"相等"的 key,判斷是否要進行值覆蓋,而後也就能夠 break 了
                            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;
                        }
                    }
                }
            }
            // binCount != 0 說明上面在作鏈表操做
            if (binCount != 0) {
                // 判斷是否要將鏈表轉換爲紅黑樹,臨界值和 HashMap 同樣,也是 8
                if (binCount >= TREEIFY_THRESHOLD)
                    // 這個方法和 HashMap 中稍微有一點點不一樣,那就是它不是必定會進行紅黑樹轉換,
                    // 若是當前數組的長度小於 64,那麼會選擇進行數組擴容,而不是轉換爲紅黑樹
                    //    具體源碼咱們就不看了,擴容部分後面說
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 
    addCount(1L, binCount);
    return null;
}

​ 對於 ConcurrentHashMap 的源碼解讀就到這裏,詳細的源碼解讀,能夠看這篇很牛的文章,我也是在寫本分析的狀況下,發現了Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析這篇文章,發現寫的比我還寫的詳細的多的多,因此若是以爲意猶未盡的同窗能夠去讀讀這篇源碼解讀。

相關文章
相關標籤/搜索