天天看點

JDK集合源碼之HashMap解析(下)

特别說明:由于HashMap底層的紅黑樹結構比較複雜,是以涉及紅黑樹相關的操作,我單獨寫部落格為大家分享,文章最後會加上紅黑樹文章連結!

5.HashMap的成員方法

5.1 put(K key, V value)方法

put方法是比較複雜的,實作步驟大緻如下:

先通過 hash 值計算出 key 映射到哪個桶;

如果桶上沒有碰撞沖突,則直接插入;

如果出現碰撞沖突了,則需要處理沖突:

a 如果該桶使用紅黑樹處理沖突,則調用紅黑樹的方法插入資料;

b 否則采用傳統的鍊式方法插入。如果鍊的長度達到臨界值,則把鍊轉變為紅黑樹;

如果桶中存在重複的鍵,則為該鍵替換新值 value;

如果 size 大于門檻值 threshold,則進行擴容;

具體的方法如下:

public V put(K key, V value) {
    // 調用hash(key)計算出key的hash值
    return putVal(hash(key), key, value, false, true);
}
      

說明:

HashMap 隻提供了 put 用于添加元素,putVal 方法隻是給 put 方法調用的一個方法,并沒有提供給使用者使用。 是以我們重點看 putVal 方法。

我們可以看到在 putVal 方法中 key 在這裡執行了一下 hash 方法,來看一下 hash 方法是如何實作的。

static final int hash(Object key) {
    int h;
    // 如果key為null,則hash值為0,
    // 否則調用key的hashCode()方法計算出key的哈希值然後指派給h,
    // 後與h無符号右移16位後的二進制進行按位異或得到最後的hash值,
    // 這樣做是為了使計算出的hash更分散
    // 為什麼要更分散呢?因為越分散,某個桶的連結清單長度就越短,之後生成的紅黑樹越少,效率越高
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
      

從上面可以得知 HashMap 是支援 key 為空的,而 HashTable 是直接用 Key 來擷取hashCode 是以 key 為空會抛異常。

解讀上述 hash 方法:

我們先研究下 key 的哈希值是如何計算出來的。key 的哈希值是通過上述方法計算出來的。

這個哈希方法首先計算出 key 的 hashCode 指派給 h,然後與 h 無符号右移 16 位後的二進制進行按位異或得到最後的 hash 值。

在 putVal 函數中使用到了上述 hash 函數計算的哈希值:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    ...
    if ((p = tab[i = (n - 1) & hash]) == null) // 這裡的n表示數組長度16 ,公式
    // (length - 1) & hash = 桶位下标 當數組長度為2的n次幂數時,
    // 該公式相當于:hash % length 哈希值對數組長度取餘
    // 例如:hash % 32 = hash & (32-1)
    ...
}
      

計算過程如下所示:

key.hashCode();傳回散列值也就是 hashcode,假設随便生成的一個值。

n 表示數組初始化的長度是 16。

&(按位與運算):運算規則:相同的二進制數位上,都是 1 的時候,結果為 1,否則為0。

^(按位異或運算):運算規則:相同的二進制數位上,數字相同,結果為 0,不同為 1。

JDK集合源碼之HashMap解析(下)
JDK集合源碼之HashMap解析(下)

後獲得0101==> 下标為5的捅。

簡單來說就是:

高 16bit 不變,低 16bit 和高 16bit 做了一個異或(得到的 hashCode 轉化為 32 位二進制,前 16 位和後 16 位低 16bit 和高 16bit 做了一個異或)。

問題:為什麼要這樣操作呢?

如果當 n 即數組長度很小,假設是 16 的話,那麼 n - 1 即為 1111 ,這樣的值和 hashCode 直接做按位與操作,實際上隻使用了哈希值的後 4 位。如果當哈希值的高位變化很大,低位變化很小,這樣就很容易造成哈希沖突了,是以這裡把高低位都利用起來,進而解決了這個問題。

JDK集合源碼之HashMap解析(下)
JDK集合源碼之HashMap解析(下)

下面,我們來看看 putVal 方法,看看它到底做了什麼。

主要參數:

hash:key 的 hash 值

key:原始 key

value:要存放的值

onlyIfAbsent:如果 true 代表不更改現有的值

evict:如果為false表示 table 為建立狀态

putVal 方法源代碼如下所示:

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

static final int hash(Object key) {
    int h;// 如果key是null 則hash值為0,否則調用key的hashCode()方法,并讓高16位參與整個hash異或,如果數組長度比較小的情況下,讓高16位也參與尋址,
    // 尋址公式:(length - 1) & hash 
    // 這樣可以使計算出的結果更分散,不容易産生哈希沖突
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /*
        1)transient Node<K,V>[] table; 表示存儲Map集合中元素的數組。
        2)(tab = table) == null 表示将空的table指派給tab,然後判斷tab是否等于null,第一次肯定是null。
        3)(n = tab.length) == 0 表示将數組的長度0指派給n,然後判斷n是否等于0,n等于0,由于if判斷使用雙或,滿足一個即可,則執行代碼 n = (tab = resize()).length; 進行數組初始化,并将初始化好的數組長度指派給n。
        4)執行完n = (tab = resize()).length,數組tab每個空間都是null。
    */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    /*
        1)i = (n - 1) & hash 表示計算數組的索引指派給i,即确定元素存放在哪個桶中。
        2)p = tab[i = (n - 1) & hash]表示擷取計算出的位置的資料指派給結點p。
        3) (p = tab[i = (n - 1) & hash]) == null 判斷結點位置是否等于null,如果為null,則執行代碼:tab[i] = newNode(hash, key, value, null);根據鍵值對建立新的結點放入該位置的桶中。
        小結:如果目前桶沒有哈希碰撞沖突,則直接把鍵值對插入空間位置。
    */ 
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 建立一個新的結點存入到桶中
        tab[i] = newNode(hash, key, value, null);
    else {
         // 執行else說明tab[i]不等于null,表示這個位置已經有值了
        Node<K,V> e; K k;
        /*
            比較桶中第一個元素(數組中的結點)的hash值和key是否相等
            1)p.hash == hash :p.hash表示原來存在資料的hash值  hash表示後添加資料的hash值 比較兩個hash值是否相等。
                 說明:p表示tab[i],即 newNode(hash, key, value, null)方法傳回的Node對象。
                    Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
                        return new Node<>(hash, key, value, next);
                    }
                    而在Node類中具有成員變量hash用來記錄着之前資料的hash值的。
             2)(k = p.key) == key :p.key擷取原來資料的key指派給k  key 表示後添加資料的key比較兩個key的位址值是否相等。
             3)key != null && key.equals(k):能夠執行到這裡說明兩個key的位址值不相等,那麼先判斷後添加的key是否等于null,如果不等于null再調用equals方法判斷兩個key的内容是否相等。
        */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                /*
                    說明:兩個元素哈希值相等,并且key的值也相等,
                    将舊的元素整體對象指派給e,用e來記錄
                */ 
                e = p;
        // hash值不相等或者key不相等;判斷p是否為紅黑樹結點
        else if (p instanceof TreeNode)
            // 放入樹中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 說明是連結清單結點
        else {
            /*
                1)如果是連結清單的話需要周遊到最後結點然後插入
                2)采用循環周遊的方式,判斷連結清單中是否有重複的key
            */
            for (int binCount = 0; ; ++binCount) {
                /*
                    1)e = p.next 擷取p的下一個元素指派給e。
                    2)(e = p.next) == null 判斷p.next是否等于null,等于null,說明p沒有下一個元素,那麼此時到達了連結清單的尾部,還沒有找到重複的key,則說明HashMap沒有包含該鍵,将該鍵值對插傳入連結表中。
                */
                if ((e = p.next) == null) {
                    /*
                        1)建立一個新的結點插入到尾部
                         p.next = newNode(hash, key, value, null);
                         Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
                                return new Node<>(hash, key, value, next);
                         }
                         注意第四個參數next是null,因為目前元素插入到連結清單末尾了,那麼下一個結點肯定是null。
                         2)這種添加方式也滿足連結清單資料結構的特點,每次向後添加新的元素。
                    */
                    p.next = newNode(hash, key, value, null);
                    /*
                        1)結點添加完成之後判斷此時結點個數是否大于TREEIFY_THRESHOLD臨界值8,如果大于則将連結清單轉換為紅黑樹。
                        2)int binCount = 0 :表示for循環的初始化值。從0開始計數。記錄着周遊結點的個數。值是0表示第一個結點,1表示第二個結點。。。。7表示第八個結點,加上數組中的的一個元素,元素個數是9。
                        TREEIFY_THRESHOLD - 1 --》8 - 1 ---》7
                        如果binCount的值是7(加上數組中的的一個元素,元素個數是9)
                        TREEIFY_THRESHOLD - 1也是7,此時轉換紅黑樹。
                    */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 轉換為紅黑樹
                        treeifyBin(tab, hash);
                    // 跳出循環
                    break;
                }
                 
                /*
                    執行到這裡說明e = p.next 不是null,不是最後一個元素。繼續判斷連結清單中結點的key值與插入的元素的key值是否相等。
                */
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循環
                    /*
                        要添加的元素和連結清單中的存在的元素的key相等了,則跳出for循環。不用再繼續比較了
                        直接執行下面的if語句去替換去 if (e != null) 
                    */
                    break;
                /*
                    說明新添加的元素和目前結點不相等,繼續查找下一個結點。
                    用于周遊桶中的連結清單,與前面的e = p.next組合,可以周遊連結清單
                */
                p = e;
            }
        }
        /*
            表示在桶中找到key值、hash值與插入元素相等的結點
            也就是說通過上面的操作找到了重複的鍵,是以這裡就是把該鍵的值變為新的值,并傳回舊值
            這裡完成了put方法的修改功能
        */
        if (e != null) { 
            // 記錄e的value
            V oldValue = e.value;
            // onlyIfAbsent為false或者舊值為null
            if (!onlyIfAbsent || oldValue == null)
                // 用新值替換舊值
                // e.value 表示舊值  value表示新值 
                e.value = value;
            // 通路後回調
            afterNodeAccess(e);
            // 傳回舊值
            return oldValue;
        }
    }
    // 修改記錄次數
    ++modCount;
    // 判斷實際大小是否大于threshold門檻值,如果超過則擴容
    if (++size > threshold)
        resize();
    // 插入後回調
    afterNodeInsertion(evict);
    return null;
}
      

