HashMap的實現原理看這篇就夠了,圖文源碼詳解,深入淺出簡單易懂

HashMap 是一線資深 java工程師必須要精通的集合容器,它的重要性幾乎等同於Volatile在併發編程的重要性(可見性與有序性)。本篇通過圖文源碼詳解,深度剖析 HashMap 的重要內核知識,易看易學易懂。建議收藏,多學一點總是好的,萬一面試被問到了呢。

我是Mike,10餘年BAT一線大廠架構技術傾囊相授。

本篇重點:

1.HashMap的數據結構

2.HashMap核心成員

3.HashMapd的Node數組

4.HashMap的數據存儲

5.HashMap的哈希函數

6.哈希衝突:鏈式哈希表

7.HashMap的get方法:哈希函數

8.HashMap的put方法

9.爲什麼槽位數必須使用2^n?

HashMap的數據結構

首先我們從數據結構的角度來看:HashMap是:數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)的數據結構,如下所示:

這裏需要搞明白兩個問題:

  • 數據底層具體存儲的是什麼?

  • 這樣的存儲方式有什麼優點呢?

1.核心成員

默認初始容量(數組默認大小):16,2的整數次方

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

 

 最大容量

static final int MAXIMUM_CAPACITY = 1 << 30;

 

默認負載因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

裝載因子用來衡量HashMap滿的程度,表示當map集合中存儲的數據達到當前數組大小的75%則需要進行擴容

 

鏈表轉紅黑樹邊界

static final int TREEIFY_THRESHOLD = 8;

 

紅黑樹轉離鏈表邊界

static final int UNTREEIFY_THRESHOLD = 6;

 

哈希桶數組

transient Node<K,V>[] table;

 

實際存儲的元素個數

transient int size;

 

當map裏面的數據大於這個threshold就會進行擴容

int threshold   閾值 = table.length * loadFactor

2.Node數組

從源碼可知,HashMap類中有一個非常重要的字段,就是 Node[] table,即哈希桶數組,明顯它是一個Node的數組。

static class Node<K,V> implements Map.Entry<K,V> {

    final int hash;//用來定位數組索引位置

    final K key;

    V value;

    Node<K,V> next;//鏈表的下一個Node節點

 

    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) {

        V oldValue = value;

        value = newValue;

        return oldValue;

    }

 

 

    public final boolean equals(Object o) {

        if (o == this)

            return true;

        if (o instanceof Map.Entry) {

            Map.Entry<?,?> e = (Map.Entry<?,?>)o;

            if (Objects.equals(key, e.getKey()) &&

                Objects.equals(value, e.getValue()))

                return true;

        }

        return false;

    }

}

Node是HashMap的一個內部類,實現了Map.Entry接口,本質是就是一個映射(鍵值對)。

HashMap的數據存儲

1.哈希表來存儲

HashMap採用哈希表來存儲數據。

哈希表(Hash table,也叫散列表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構,只要輸入待查找的值即key,即可查找到其對應的值。

哈希表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。

2.哈希函數

哈希表中元素是由哈希函數確定的,將數據元素的關鍵字Key作爲自變量,通過一定的函數關係(稱爲哈希函數),計算出的值,即爲該元素的存儲地址。
表示爲:Addr = H(key),如下圖所示:

哈希表中哈希函數的設計是相當重要的,這也是建哈希表過程中的關鍵問題之一。

3.核心問題

建立一個哈希表之前需要解決兩個主要問題:

1)構造一個合適的哈希函數,均勻性 H(key)的值均勻分佈在哈希表中

2)衝突的處理

衝突:在哈希表中,不同的關鍵字值對應到同一個存儲位置的現象。

4.哈希衝突:鏈式哈希表

哈希表爲解決衝突,可以採用地址法和鏈地址法等來解決問題,Java中HashMap採用了鏈地址法。

鏈地址法,簡單來說,就是數組加鏈表的結合,如下圖所示:

HashMap的哈希函數

/**

* 重新計算哈希值

*/

static final int hash(Object key) {

    

    int h;

 

     // h = key.hashCode() 爲第一步 取hashCode值

     // h ^ (h >>> 16) 爲第二步 高位參與運算

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

//計算數組槽位

(n - 1) & hash

對key進行了hashCode運算,得到一個32位的int值h,然後用h 異或 h>>>16位。在JDK1.8的實現中,優化了高位運算的算法,通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16)。

 

這樣做的好處是,可以將hashcode高位和低位的值進行混合做異或運算,而且混合後,低位的信息中加入了高位的信息,這樣高位的信息被變相的保留了下來。

等於說計算下標時把hash的高16位也參與進來了,摻雜的元素多了,那麼生成的hash值的隨機性會增大,減少了hash碰撞。

備註:

  • ^異或:不同爲1,相同爲0

  • >>> :無符號右移:右邊補0

  • &運算:兩位同時爲“1”,結果才爲“1,否則爲0

h & (table.length -1)來得到該對象的保存位,而HashMap底層數組的長度總是2的n次方。

爲什麼槽位數必須使用2^n?

1.爲了讓哈希後的結果更加均勻

假如槽位數不是16,而是17,則槽位計算公式變成:(17 – 1) & hash

從上文可以看出,計算結果將會大大趨同,hashcode參加&運算後被更多位的0屏蔽,計算結果只剩下兩種0和16,這對於hashmap來說是一種災難。2.等價於length取模

當length總是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率。

位運算的運算效率高於算術運算,原因是算術運算還是會被轉化爲位運算。

最終目的還是爲了讓哈希後的結果更均勻的分部,減少哈希碰撞,提升hashmap的運行效率。

分析HashMap的put方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

               boolean evict) {

    Node<K,V>[] tab; Node<K,V> p; int n, i;

    

    // 當前對象的數組是null 或者數組長度時0時,則需要初始化數組

    if ((tab = table) == null || (n = tab.length) == 0) {

        n = (tab = resize()).length;

    }

    

    // 使用hash與數組長度減一的值進行異或得到分散的數組下標,預示着按照計算現在的

    // key會存放到這個位置上,如果這個位置上沒有值,那麼直接新建k-v節點存放

    // 其中長度n是一個2的冪次數

    if ((p = tab[i = (n - 1) & hash]) == null) {

        tab[i] = newNode(hash, key, value, null);

    }

    

    // 如果走到else這一步,說明key索引到的數組位置上已經存在內容,即出現了碰撞

    // 這個時候需要更爲複雜處理碰撞的方式來處理,如鏈表和樹

    else {

        Node<K,V> e; K k;

       

        //節點key存在,直接覆蓋value

        if (p.hash == hash &&

            ((k = p.key) == key || (key != null && key.equals(k)))) {

            e = p;

        }

        // 判斷該鏈爲紅黑樹

        else if (p instanceof TreeNode) {

            // 其中this表示當前HashMap, tab爲map中的數組

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

                    // TREEIFY_THRESHOLD = 8

                    // 從0開始的,如果到了7則說明滿8了,這個時候就需要轉

                    // 重新確定是否是擴容還是轉用紅黑樹了

                    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;

            }

        }

        // 此時的e是保存的被碰撞的那個節點,即老節點

        if (e != null) { // existing mapping for key

            V oldValue = e.value;

            // onlyIfAbsent是方法的調用參數,表示是否替換已存在的值,

            // 在默認的put方法中這個值是false,所以這裏會用新值替換舊值

            if (!onlyIfAbsent || oldValue == null)

                e.value = value;

            // Callbacks to allow LinkedHashMap post-actions

            afterNodeAccess(e);

            return oldValue;

        }

    }

    // map變更性操作計數器

    // 比如map結構化的變更像內容增減或者rehash,這將直接導致外部map的併發

    // 迭代引起fail-fast問題,該值就是比較的基礎

    ++modCount;

   

     // size即map中包括k-v數量的多少

   // 超過最大容量 就擴容

    if (++size > threshold)

        resize();

    // Callbacks to allow LinkedHashMap post-actions

    afterNodeInsertion(evict);

    return null;

}

HashMap的put方法執行過程整體如下:

①.判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容;

②.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加

③.判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value

④.判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對

⑤.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;

⑥.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。

HashMap總結

HashMap底層結構?基於Map接口的實現,數組+鏈表的結構,JDK 1.8後加入了紅黑樹,鏈表長度>8變紅黑樹,<6變鏈表

兩個對象的hashcode相同會發生什麼? Hash衝突,HashMap通過鏈表來解決hash衝突

HashMap 中 equals() 和 hashCode() 有什麼作用?HashMap 的添加、獲取時需要通過 key 的 hashCode() 進行 hash(),然後計算下標 ( n-1 & hash),從而獲得要找的同的位置。當發生衝突(碰撞)時,利用 key.equals() 方法去鏈表或樹中去查找對應的節點

HashMap 何時擴容?put的元素達到容量乘負載因子的時候,默認16*0.75

 hash 的實現嗎?h = key.hashCode()) ^ (h >>> 16), hashCode 進行無符號右移 16 位,然後進行按位異或,得到這個鍵的哈希值,由於哈希表的容量都是 2 的 N 次方,在當前,元素的 hashCode() 在很多時候下低位是相同的,這將導致衝突(碰撞),因此 1.8 以後做了個移位操作:將元素的 hashCode() 和自己右移 16 位後的結果求異或

HashMap線程安全嗎?HashMap讀寫效率較高,但是因爲其是非同步的,即讀寫等操作都是沒有鎖保護的,所以在多線程場景下是不安全的,容易出現數據不一致的問題,在單線程場景下非常推薦使用。

以上就是HashMap的介紹。

---END--

我是Mike,10餘年BAT一線大廠架構技術傾囊相授。Mike分享的每篇深度技術文,都是花上2-5天時間精心創作的,大家看了如果覺得還行,順手【點贊+轉發+收藏】一鍵三連支持下,謝謝。

MikeChen的互聯網架構

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章