原始碼閱讀-HashMap

NERD發表於2018-08-15

title: 原始碼閱讀-HashMap date: 2018-8-16 0:34:42 tags: - HashMap - SourceCode - Java - 原始碼閱讀 categories: Java

寫在開頭

  1. 擴容是一個特別耗效能的操作,因此建議使用HashMap時,儘量指定一定大小的初始容量
  2. HashMap是執行緒不安全的,併發環境中建議使用ConcurrentHashMap
  3. JDK8中引入的紅黑樹優化了大量hash碰撞時的效能
  4. HashMap中的紅黑樹程式碼作者實在沒力氣看了,因此這篇文章不涉及內部紅黑樹分析
  5. 該篇文章純粹是作者個人觀點,並非官方權威,閱讀請勿迷信

資料結構

hashmap使用陣列+連結串列(紅黑樹)作為整體結構

節點資料結構

一般情況下,節點會使用如下程式碼構建結構,該結構為單連結串列

    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;
        }

        ...
    }
複製程式碼

JDK8中增加特性:當連結串列長度超過8時(等於也會觸發),會轉換為紅黑樹結構。紅黑樹需要了解的小夥伴可以看下這篇文章:一步一步資料結構-紅黑樹

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
    }
複製程式碼
整體結構

圖片來源美團技術部落格:Java 8系列之重新認識HashMap
hashMap記憶體結構圖
本質上,在一般情況下的HashMap就是一維陣列+單連結串列,其中一維陣列在這裡的作用個人感覺更像是指標,當使用者通過get獲取值的時候,先通過hash找到對應陣列位置,再通過陣列找到對應的連結串列,再進行連結串列遍歷找到完全符合的鍵值節點

資料操作

在初步瞭解HashMap長相之後,我們可以通過基本操作來了解它的工作過程。

hash與索引計算

HashMap通過Key的hash值找到陣列的對應位置,因此我們需要先行了解hash的運算規則,因使用的是key對應物件的hashCode()函式,因此在使用自定義物件作為key時,需格外注意。
運算子相關介紹:
>>> 右移運算子
^ 按位異或運算子

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

索引計算通過(n - 1) & hash,引用美團技術部落格中原文(n即length):

這個方法非常巧妙,它通過h & (table.length -1)來得到該物件的儲存位,而HashMap底層陣列的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率。

&

    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;
        // 此處是對HashMap容量判斷,屬於邊界異常判斷
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 需注意的是,上一小節是介紹hash的運算過程,轉換為陣列索引是`(n - 1) & hash`
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 索引位置連結串列為空,直接插入
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 此處判斷的是對應索引位置單連結串列的頭結點,需注意(k = p.key) == key || (key != null && key.equals(k)))
            // 因key可以為任意物件,因此==在某些時候不能作為判斷相等
            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) {
                        // 無相同key節點,直接尾插法插入新節點
                        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
                // 相同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;
    }
複製程式碼
    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        // 避免陣列為空(即HashMap為空),陣列索引對應連結串列為空
        // (n - 1) & hash   已經計算索引位置
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            // 找對應節點
            // 判斷索引對應連結串列頭結點
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            // 新增if判斷,避免邊界問題(個人猜測主要是避免紅黑樹的情況)
            // 因為單連結串列的邊界避免可以通過迴圈條件控制,此處使用的是do{}while迴圈,可以使用while改變條件判斷時機
            // 此處都是個人猜測,極具爭議性,非正規解釋
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    // 紅黑樹節點交由紅黑樹內部方法定位所尋找節點
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            // 找到節點後進行刪除操作,判斷條件避免不存在節點情況和需要嚴格匹配值情況
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    // 紅黑樹交由內部處理
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    // 此處情況是,連結串列頭結點就是所尋節點,因此node與p相等
                    tab[index] = node.next;
                else
                    // 單連結串列刪除操作
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);// 意義不明
                return node;
            }
        }
        return null;
    }
複製程式碼
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // 避免為空條件三連
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 連結串列頭結點判斷
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 當連結串列不僅僅只有頭節點
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    // 紅黑樹交由內部處理
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 遍歷連結串列尋找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
複製程式碼
小結

所有操作在需要對連結串列進行判斷的情況下,在JDK8中,都是先判斷頭結點,再判斷是否存在後續節點,然後紅黑樹交由紅黑樹內部方法處理,單連結串列遍歷通過do{}while進行迴圈遍歷。對比JDK1.7我們可以看出程式碼變化

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
複製程式碼

JDK7中,因為沒有引入紅黑樹優化,因此連結串列都為單連結串列,因此遍歷都是通過for迴圈,後續節點非空判斷也在for迴圈的判斷條件中。因此,此處個人大膽總結,該變化由紅黑樹引起。可能由於instanceof存在一定程度的效能損耗,因此,先進行首節點判斷以儘可能的避免首節點就是所尋節點從而不用使用instanceof可以提升一定程度的效能(存在爭議)。

自動擴容

HashMap的建立
    // 可自定義初始容量和載入因子
    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);
    }

    // 僅指定初始容量,使用預設的載入因子0.75
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    // 使用預設的載入因子初始化,未指定初始容量,將會使用預設初始容量16
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    // 根據已有map匯入至新的hashmap中,使用預設載入因子0.75
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
複製程式碼