(1)計算key的hash值;

(2)如果桶(數組)數量為0,則初始化桶;

(3)如果key所在的桶沒有元素,則直接插入;

(4)如果key所在的桶中的第一個元素的key與待插入的key相同,說明找到了元素,轉後續流程(9)處理;

(5)如果第一個元素是樹節點,則調用樹節點的putTreeVal()尋找元素或插入樹節點;

(6)如果不是以上三種情況,則周遊桶對應的連結清單查找key是否存在于連結清單中;

(7)如果找到了對應key的元素,則轉後續流程(9)處理;

(8)如果沒找到對應key的元素,則在連結清單最後插入一個新節點并判斷是否需要樹化;

(9)如果找到了對應key的元素,則判斷是否需要替換舊值,并直接傳回舊值;

(10)如果插入了元素,則數量加1并判斷是否需要擴容;

5.2 擴容方法 resize()

擴容機制:

什麼時候才需要擴容?

當 HashMap 中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)時,就會進行數組擴容,loadFactor 的預設值是 0.75。

HashMap 的擴容是什麼?

進行擴容,會伴随着一次重新 hash 配置設定,并且會周遊 hash 表中所有的元素,是非常耗時的。在編寫程式中,要盡量避免 resize。

HashMap 在進行擴容時,使用的 rehash 方式非常巧妙,因為每次擴容都是翻倍,與原來計算的 (n - 1) & hash 的結果相比,隻是多了一個 bit 位,是以結點要麼就在原來的位置,要麼就被配置設定到 “原位置 + 舊容量” 這個位置。

例如我們從 16 擴充為 32 時,具體的變化如下所示:

JDK集合源碼之HashMap解析(下)
JDK集合源碼之HashMap解析(下)

是以元素在重新計算 hash 之後,因為 n 變為 2 倍,那麼 n - 1 的标記範圍在高位多 1bit(紅色),是以新的 index 就會發生這樣的變化。

JDK集合源碼之HashMap解析(下)

