淺析Java源碼之HashMap


  寫這篇文章還是下了一定決心的,因為這個源碼看的頭疼得很。

  老規矩,源碼來源於JRE1.8,java.util.HashMap,不討論I/O及序列化相關內容。

  該數據結構簡介:使用了散列碼來進行快速搜索。(摘自Java編程思想)

  那么,文章的核心就探討一下,內部是如何對搜索操作進行優化的。

  先來一張帥氣的圖片總覽:

  預備知識:

1、Map沒有迭代器,但是可以通過Map.entry()生成一個Set容器,然后通過Set的迭代器遍歷map元素。

2、HashMap是亂序的。

3、HashMap元素根據散列碼分散在一個數組的不同索引中,利用了數組的快速搜索特性對get操作進行了優化。

4、HashMap元素的保存形式為單向鏈表,是一個靜態內部類。

  先過一遍這個內部類:

    static class Node<K,V> implements Map.Entry<K,V> {
        // hash值、key、value、后指針
        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;
        }

        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) {
            // ...
        }

        public final boolean equals(Object o) {
            // ...
        }
    }

  代碼非常簡單,常規的get/set/equals,構造函數僅有一個指向下一個節點的指針,屬於單向鏈表。

  還有一個新建Node的方法:

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
        return new Node<>(hash, key, value, next);
    }

 

  總覽一下類的聲明:

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
    // code...
}

  其中AbstractMap類實現了大部分常規方法,諸如get、contain、remove、size等方法,但是put方法是一個沒有實現的方法,僅拋出一個錯誤。

  至於Map接口,下載的源碼包沒有這個class的,所以暫時不知道內部的代碼,不過影響不大。

  這里比較奇怪的是,類AbstractMap中實現了Map接口,這里HashMap又重新聲明實現Map接口,不太懂為啥。

  

變量

  HashMap中的變量比較多,如下:

    // 容器默認容量 必須為2的次方
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    // 容器最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默認負載參數
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 容器參數
    final float loadFactor;
    // 一個節點數組 HashMap的容器
    transient Node<K,V>[] table;
    // 保存所有map的Set容器 可以用來遍歷、查詢等
    transient Set<Map.Entry<K,V>> entrySet;
    // map對象數量
    transient int size;
    // 容量臨界值 觸發resize
    int threshold;
    // 將紅黑樹轉換回鏈表的臨界值
    static final int UNTREEIFY_THRESHOLD = 6;
    // 鏈表轉樹的臨界值
    static final int TREEIFY_THRESHOLD = 8;
    // (感謝指正)當某一個數組索引處的Node數量大於此值時 觸發resize並重新分配Node
    static final int MIN_TREEIFY_CAPACITY = 64;

  所有的容量與參數都是table相關,table就是開篇所講的數組。

 

構造函數

  

1、無參構造函數

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
    }

  簡單的將默認負載參數賦值給負載參數。

 

2、int單參數構造函數

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

  調用另外一個構造函數,第二個參數為默認的負載參數。

 

3、int、float雙參數構造函數

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

  錯誤處理就不管了,這里負載參數是正常的直接賦值,但是初始容器大小就不太一樣了,是通過一個函數返回。

  這個函數很有意思:

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

  第一次看沒搞懂,后面也沒太看懂,於是嘗試用個測試代碼看一下輸入值從0-100會輸出什么。

  測試代碼:

public class suv {
    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 + 1;
    }
    public static void main(String[] args){
        for(int i=1;i<100;i++){
            System.out.print(tableSizeFor(i) + ",");
            if(i%20 == 0){System.out.println();}
        }
    }
}

  輸出如下:

  有非常明顯的規律:

1、輸出均為2的次方

2、輸入值為大於該值的最小2次方數

  例如:輸入5,大於5的最小2次方數為2的三次方8,所以輸出為8。

  如果還不懂,可以看我自己寫的方法,輸出跟上面一樣:

    static final int diyFn(int cap){
        int start = 1;
        for(;;){
            if(start >= cap){
                return start;
            }
            start = start << 1;
        }
    }

  這里暫時不需要知道原因,只需要知道容量必須是2的次方。

 

