Java集合系列之HashMap

我又不是架構師發表於2017-12-29

Java集合系列之HashMap

Hello,大家好,喜歡看我文章的小夥伴應該都知道作者正在寫Java併發程式設計系列的文章,但是今天要插播一些Java集合系列的文章,之後也會一直並行的寫Java併發程式設計系列文章和Java集合系列的文章,因為一直寫一個系列的文章實在是比較枯燥。而且這兩個系列其實是有關聯的,希望大家喜歡。好了,廢話少說,今天給大家講一講HashMap底層的實現,結構如下:

  1. HashMap底層資料結構
  2. 核心API
  3. 擴容相互(容量,負載因子,2的倍數)
  4. 非執行緒安全

1. HashMap底層資料結構

Java集合系列之HashMap
HashMap底層是陣列加上鍊表的資料結構,每一個陣列的元素通過指標又引出一個連結串列,連結串列的尾部指標指向NULL,這個元素的結構如下:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
        ....
        }
複製程式碼

可以看到,儲存了K和V,然後一個next指標指向下一個元素。

2. 核心API

知道了儲存結構是不夠的,元素怎麼入到這個集合裡面呢?下面來看下核心的API,put和get.

put()講解:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
        //key為null時,直接丟到table[0]這個陣列位置上
            return putForNullKey(value);
        //自己封裝的hash方法,對key做完hash後加了高位運算,儘量避免Hash碰撞
        int hash = hash(key);
        //根據hash值`對陣列長度取模`,算出應該放入索引為幾的陣列格子裡
        int i = indexFor(hash, table.length);
        //如果這個格子裡已經有元素了,那麼就遍歷執行equals方法,如果equals也一樣,就用value替換掉oldValue。返回oldValue
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果這個陣列隔離裡沒有equals一樣的,把這個元素加入到連結串列的頭部。
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
複製程式碼

當我們往 HashMap 中 put 元素的時候,先根據 key 的 hashCode 重新計算 hash 值,根據 hash 值得到這個元素在陣列中的位置(即下標),如果陣列該位置上已經存放有其他元素了,那麼在這個位置上的元素將以連結串列的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果陣列該位置上沒有元素,就直接將該元素放到此陣列中的該位置上。 還可以看到,當key-value 對時,完全沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每個 Entry 的儲存位置。我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的儲存位置之後,value(可以為NULL) 隨之儲存在那裡即可。

這裡再順帶提一下,為什麼HashMap在擴容時陣列的長度必須是2的倍數(預設16),其實這和indexFor()這個方法有關係的,這個方法是根據key的hash值算出在陣列對應的索引,大家可能會想到,直接用hash%陣列長度不就可以保證元素儘量平均的分配到陣列裡,避免少的碰撞嗎?但是取模的效率是比較低下的,所以這個API的實現如下:

static int indexFor(int h, int length) {  
    return h & (length-1);
}
複製程式碼

h & (length-1)要和h%length的效果一樣就必須保證length的長度是2的倍數,至於為什麼,小弟也不知道,這是演算法這塊的東西了。而&符號可比%符號效率高的多了。所以大家知道了吧。陣列長度為什麼必須是2的倍數。 整體上看,當陣列長度為 2 的 n 次冪的時候,不同的 key 算得得 index 相同的機率較小,那麼資料在陣列上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的連結串列,這樣查詢效率也就較高了。

稍微總結下put(): 當程式試圖將一個key-value對放入HashMap中時,程式首先根據該 key 的 hashCode() 返回值決定該 Entry 的儲存位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的儲存位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新新增 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但key不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新新增的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新新增的 Entry 位於 Entry 鏈的頭部.

get()講解:

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
複製程式碼

其實大家應該自己都可以看懂了,比較easy,從 HashMap 中 get 元素時,首先計算 key 的 hashCode,找到陣列中對應位置的某一元素,然後通過 key 的 equals 方法在對應位置的連結串列中找到需要的元素。

好,儲存元素和讀取元素來一個總結: 簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 物件。HashMap 底層採用一個 Entry[] 陣列來儲存所有的 key-value 對,當需要儲存一個 Entry 物件時,會根據 hash 演算法來決定其在陣列中的儲存位置,在根據 equals 方法決定其在該陣列位置上的連結串列中的儲存位置;當需要取出一個Entry 時,也會根據 hash 演算法找到其在陣列中的儲存位置,再根據 equals 方法從該位置上的連結串列中取出該Entry.

3. 擴容相關(容量,負載因子,2的倍數)

當 HashMap 中的元素越來越多的時候,hash 衝突的機率也就越來越高,因為陣列的預設長度是16。所以為了提高查詢的效率,就要對 HashMap 的陣列進行擴容,陣列擴容這個操作也會出現在 ArrayList 中,這是一個常用的操作,而在 HashMap 陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是 resize。

那麼 HashMap 什麼時候進行擴容呢?當 HashMap 中的元素個數超過(陣列大小 XloadFactor)時,就會進行陣列擴容,loadFactor的預設值為 0.75,這是一個折中的取值。也就是說,預設情況下,陣列大小為 16,那麼當 HashMap 中元素個數超過 16X0.75=12 的時候,就把陣列的大小擴充套件為 2X16=32,即擴大一倍(想想前面講過的2的倍數),然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知 HashMap 中元素的個數,那麼預設元素的個數能夠有效的提高 HashMap 的效能。

然後順帶提下幾個HashMap的構造方法:

  • HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap。
  • HashMap(int initialCapacity):構建一個初始容量為initialCapacity,負載因子為 0.75 的 HashMap。
  • HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。

負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼雜湊表的資料將過於稀疏,對空間造成嚴重浪費。預設的0.75其實是一個比較折中的方案了。

4. 非執行緒安全

稍微提一下,HashMap是非執行緒安全的,在多執行緒併發訪問的時候,擴容時有可能造成陣列的某個元素後的連結串列形成閉環,所以查詢的時候就一直死迴圈,造成100%利用率。所以,在多執行緒環境下應該使用ConcurrentHashMap替代之。後面會專門講解ConcurrentHashMap.此外,關於HashMap為什麼會造成這種死迴圈,因為網上文章比較多我就不多餘了,給大家幾個傳送門:

結語

好了,HashMap算是給大家說完了,後續會繼續給大家分享Java集合的相關底層知識。Over,Have a good day!

相關文章