源碼閱讀-HashMap


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使用數組+鏈表(紅黑樹)做爲總體結構html

節點數據結構

通常狀況下,節點會使用以下代碼構建結構,該結構爲單鏈表java

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時(等於也會觸發),會轉換爲紅黑樹結構。紅黑樹須要瞭解的小夥伴能夠看下這篇文章:一步一步數據結構-紅黑樹node

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時,需格外注意。
運算符相關介紹:
>>> 右移運算符
^ 按位異或運算符bash

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,可是&比%具備更高的效率。併發

&app

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;是爲了不索引一致時的尾插法的鏈表遍歷(鏈表插入刪除操做快,查詢滿;而數組查詢快,刪除插入操做稍慢),使用第二段代碼明顯能夠減小一次單鏈表的遍歷。

相關文章
相關標籤/搜索