4、帶有初始化集合的構造函數

    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

  這里負載參數設置為默認的,然后調用putMapEntries方法初始化HashMap。

  這個方法會初始化一些參數,稍微看一下:

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            // 初始table為null
            if (table == null) { // pre-size
                // 用負載參數進行計算
                float ft = ((float)s / loadFactor) + 1.0F;
                // 與最大容量作比較 返回對應的int類型值
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                // The next size value at which to resize (capacity * load factor).
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            // 擴容
            else if (s > threshold)
                resize();
            // 插入處理
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

  這里的擴容類似於ArrayList的grow函數,不同的是這里擴容的算法是每次乘以2,並且存在一個負載參數來修正初次擴容的步數。

  threshold可以看注釋,這是一個擴容臨界值。當容器大小大於這個值時,就會進行resize擴容操作,臨界值取決於當前容器容量與負載參數。

  接下來應該要進入resize函數,參照之前的ArrayList源碼,這里也是先擴容得到一個新的數組,然后將所有節點進行轉移。

  函數有點長,一步一步來:

    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;
            }
            // 容量與臨界值同時<<1
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1;
        }
        // 下面的else均代表舊數組為空
        else if (oldThr > 0)
            // 新容量設置為舊的臨界值
            newCap = oldThr;
        else {
            // 當容器為空時 初始化所有參數
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 這里的情況是初始化一個空HashMap 然后調用putAll插入大量元素觸發的resize
        // 新臨界值為新容量與負載參數相乘
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 新臨界值
        threshold = newThr;
        
        // ...數組操作
    }

  首先第一步是參數修正,包括臨界值與容器容量。

  接下來就是數組操作,如下:

    final Node<K,V>[] resize() {
        // 參數修正
        
        @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;
                            // 兩個分支都是執行鏈表的鏈接
                            // 由於數組擴容 所以對於(length-1) & hash的運算會改變 所以對原有的數組內容重新分配
                            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;
                            // oldCap為舊容量
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

  至此,可以看出,數組保存了一系列單向鏈表的第一個元素。

 

核心講解

  這里存在一個核心運算,即:

    newTab[e.hash & (newCap - 1)] = e;

  之前講過擴容,每次擴容的容量都是2的次方,為什么必須是呢?這里就給出了答案。

  開篇講過,該數據結構是通過hash值來優化搜索,這里就用到了hash值。但是hash值是不確定的,如何保證元素分配到的索引平均分配到數組的每一個索引,並且不會超過索引呢?

  答案就是這個運算,這里舉一個例子:

  比如說容量為默認的16,此時的二進制表示為10000,減1后會得到01111。

  與運算應該都不陌生,兩個都為1時才會返回1。

  由於高位會自動補0,所以任何數與01111做與運算時,高位都是0,范圍限定在 00000 ~ 01111,十進制表示就是0 - 15,巧的是,容量為16的數組,索引恰好是[0] - [15]。

  這就解釋了為什么容量必須為2的次方,而且元素是如何被平均分配到數組中的。

 

    (e.hash & oldCap) == 0

  這是用來區分lo、hi的運算,注釋中已經解釋了為什么需要做切割,這里給一個簡圖說明一下:

  

  首先,假設這個tab容量目前是8,而索引0中的節點太多了(這里應該是樹,懶得畫了),於是觸發了resize,並將該索引每個節點的hash值按照上面的那個計算,判斷是否需要移動。

  經過重分配,數組大概變成了這樣:

  擴容后,會進行插入操作,留到下一部分解釋。

  由於大體上的思想已經很明顯了,下面看一下增刪改查的API。

 

方法

  按照增刪改查的順序。

  首先看一眼

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

  方法需要傳入鍵值對,返回值。這里調用了內部的添加方法,其中散列碼用的是key的,這里的hash並不是直接用hashCode方法,而是內部做了二次處理。

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

  這個運算沒啥講的,當成返回一個隨機數就行了。

  下面是putVal的完整過程:

    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后第一次添加會調用resize初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            // 返回擴容后的長度 默認情況下為1<<4
            n = (tab = resize()).length;
        // 又是位運算 這里代表該索引位沒有鏈表 於是新建一個Node
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K, V> e;
            K k;
            // 傳入元素的key與鏈表第一個元素的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) {
                        p.next = newNode(hash, key, value, null);
                        // 當鏈表的長度大於臨界值時 調用treeifyBin
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 當中途遇到key相同的元素時 跳出循環
                    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();
        // 新建鏈表的鈎子函數
        afterNodeInsertion(evict);
        return null;
    }

  這里的過程可以簡述為:通過key的hash值計算出一個值作為索引,然后對索引處的鏈表進行插入或者修改操作

  但是這里還是有幾個特殊的點:

1、鈎子函數

2、當鏈表長度大於某個值時,會調用treeifyBin方法將鏈表轉換為紅黑樹

  鈎子函數是我自己取的名字,因為讓我想到了vue生命周期的鈎子函數。這兩個方法都是本地已定義但是沒有具體內容,是用來重寫的函數。

  另外一個是treeifyBin方法,該方法將鏈表轉換為紅黑樹結構保存:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 若小於最低樹臨界值 觸發resize
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        // 該索引處有元素
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            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);
        }
    }

  可以看出,當鏈表的長度大於某一臨界值時,會將數據結構轉換為紅黑樹。

  當然,這個鏈表的Node比一般的鏈表還是牛逼一點,采用的鍵值對的泛型,而TreeNode本身是一個靜態內部類,目前僅需要知道繼承於LinkedHashMap.Entry,元素按照插入順序進行排序。

  關於TreeNode轉換的詳解可以單獨分一節講了,這里暫時跳過吧。

 

  下面是

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
    }

  直接看removeNode的實現:

    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;
        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;
            // 當對應索引第一個鏈表元素就與key相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                // 紅黑樹刪除
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    // 遍歷鏈表對key做比較
                    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)
                    tab[index] = node.next;
                // 重新鏈接next
                else
                    p.next = node.next;
                ++modCount;
                --size;
                // 鈎子函數
                afterNodeRemoval(node);
                // 返回刪除的節點
                return node;
            }
        }
        return null;
    }

  這里很簡答,通過hash值快速找到對應的索引處,遍歷鏈表或者紅黑樹進行查詢,找到就刪除節點並重新執行next鏈接。

  同樣,這里也有一個鈎子函數,參數為被刪除的節點。

 

  由於改的情況在增的情況中已經提及,所以這里就跳過。

 

  最后看一眼查:

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }

  一個獲取,一個查詢,都指向同一個方法,所以看getNode的實現:

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

  沒啥營養,常規的找索引,遍歷,返回節點或者null。

 

  至此,HashMap的基本內部實現已經完事,紅黑樹轉換另外開一篇單獨弄。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM