HashMap原始碼解析和設計解讀

紅細胞司令發表於2021-06-14

HashMap原始碼解析

​ 想要理解HashMap底層資料的儲存形式,底層原理,最好的形式就是讀它的原始碼,但是說實話,原始碼的註釋說明全是英文,英文不是非常好的朋友讀起來真的非常吃力,我基本上看了差不多七八遍,還結合網上的一些解析,才覺得自己有點理解。

​ 我先畫了一個圖,HashMap資料儲存的結構圖,先有個理解,再來看看下面的程式碼解析可能會好理解些。

HashMap的資料結構

image-20210403232719038

HashMap靜態屬性


    /**
     * The default initial capacity - MUST be a power of two.
     * 預設的陣列容量16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * 最大的容量
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     * 負載因子,用於擴容,當陣列的容量大於等於 0.75*DEFAULT_INITIAL_CAPACITY時,就要擴容
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 每個桶中資料結構轉變為樹的連結串列長度界限,當連結串列長度為為8時,轉成紅黑樹
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 當樹的結點等於小於等於6時,又轉會連結串列
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    static final int MIN_TREEIFY_CAPACITY = 64;

儲存的物件


    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    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資料儲存的形式,以一個Node節點物件儲存。而且還是靜態的。

普通屬性


    /**
     * 這就是HashMap儲存資料的本質容器,陣列,只不過是Node陣列,裡面儲存的是
     * Node節點
     */
    transient Node<K,V>[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set<Map.Entry<K,V>> entrySet;

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

    /**
     * 這是用來標記HashMap結構變化的次數的
     */
    transient int modCount;

如何存放資料

構造器和其他的一些方法就先不看了,重點了解下put方法底層樹如何來存放資料的

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

一層套一層,接著看putVal方法,這裡有一個hash(key),是將每個key生成一個hash值,用處是用來尋找儲存的位置

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
// 這 hashCode是一個本地方法,不管,那為什麼又要和無符號右移16位做異或運算呢?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    	// tab是用來操作儲存容器的,p是儲存的節點,n是陣列的長度,i是陣列位置下標
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            // 陣列容器初始化resize(),這個方法後面重點看,就是重新設定容量大小
            n = (tab = resize()).length;
    	// 如果找到的這個位置的Node節點為null,就直接new一個,將put的資料存放進來
    	// (table.length-1)&hash 是HashMap中確定陣列存放位置的方式
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 進到這裡,就說明出現了hash碰撞,生成的hash值一樣了,找到的位置已經有值了,p不為null
            // e是一個臨時節點變數
            Node<K,V> e; K k;
            // 如果hash值一樣,key也一樣,那就是覆蓋嘛,直接吧p給e,到下面或進行value的新舊替換
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果key不一樣,判斷是否是樹節點了,就用樹的增加節點方法,這裡我們先不研究
            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);
                        // 如果已經大於等於7了,就轉成紅黑樹
                        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;
                }
            }
            // 到這裡就是key相同的時候,新舊值替換
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);// 這個是LinkedHashMap才會用到,HashMap不用管
                return oldValue;
            }
        }
        ++modCount; // 修改記錄,這個只有當陣列新增了值才會到這裡,想上面只增加再同一個桶中的連結串列後,不會加一
        if (++size > threshold)
            resize(); // 擴容
        afterNodeInsertion(evict);
        return null;
    }

到這裡可以再翻到上面去看下儲存資料結構的圖,可能更好理解

putVal方法整體概括下邏輯應該分為以下幾點:

  • 首先先根據key得到hashCode值,確認存放的陣列位置
  • 該位置如果沒有值,就直接new一個Node節點存放進去
  • 該位置有值,又分為兩種情況
    • 如果key相同,則就是替換嘛,把這個key所對應的value給換成新的
    • 如果key不相同,則就是hash衝突,就需要增加該桶的連結串列長度了,將該資料增加到該連結串列的後邊。

注意:這裡沒有討論變成紅黑樹,臨界值8的情況

擴容機制

final Node<K,V>[] resize() {
    	// 定義一個Node[]
        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
            // 這裡是最開是初始化的時候,預設容量是16,預設的閾值時0.75*16=12
            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 = newThr;
        
    ……#############這下面才是擴容機制中重新確定每個Node節點所在的位置的精髓所在,單獨講#################
    }

為什麼每次擴容設定成前一次的2倍?

我們再putVal方法中看到,HashMap確定陣列儲存的下標是這樣確定的:(table.length-1) & hash,與運算是二進位制的運算,都是1則為1,否則為0。

因為table的容量總是2的倍數,所以換成二進位制時全部都是1後面都是0,比如16,二進位制就是10000,32就是100000,那麼減一之後它的二進位制就都會是1,比如15的二進位制是1111,31的二進位制11111,這樣再與hash值做與運算的時候離散性會更好,降低hash碰撞

