1. 程式人生 > 程式設計 >HashMap原始碼解析

HashMap原始碼解析

1. 概述

HashMap是基於hash表的Map介面實現,允許null key、null value

2. 成員變數

    /**
     * [1] 預設初始容量,16(必須是2的冪)
     * The default initial capacity - MUST be a power of two.
     */
    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. */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 連結串列轉紅黑樹的閾值 */ static final int TREEIFY_THRESHOLD = 8; /** * [2] 紅黑樹轉列表的閾值 */ static final
int UNTREEIFY_THRESHOLD = 6; /** * 最小樹形化閾值 * 當HashMap中的table的長度大於64的時候,這時候才會允許桶內的連結串列轉成紅黑樹(要求桶內的連結串列長度達到8) * 如果只是桶內的連結串列過長,而table的長度小於64的時候 * 此時應該是執行resize方法,將table進行擴容,而不是連結串列轉紅黑樹 * 最小樹形化閾值至少應為連結串列轉紅黑樹閾值的四倍 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * 存放具體元素的集 * Holds cached entrySet(). Note that AbstractMap fields are used * for keySet() and values(). */
transient Set<Map.Entry<K,V>> entrySet; /** * HashMap的負載因子 * 負載因子控制這HashMap中table陣列的存放資料的疏密程度 * 負載因子越接近1,那麼存放的資料越密集,導致查詢元素效率低下 * 負載因子約接近0,那麼存放的資料越稀疏,導致陣列空間利用率低下 * The load factor for the hash table. * * @serial */ final float loadFactor; /** * 修改次數 */ transient int modCount; /** * 鍵值對的個數 * The number of key-value mappings contained in this map. */ transient int size; /** * 儲存元素的陣列 */ transient Node<K,V>[] table; /** * 當{@link HashMap#size} >= {@link HashMap#threshold}的時候,陣列要進行擴容操作 * The next size value at which to resize (capacity * load factor). * * @serial */ int threshold; 複製程式碼

[1] 處,為什麼會要求HashMap的容量必須是2的冪,可以看看

HashMap 容量為2次冪的原因

3. 構造方法

java.util.HashMap#HashMap()

    public HashMap() {
        // 使用預設的引數
        // 預設的負載因子、預設的容量
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
複製程式碼

預設的建構函式裡面並沒有對table陣列進行初始化,這個操作是在java.util.HashMap#putVal方法進行的

java.util.HashMap#HashMap(int)

    public HashMap(int initialCapacity) {
        // 呼叫過載建構函式
        // 指定初始容量,預設的負載因子
        this(initialCapacity,DEFAULT_LOAD_FACTOR);
    }
複製程式碼

java.util.HashMap#HashMap(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;
        // 手動指定HashMap的容量的時候,HashMap的閾值設定跟負載因子無關
        this.threshold = tableSizeFor(initialCapacity); // [1]
    }
複製程式碼

tableSizeFor這個方法的作用是,根據指定的容量,大於指定容量的最小的2冪的值

比如說,給定15,返回16;給定30,返回32

tableSizeFor方法是一個很牛逼的方法,5行程式碼看得我一臉懵逼

java.util.HashMap#HashMap(java.util.Map<? extends K,? extends V>)

    public HashMap(Map<? extends K,? extends V> m) {
        // 使用預設的負載因子
        this.loadFactor = DEFAULT_LOAD_FACTOR;

        putMapEntries(m,false);
    }
    
    final void putMapEntries(Map<? extends K,? extends V> m,boolean evict) {
        int s = m.size();
        if (s > 0) {
            // 判斷table是否已經例項化
            if (table == null) { // pre-size
                // 計算m的擴容上限
                float ft = ((float)s / loadFactor) + 1.0F;
                // 檢查擴容上限是否大於HashMap的最大容量
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold) {
                    // m的擴容上限大於當前HashMap的擴容上限,則需要重新調整
                    threshold = tableSizeFor(t);
                }
            }
            else if (s > threshold)
                // m.size大於擴容上限,執行resize方法,擴容table
                resize();
            for (Map.Entry<? extends K,? extends V> e : m.entrySet()) {
                // 將m中所有的鍵值對新增到HashMap中
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key),key,value,false,evict);
            }
        }
    }
