容器類原始碼解析系列(三)—— HashMap 原始碼分析(最新版)

MRYangY發表於2019-04-21

容器類原始碼解析系列(三)—— HashMap 原始碼分析(最新版)

前言

本篇文章是《Java容器類原始碼解析》系列的第三篇文章,主要是對HashMap的原始碼實現進行分析。強烈建議閱讀本文之前,先看看該系列的前兩篇文章:

  1. 容器類原始碼解析系列(一)ArrayList 原始碼分析——基於最新Android9.0原始碼
  2. 容器類原始碼解析系列(二)—— LinkedList 集合原始碼分析(最新版)

要點

  • HashMap 內部是基於陣列加連結串列結構來實現資料儲存,這句話在jdk1.8版本之後,就不準確了。因為在JDK1.8版本之後,HashMap內部加入了紅黑樹的資料結構來提高資料查詢效率。所以現在應該改為陣列加連結串列(紅黑樹)。
  • HashMap支援NULL 鍵(key)、NULL 值(value),HashTable不支援。
  • HasMap 是非執行緒安全的,所以在多執行緒併發場景下,需要加鎖來保證同步操作;HashTable是執行緒安全的。
  • HashMap具有fai-fast機制的,關於fail-fast機制,我在該系列第一篇文章有講解。容器類原始碼解析系列(一)ArrayList 原始碼分析——基於最新Android9.0原始碼
  • HashMap的樹化條件是連結串列深度達到閥值8,同時陣列長度(capacity)要達到64.

準備

先了解一下分析HashMap原始碼,需要知道的一些內容。

DEFAULT_INITIAL_CAPACITY = 1 << 4 預設的capacity(容量)大小

MAXIMUM_CAPACITY = 1 << 30 最大的capacity

DEFAULT_LOAD_FACTOR = 0.75f 預設的載入因子

TREEIFY_THRESHOLD = 8 連結串列樹化閥值(連結串列長度)

UNTREEIFY_THRESHOLD = 6 反樹化,TreeNode->Node

MIN_TREEIFY_CAPACITY = 64 連結串列樹化閥值(capacity)

Node<K,V>[] table 儲存資料的容器

Node<K,V> 類,當資料量不大,沒有達到樹化條件時,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;
        }

        ...
        ...
    }
複製程式碼

TreeNode<K,V> 儲存數量較大,滿足樹化條件時,HashMap的儲存節點結構。

static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<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);
        }
        ...
        ...
        
}
複製程式碼

樹化前:

樹化前

樹化後:

紅黑樹直接拿的wiki上面的圖,省事!?

樹化後

圖可能畫的不準確,大概就是這個意思,幫助理解的,don’t care little things!

構造

HashMap提供四種構造方法,可以分為兩類,一類是單純設定capacity和loadFactor這兩個成員變數的,建立一個空的hashmap;一類是傳遞一個Map集合引數,來賦值的。 我們先看第一類構造方法。

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
複製程式碼

我們主要看第一個構造方法,第二個第三個比較簡單,還有註釋就不提了。在第一個構造方法中,可以看到先是對傳進來的initialCapacity、loadFactor引數進行一個有效性判斷,然後在賦值initialCapacity的時候對其值進行了一個處理,然後賦值給threshold變數,這個threshold是HashMap擴容時的閥值。在table陣列沒有初始化的時候這個threshold表示初始陣列的capacity。 剛說了,對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 >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
複製程式碼

上面的處理是對傳進來的引數進行位操作處理,來實現return出去的資料是2的n次方。舉個例子: 傳進的值是11,減一後變成10;10的二進位制表示是1010,進過位操作後,變成1111;1111+1 變成10000 轉成10進位制是16;是2的4次方。 一般來說通過這個方法實際賦的值都是大於等於傳進來,期望的值的。 接著看第二類構造方法:

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

它傳進來一個Map容器,capacity和loadFactor都是用的預設值,分別是16和0.75f。這裡提一嘴,預設的loadFactor值0.75f是經過測試比較合適的一個平衡點,如果傳入的loadFactor值比較大,雖然可以減少記憶體空間的消耗但是會增加資料查詢的複雜度。因為擴容操作是很耗效能的,所以在構造HashMap時,應該根據自己需要儲存的資料量大小來設定合適的capacity,避免出現擴容操作。

	final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                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);
            }
        }
    }
複製程式碼

如果table陣列沒有初始化就先計算容量,然後在呼叫putVal方法,在執行putVal會有擴容判斷處理,來對table進行初始化操作。這個在講解put操作的時候在詳解putVal方法的是實現邏輯。


擴容機制

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//得到table陣列的長度
        int oldThr = threshold;//如果table陣列還沒有初始化,threshold代表initial capacity,否則代表擴容閾值。
        int newCap, newThr = 0;
        if (oldCap > 0) {//table陣列長度大於0
            if (oldCap >= MAXIMUM_CAPACITY) {//table陣列長度達到最大值,不做擴容處理,一般不會達到這個條件
                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) // 這是通過構造方法設定了capacity,還沒有初始化table陣列時
            newCap = oldThr;//註釋一
        else {               // 用了無參的構造方法,還沒初始化table陣列呢
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {//通過上面的分析,可以看出來,只有在“註釋一”的case下,沒有給newThr賦值了
            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;//直接把節點賦值到新的陣列索引下,新陣列的新索引通過“e.hash & (newCap - 1)”這種“與操作”來確定。
                    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) {//等於0,擴容後索引的計算依然與擴容前一致
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else { //不為0,擴容後的索引值是舊的索引值加舊的陣列大小
                                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;
    }
複製程式碼

通過上面的程式碼,我們知道正常情況下,擴容後的Capacity是之前容量的兩倍。

上面的擴容邏輯,在每行程式碼後面已經給了註釋講解,比較簡單,接著我們看*"註釋二”*,可能看到這裡會比較疑惑,為什麼會有個等於零的判斷,而且出現這麼多Node變數作用感覺很相似,重複。之所以出現等於0 的判斷是因為HashMap在擴容的時候,有一個特點是,如果節點的hash值&擴容前陣列大小的值等於0表示該節點在擴容後新陣列下的index索引跟之前的陣列索引一致;不等於則新的陣列索引為舊的陣列索引+oldCapacity。

為什麼又會這個結論?這根HashMap的索引計算有關,HashMap 中,索引的計算方法為 (n - 1) & hash,n表示陣列長度。假如有一個Node節點的hash值為111001,OldCapacity是16(預設值)。那麼:

擴容前:

111001 & (16-1)—> 111001&1111 = 001001(9)

擴容後:(Capacity變成了之前的兩倍為32)

111001&(32-1)—> 111001&11111 = 011001(25)

擴容後節點的索引變了。這裡我們注意下16的二進位制表示:10000

假如hash值是101001,再看下結果:

擴容前:

101001 & (16-1)—> 101001&1111 = 001001(9)

擴容後:(Capacity變成了之前的兩倍為32)

101001&(32-1)—> 101001&11111 = 001001(9)

這次擴容後節點的索引還是之前的索引,原因體現在我上面加粗字型,我們記住陣列長度的二進位制表示中1的位置,如果hash值對應的位置是0的話表示擴容後索引不變,是1的話擴容後索引是原來的索引加上原陣列長度。


常規操作

put操作

官方介紹HashMap的"put","get"操作,說是時間複雜度是O(1),其實這是不準確的,他是假設hash雜湊操作能完全均勻分散到容器中去,現實中很難達到。

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
複製程式碼

當呼叫put方法時,會進而呼叫內部的putVal方法,putVal接收四個引數。

Parameter1 是傳進來的key的hash值;

Parameter4 fasle表示相同key的情況下替換value值,true的話就不改變原來的value

Parameter5 只有在初始化table陣列的時候才是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)//如果table陣列還沒有建立,那就先通過resize建立,並記錄陣列長度與引用
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//傳進來的key對應的陣列索引下沒有資料
            tab[i] = newNode(hash, key, value, null);//那就新建立節點資料存進去
        else { //對應索引下存在節點資料
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;//如果key的hash值相同,key也相同,那麼替換原來value即可。
            else if (p instanceof TreeNode)//是樹節點的話,說明已經樹化了,要走紅黑樹的對應put邏輯。
                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);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 達到樹化條件,鏈的長度為8
                            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
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)//替換舊值
                    e.value = value;
                afterNodeAccess(e);//LinkedHashMap重寫了這個方法,感興趣可以去看看
                return oldValue;//返回舊值
            }
        }
        ++modCount;
        if (++size > threshold)//判斷是否需要擴容
            resize();
        afterNodeInsertion(evict);//LinkedHashMap重寫了這個方法,感興趣可以去看看
        return null;
    }

複製程式碼

HashMap的預設put操作在遇到相同key,hash的時候,是會替換原來的value的,原因在onlyIfAbsent為false;

如果節點是普通節點則會把資料插入鏈尾,如果是樹化節點TreeNode則會有樹的相應插入邏輯。在作為普通節點插入資料至鏈尾的過程中會檢測是否達到(可能)樹化條件,達到的話會走樹化邏輯。把普通Node節點變成TreeNode。

get操作

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

複製程式碼

程式碼比較少,首先先進行table陣列有效性判斷,獲取目標索引下的頭結點。如果頭結點就滿足key相等的要求,那自然是皆大歡喜,省事了。直接返回頭結點即可。

不是頭結點的話,它會接著判斷是不是TreeNode,是TreeNode的話則走樹對應的get操作;否則走普通節點的查詢操作,即遍歷尋找,找到後就返回對應的值沒找到就返回null。

remove操作

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

remove方法會返回刪除節點的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) {//table陣列有效性判斷,獲取對應索引的頭結點
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))//巧了,頭結點就是要找到節點O(∩_∩)O哈哈~
                node = p;
            else if ((e = p.next) != null) {//頭節點不是要找到,那就接著看它的next節點
                if (p instanceof TreeNode)//頭結點是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)//如果頭結點就是要找到節點,直接把頭節點的next節點指向index索引即可
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }
複製程式碼

結語

在分析原始碼邏輯的時候,可以發現主要分為兩部分,一種是如果節點是TreeNode要走紅黑樹的查詢,新增等邏輯;另外一種是走普通的連結串列邏輯。

為什麼要在新的JDK中新增紅黑樹的資料結構,是為了提交效率,當連結串列過長,會拖慢效率,而紅黑樹的效能很好,對插入時間、刪除時間和查詢時間提供了最好可能的最壞情況擔保。時間複雜度是O(log n)。而連結串列是最壞情況下的時間複雜度是O(n)。

本文主要是對HashMap的原始碼進行整體的分析,對於紅黑樹的演算法邏輯細節沒有提及。如果對紅黑樹這種結構有興趣研究的話可以自行研究。

紅黑樹-wiki

宇寶守護神的個人站


掃碼加入我的個人微信公眾號:Android開發圈 ,一起學習Android知識!!

在這裡插入圖片描述

相關文章