為什麼hash計算需要右移16位做異或運算

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  1. 首先需要確定的一點是,用hash值是為了快速檢索定位,但是需要好的hash演算法去減少hash衝突,提高離散性。以這點作為基礎,猜想上面這樣演算法是因為降低hash衝突,提高離散性。
  2. (table.length-1) & hash還是再看確定bin下表的方式,table的長度和hash值做與運算,在實際中table的長度並不會特別大,2的16次方都比較少,更何況是32位,所以如果直接用hashCode()方法生成的hash值做運算,其實大概率只用到了後16位,前十六位就浪費了。所以(h = key.hashCode()) ^ (h >>> 16)先用右移16位做異或運算,其實就是後16位和前16位做異或運算,這樣在確定下標中hash的32位都參與了運算,這樣既就增加了隨機性,提高了離散性。
  3. 為什麼是做"異或"運算呢?因為相比於“與”運算和“或”運算,“異或”運算生成0和1的概率是相等的,沒有偏重哪一個。
@SuppressWarnings({"rawtypes","unchecked"})
// 確定完容量後,new一個新的陣列,將新的陣列賦給table這個陣列
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
    // 這裡就是來確定老的table中每個桶中每個Node在新的table中的位置是否要移動,怎麼移動
    // 這裡如果不明白為什麼需要在新表中重新確定位置的,看下面的圖解可能好理解
    for (int j = 0; j < oldCap; ++j) {
        // 臨時節點e
        Node<K,V> e;
        // 取出第j個位置的節點給e
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            // 這說明在這個原來的j位置上不存在hash碰撞,就直接放到新table中相同的位置就行
            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 { // preserve order
                // 進到這個else就說明在原來j位置存在hash碰撞,形成連結串列了,e.next不為null
                // 低位節點,loHead地位節點的head,loTail地位節點的尾部
                Node<K,V> loHead = null, loTail = null;
                // 高位節點
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                // 迴圈遍歷這個桶的連結串列的每個節點,重新弄確定位置
                do {
                    next = e.next;
                    // 這個條件的&運算非常巧妙,如果為0,就說明這個節點不需要移動位置,在新table中也是j這個位置的桶中
                    // 這裡這個&運算的結果不為0就為1,下面詳細介紹
                    if ((e.hash & oldCap) == 0) {
                        // 這裡是理清節點的關係,相當於這裡是這個桶中所有不需要移位的Node,又要形成一個新的連結串列
                        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.next = null;
                    // 這裡就是低位節點,不需要移位,在新的table中還是j位置
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                    // 高位節點在新的table中的位置就變成 (原來的位置+原來的容量)
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}
return newTab;

高位低位節點說明:

上面的程式碼中 e.hash & oldCap 這個結果為什麼不是0就是1呢?

首先要明白為什麼多個Node會存到同意的桶中,也就是在尋找陣列位置的時候找到了同一個。

確定桶位置的計算方式是:(table.length-1) & hash,因為table.length-1的二進位制全是1,在和hash作&運算時如果位數不夠,就在前面補0,這時(table.length-1)的二進位制中後面的1視為低位,前面的0就是高位,所以這個運算,高位算出來全是0,所以主要看低位。

現在看 e.hash & oldCap,oldCap因為時2的倍數,所以二進位制都是1000……的形式,1視為高位,那麼所以這個運算中,e.hash的低位不用管,高位可能是1也可能是0,所以運算的結果只能是0或1

那麼為什麼e.hash高位時1就要移位,是0就不需要呢?

現在已經清楚確定桶位置的計算方式是:(table.length-1) & hash,例如原本容量是16,那麼(table.length-1) 二進位制就是1111,hash是11111,則在原來的位置是 01111 & 11111 = 15,但是現在擴容之後為32了,現在(table.length-1)二進位制為11111,那現在的位置就是11111 & 11111 = 31,改變的位置=原來的位置+原來的容量。

其實原因就是因為節點Node的hash沒有變化,可是容量變了,所以如果節點Node的高位為1就是計算出與之前不一樣的值,確定的位置當然要發生變化。

原來的資料儲存

擴容後的資料儲存

非執行緒安全

jdk1.8中的HashMap執行緒不安全主要是在多執行緒併發的時候出現資料覆蓋的情況,在putVal方法中

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    	// tab是用來操作儲存容器的,p是儲存的節點,n是陣列的長度,i是陣列位置下標
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            // 陣列容器初始化resize(),這個方法後面重點看,就是重新設定容量大小
            n = (tab = resize()).length;
    	// 如果找到的這個位置的Node節點為null,就直接new一個,將put的資料存放進來
    	// (table.length-1)&hash 是HashMap中確定陣列存放位置的方式
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

看上面的程式碼,if ((p = tab[i = (n - 1) & hash]) == null),如果找定位到陣列的位置節點為空的話,就直接new一個節點存資料,當多執行緒時候,出現hash衝突,都定位到同一個位置,當一個A執行緒進來這個if,還沒來得及存資料,另一個B執行緒進來搶先存了資料,可是A再去存資料的時候,已不會判斷是否有值了,就直接覆蓋了,所以就將B執行緒的資料覆蓋了。

相關文章