複製程式碼

putMapEntries方法的流程:

  1. 如果table為空,則重新計算擴容上限
  2. 如果HashMap的擴容上限小於指定Map的size,那麼執行resize進行擴容
  3. 將指定Map中所有的鍵值通過putVal方法放到HashMap中

這裡使用到的hash函式實際上是一個擾動函式,下文會介紹的

4. Very重要的方法

java.util.HashMap#tableSizeFor

    /**
     * 靜態工具方法
     * 根據指定的容量,大於指定容量的最小的2冪的值
     * 備註:牛逼的演演算法
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        /*
        演演算法的原理請看
        HashMap原始碼註解 之 靜態工具方法hash()、tableSizeFor()(四) - 程式設計師 - CSDN部落格
        https://blog.csdn.net/fan2012huan/article/details/51097331
         */
        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;
    }
複製程式碼

演演算法的流程如下

位移的那五句程式碼是真的很牛逼!!如果看完流程圖以後,還不懂,那就看看註釋裡面的文章吧

java.util.HashMap#hash

    // 擾動函式
    static final int hash(Object key) {
        int h;
        // 混合原始Hash值的高位和地位,減少低位碰撞的機率
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
複製程式碼

為什麼會有這個函式出現?

首先我們要了解,HashMap是根據key的hash值中幾個低位的值來確定key在table中對應的index

這句話怎麼理解呢?我舉個栗子

有一個32位的hash值如下

如果取Hash值的低4位,則index = 0101 = 5

如果出現大量的低4位為0101的hash值,那麼所有鍵值對都會放在table的index = 5的地方

這樣就會導致key無法均勻分佈在table中

那麼HashMap為瞭解決這個問題,就搞出了這個方法java.util.HashMap#hash

把一個32位的hash值的高16位 & 低16位,那麼低位就會攜帶高位的資訊

說白了就是,即使有大量hash值低位相同的key,經歷過hash方法後,計算得到的index會不一樣

通過hash方法降低hash衝突的概率

java.util.HashMap#resize

    /**
     * 對table初始化操作或擴容操作
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
        // 拿出舊table快照
        Node<K,V>[] oldTab = table;
        // 檢查舊table容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 舊的擴容上限
        int oldThr = threshold;
        // 新table的容量和擴容上限
        int newCap,newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                // 如果舊table的容量大於HashMap的最大容量,則不進行擴容操作了
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 沒有超過HashMap的最大容量,則擴容兩倍(newCap,newThr)
                newThr = oldThr << 1; // double threshold
        }

        else if (oldThr > 0) // initial capacity was placed in threshold
            // 只設定了擴容上限,沒有初始化table,將初始容量設定為擴容上限
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            // 沒有設定擴容上限,沒有初始化table,則使用預設的容量(16)和擴容上限(12)
            // 比如:java.util.HashMap.HashMap()
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            // newThr在oldCap > 0的條件擴容兩倍後仍然等於0,那就說明,oldThr原本就是0
            // 重新計算新的擴容上限
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 更新HashMap的擴容上限
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // 初始化table
        table = newTab;
        // 如果old table不為空,則需要將裡面的
        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)
                        // 連結串列上只有一個節點,根據節點hash值,重新計算e在newTab中的index
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        // 連結串列已經轉成紅黑樹,拆分樹
                        ((TreeNode<K,V>)e).split(this,newTab,j,oldCap);
                    else { // preserve order
                        // 定義兩個連結串列,lo連結串列和hi連結串列
                        Node<K,V> loHead = null,loTail = null;
                        Node<K,V> hiHead = null,hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            // 通過hash值&oldCap判斷連結串列上的節點是否應該停留新table中的原位置
                            if ((e.hash & oldCap) == 0) {
                                // 節點仍然停留在位置j
                                // 插入lo連結串列(尾插法)
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                // 節點轉移到位置j+oldCap
                                // 插入hi連結串列(尾插法)
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            // 如果lo連結串列非空,把整個lo連結串列放到新table的j位置上
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            // 如果hi連結串列非空,把整個hi連結串列放到新table的j+oldCap位置上
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
複製程式碼

resize流程並不複雜,大致如下

個人認為,比較關鍵的一點是重新分配鍵值對到新table

這個時候要考慮三種情況:

  1. table中index位置只有一個元素
  2. table中index位置上是一棵紅黑樹
  3. table中index位置上是一條連結串列(重點看這個

如果是第3種情況,table中index位置上是一條連結串列,再重新分配的時候,會把這個連結串列拆分成兩條連結串列

一條lo連結串列,留在原來的index位置

另一條hi連結串列,會被移動到index+oldCapacity的位置

此時,需要判斷節點是留在lo連結串列,還是放在hi連結串列?

推薦看一下這篇文章 深入理解HashMap(四): 關鍵原始碼逐行分析之resize擴容

在jdk1.7裡,table的擴容在多執行緒併發執行下會形成環,牆裂推薦仔細閱讀這篇文章?? HashMap連結串列成環的原因和解決方案

java.util.HashMap#treeifyBin

    final void treeifyBin(Node<K,V>[] tab,int hash) {
        int n,index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            // [1] table的長度小於最小樹形化閾值,執行resize方法擴容
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            // 將連結串列轉成紅黑樹
            // hd頭節點、tl尾節點
            TreeNode<K,V> hd = null,tl = null;
            // do-while迴圈將單向連結串列轉成雙向連結串列
            do {
                // 將節點轉成TreeNode
                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);
        }
    }
複製程式碼

treeifyBin方法的作用是將table中某個index位置上的連結串列轉成紅黑樹

這個方法一般是在新增或合併元素後,發現連結串列的長度大於TREEIFY_THRESHOLD的時候呼叫

[1]處可以看到,如果當前table.length小於最小樹形化閾值(64),那麼會呼叫resize方法進行擴容,而不是將連結串列樹形化

java.util.HashMap#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;
        if ((tab = table) == null || (n = tab.length) == 0)
            // [1] table還沒有初始化,通過resize方法初始化table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 如果key的hash值在table上對應的位置沒有元素,則直接將建立節點
            tab[i] = newNode(hash,null);
        else {
            // table上對應的位置已經存在節點
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // 指定的key與已存在的節點的key相等
                e = p;
            else if (p instanceof TreeNode)
                // 連結串列節點已變成紅黑樹節點
                e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,value);
            else {
                // 遍歷連結串列,插入或更新節點
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // 在連結串列的尾部新新增一個節點
                        p.next = newNode(hash,null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            // TREEIFY_THRESHOLD - 1的原因是binCount是從0開始,連結串列上有8個節點的時候,binCount=7
                            // 新增節點後,當連結串列的節點數量大於等於8的時候,將連結串列樹形化
                            // [2]
                            treeifyBin(tab,hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        // 在連結串列中發現了key對應的節點
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                // 發現了key對應的節點,則更新節點上的value
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    // 更新節點對應的值
                    e.value = value;
                afterNodeAccess(e);
                // 返回節點的舊值
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            // [3]
            resize();
        afterNodeInsertion(evict);
        return null;
    }
複製程式碼

putVal方法是新增鍵值對相關方法的實現

上圖可以看到,新增鍵值對的方法內部都會呼叫putVal方法

[1]處可以看到,putVal方法內部通過呼叫resize方法對table進行初始化

整體邏輯並不複雜,但是要注意一下[2]、[3]處

[2]處,如果新增節點後,連結串列過長,要將連結串列轉成紅黑樹

[3]處,如果新增節點後,整個HashMap的鍵值對數量達到了擴容上限,那麼要對table進行擴容操作

5. 總結

如果說ArrayList的原始碼閱讀難度是一星半,那麼我覺得HashMap的原始碼閱讀難度至少有三顆星

這篇文章省略了一些內容,比如HashMap裡面的紅黑樹實現,不寫上去的原因主要是我也不是很懂紅黑樹?,後續的時間如果我弄懂了,我會再補一篇

最起碼這篇文章把HashMap最重要的幾個方法的實現講得比較明白,還是可以的?

6. 推薦閱讀

HashMap原始碼註解 之 靜態工具方法hash()、tableSizeFor()

深入理解HashMap(四): 關鍵原始碼逐行分析之resize擴容

HashMap中的hash函式 - 淡騰的楓 - 部落格園

HashMap 容量為2次冪的原因

HashMap連結串列成環的原因和解決方案