5 是假設計算出來的原來的索引。這樣就驗證了上述所描述的:擴容之後是以結點要麼就在原來的位置,要麼就被配置設定到 “原位置 + 舊容量” 這個位置。

是以,我們在擴充 HashMap 的時候,不需要重新計算 hash,隻需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就可以了,是 0 的話索引沒變,是 1 的話索引變成 “原位置 + 舊容量” 。可以看看下圖為 16 擴充為 32 的 resize 示意圖:

JDK集合源碼之HashMap解析(下)
JDK集合源碼之HashMap解析(下)

正是因為這樣巧妙的 rehash 方式,既省去了重新計算 hash 值的時間,而且同時,由于新增的 1bit 是 0 還是 1 可以認為是随機的,在 resize 的過程中保證了 rehash 之後每個桶上的結點數一定小于等于原來桶上的結點數,保證了 rehash 之後不會出現更嚴重的 hash 沖突,均勻的把之前的沖突的結點分散到新的桶中了。

源碼 resize 方法的解讀

下面是代碼的具體實作:

正是因為這樣巧妙的 rehash 方式,既省去了重新計算 hash 值的時間,而且同時,由于新增的 1bit 是 0 還是 1 可以認為是随機的,在 resize 的過程中保證了 rehash 之後每個桶上的結點數一定小于等于原來桶上的結點數,保證了 rehash 之後不會出現更嚴重的 hash 沖突,均勻的把之前的沖突的結點分散到新的桶中了。

源碼 resize 方法的解讀

下面是代碼的具體實作:
————————————————
版權聲明:本文為CSDN部落客「興趣使然的草帽路飛」的原創文章,遵循CC 4.0 BY-SA版權協定,轉載請附上原文出處連結及本聲明。
原文連結:https://blog.csdn.net/weixin_43591980/article/details/109496637      

(1)如果使用是預設構造方法,則第一次插入元素時初始化為預設值,容量為16,擴容門檻為12;

(2)如果使用的是非預設構造方法,則第一次插入元素時初始化容量等于擴容門檻,擴容門檻在構造方法裡等于傳入容量向上最近的2的n次方;

(3)如果舊容量大于0,則新容量等于舊容量的2倍,但不超過最大容量2的30次方,新擴容門檻為舊擴容門檻的2倍;

(4)建立一個新容量的桶;

(5)搬移元素,原連結清單分化成兩個連結清單,低位連結清單存儲在原來桶的位置,高位連結清單搬移到原來桶的位置加舊容量的位置;

5.3 删除方法 remove()

删除方法就是首先先找到元素的位置,如果是連結清單就周遊連結清單找到元素之後删除。如果是用紅黑樹就周遊樹然後找到之後做删除,樹小于 6 的時候要轉連結清單。

删除 remove() 方法:

// remove方法的具體實作在removeNode方法中,是以我們重點看下removeNode方法
// 根據key删除
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
// 根據key,value 删除
@Override
public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
}
      