以上建立HashMap的方式中,需要注意指定初始容量的函式,兩者都會執行this.threshold = tableSizeFor(initialCapacity);這段程式碼將會將使用者指定的容量轉化為2的整數次方數,舉例說明tableSizeFor(11)將會返回16,tableSizeFor(17)將會返回32。其內部實現為

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
複製程式碼

他人解釋,根據該解釋,再加上程式語言一般情況下int最大值為2147483647,轉化為二進位制是32位,而1+2+4+8+16=31,因此基本可以認定只要傳入int不是非法,都會被該函式運算處理,此時需要注意static final int MAXIMUM_CAPACITY = 1 << 30;該函式限定了最大值。 一般情況下,我們使用HashMap並不會指定初始容量與載入因子,會使用預設的無參構造(即將會建立初始容量為16,載入因子為0.75的一個HashMap),那麼很容易會碰到容量達到閾值(總容量*載入因子)從而觸發自動擴容。因為底層就是建立新陣列,然後資料內容從舊陣列中轉移至新的,因此我們先看下資料的新增定位過程。

HashMap的索引計算

之前檢視put原始碼時,很容易看出索引位置由(n - 1) & hash算出,其中n為當前陣列容量長度。&是按位與運算,我簡易模擬下hash為48和1568時的運算:

  48-> 0000 0000 0000 0000 0000 0000 0011 0000
  15-> 0000 0000 0000 0000 0000 0000 0000 1111
&
       0000 0000 0000 0000 0000 0000 0000 0000 -> 0

1568-> 0000 0000 0000 0000 0000 0110 0010 0000
  15-> 0000 0000 0000 0000 0000 0000 0000 1111
&
       0000 0000 0000 0000 0000 0000 0000 0000 -> 0
Tips:48為字串"0"hash值,1568為字串"11"hash值
因為&運算的特性,僅有1&1的結果才為1,因此n-1的值限定了計算&的長度
當前例子中僅僅計算最後四位,因為前面的所有都是0,無需考慮
複製程式碼
HashMap的擴容

擴容由++size > threshold觸發,因此我使用如下程式碼進行簡易的觸發擴容,並且確保至少有一條單連結串列存在一個以上的節點。

        HashMap<String, Integer> test = new HashMap<String, Integer>();
        for (int i = 0; i < 13; i++) {
            // 0 與 11 索引位置相同,索引為0
            // 1 與 12 索引位置相同,索引為1
            test.put(String.valueOf(i), i);
        }
複製程式碼

簡易資料

從圖中可以輕易看出陣列已經存在11個資料,因此當前size為11,此時滿足++size > threshold條件,觸發擴容,容量會由newThr = oldThr << 1; // double threshold擴大一倍(即原來的兩倍),因為容量的擴大,計算索引時的公式(n - 1) & hash,此時n-1的二進位制肯定比之前多一位,因此節點的位置需要重新計算。而根據函式tableSizeFor我們可知,基本上所有的HashMap的容量都是2的整數次方數。因此可以看如下過程(以初始容量為16舉例)

原始內容    hash值   hash值的二進位制                               與15&結果  與31&結果
0           48      0000 0000 0000 0000 0000 0000 0011 0000     0           16
11          1568    0000 0000 0000 0000 0000 0110 0010 0000     0           0
-----------------------------------------------------------
n-1=15(非hash值!)   0000 0000 0000 0000 0000 0000 0000 1111
n^2-1=31            0000 0000 0000 0000 0000 0000 0001 1111
Tip:對不齊我也沒辦法,我也很難受,將就看吧
複製程式碼

可以輕易看出,元素是否需要轉移位置取決於新增的那一位是1還是0,因此和n進行&運算即可得知,為0即保留位置無需移動,為1則代表需要移動n個位置。 先看下原始碼

    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;
            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;
        @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;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        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) {
                                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;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
複製程式碼

我挑出兩部分著重看一下

    if (e.next == null)
        // 此處意味著當前索引的連結串列僅有頭結點
        // 因此直接重新計算索引
        newTab[e.hash & (newCap - 1)] = e;
複製程式碼
    if ((e.hash & oldCap) == 0) {
        // 保留原位
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        // 移動原陣列容量位置
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
複製程式碼

以上兩段程式碼可以清晰看出無論當前索引位置的連結串列僅有一個節點還是多個,都會進行&計算,因此美團關於HashMap的文章中有這麼一段

這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。有興趣的同學可以研究下JDK1.8的resize原始碼,寫的很贊,如下:

同時我們對比JDK7中擴容遷移的原始碼來看

    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;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
複製程式碼

此時,我們觀察原始碼得知,的確省去重新計算hash值的時間,不過連結串列元素會產生倒置是因為JDK7中put使用的是頭插法。因此,個人在此猜測,當連結串列超過一個節點時不直接使用newTab[e.hash & (newCap - 1)] = e;是為了避免索引一致時的尾插法的連結串列遍歷(連結串列插入刪除操作快,查詢滿;而陣列查詢快,刪除插入操作稍慢),使用第二段程式碼明顯可以減少一次單連結串列的遍歷。

相關文章