removeNode() 方法:

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    // 參數:
    // matchValue 當根據 key和value 删除的時候該參數為true
    // movable 可以先不用考慮這個參數 
    
    // tab:引用目前haashMap中的散清單
    // p:目前node元素
    // n:目前散清單數組長度
    // index:表示尋址結果
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 根據hash找到位置 
    // 如果目前key映射到的桶不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        // 進入這個if判斷内部,說明桶位是有資料的,需要進行查詢操作,并且執行删除
        // node:通過查找得到的要删除的元素
        // e:表示目前node的下一個元素
        // k,v 鍵 值
        Node<K,V> node = null, e; K k; V v;
        
        // 第一種情況:目前桶位中的元素 即為我們要删除的元素
        // 如果桶上的結點就是要找的key,則将node指向該結點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        // 如果桶位中的頭一個元素不是我們要找的元素,且桶位中的e = p.next不為null
        // 說明該桶位中的節點存在下一個節點
        else if ((e = p.next) != null) {
            // 說明:目前桶位,要麼是 連結清單,要麼是 紅黑樹
            
            // 第二種情況:判斷桶位中是否已經形成了紅黑樹
            if (p instanceof TreeNode)
                // 說明是以紅黑樹來處理的沖突,則擷取紅黑樹要删除的結點
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 第三種情況:桶位中已經形成連結清單
            else {
                // 判斷是否以連結清單方式處理hash沖突
                // 是的話則通過周遊連結清單來尋找要删除的結點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 比較找到的key的value和要删除的是否比對
        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;
            // 如果桶位中
            else
                // 第三種情況:将目前元素p的下一個元素設定為 要删除元素的 下一個元素
                p.next = node.next;
            // 記錄修改次數
            ++modCount;
            // 變動的數量
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
      

5.4 查找元素方法 get()

查找方法,通過元素的 key 找到 value。

代碼如下:

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

get 方法主要調用的是 getNode 方法,代碼如下:

final Node<K,V> getNode(int hash, Object key) {
    // tab:引用目前hashMap的散清單
    // first:桶位中的頭元素
    // e:臨時node元素
    // n:table數組的長度
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 如果哈希表不為空,并且key對應的桶不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        /* 
            判斷數組元素是否相等
            根據索引的位置檢查第一個元素
            注意:總是檢查第一個元素
        */
        // 第一種情況:定位出來的桶位元素 就是我們要get的資料
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 桶位第一個元素不是我們要找的目标元素,且first.next不為null
        // 說明目前桶位不止一個元素,可能是連結清單,也可能是紅黑樹
        if ((e = first.next) != null) {
            // 第二種情況:桶位已經更新成了紅黑樹
            // 判斷是否是紅黑樹,是的話調用紅黑樹中的getTreeNode方法擷取結點
            if (first instanceof TreeNode)
                // 調用與樹相關的方法得到目标元素
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 第三種情況:桶位已經形成連結清單
            do {
                // 不是紅黑樹的話,那就是連結清單結構了
                // 通過循環的方法判斷連結清單中是否存在該key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 如果沒找到傳回null
    return null;
}
      

小結:

get 方法實作的步驟:

a. 通過 hash 值擷取該 key 映射到的桶

b. 桶上的 key 就是要查找的 key,則直接找到并傳回

c. 桶上的 key 不是要找的 key,則檢視後續的結點:

如果後續結點是紅黑樹結點,通過調用紅黑樹的方法根據 key 擷取 value

如果後續結點是連結清單結點,則通過循環周遊連結清單根據 key 擷取 value

6. 周遊HashMap的幾種方式

分别周遊 Key 和 Values

for (String key : map.keySet()) {
    System.out.println(key);
}
for (Object vlaue : map.values() {
    System.out.println(value);
}
      

使用 Iterator 疊代器疊代

Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Object> mapEntry = iterator.next();
    System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());
}
      

通過 get 方式(不建議使用)

Set<String> keySet = map.keySet();
for (String str : keySet) {
    System.out.println(str + "---" + map.get(str));
}
      

根據阿裡開發手冊,不建議使用這種方式,因為疊代兩次。keySet 擷取 Iterator一次,還有通過 get 又疊代一次,降低性能。

  1. jdk8 以後使用 Map 接口中的預設方法:
default void forEach(BiConsumer<? super K,? super V> action) 
// BiConsumer接口中的方法:
    void accept(T t, U u) 對給定的參數執行此操作。  
        參數 
            t - 第一個輸入參數 
            u - 第二個輸入參數 
      

周遊代碼:

HashMap<String,String> map = new HashMap();
map.put("001", "zhangsan");
map.put("002", "lisi");
map.forEach((key, value) -> {
    System.out.println(key + "---" + value);
});
      

7.總結

(1)HashMap是一種散清單,采用(數組 + 連結清單 + 紅黑樹)的存儲結構;

(2)HashMap的預設初始容量為16(1<<4),預設裝載因子為0.75f,容量總是2的n次方;

(3)HashMap擴容時每次容量變為原來的兩倍;

(4)當桶的數量小于64時不會進行樹化,隻會擴容;

(5)當桶的數量大于64且單個桶中元素的數量大于8時,進行樹化;

(6)當單個桶中元素數量小于6時,進行反樹化;

(7)HashMap是非線程安全的容器;

(8)HashMap查找添加元素的時間複雜度都為O(1);

關于HashMap紅黑樹操作,相關知識,之後很快就會更新…