1. 程式人生 > >深入理解 HashMap

深入理解 HashMap

包含 刪除 不同 鍵值 code 1.8 信息 索引 導致

1. 簡介

HashMap 是Java開發中使用頻率最高的鍵值對數據類型容器。它根據鍵的哈希值(hashCode)來存儲數據,訪問速度高,但無法按照順序遍歷。HashMap 允許鍵值為空和記錄為空,非線程安全。

另外,如果想要保持有序,可以使用LinkedHashMap。LinkedHashMap 是 HashMap 的一個子類,保存了記錄的插入順序,在用 Iterator 遍歷 LinkedHashMap 時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照訪問次序排序。

2. 存儲結構

HashMap 是數組 + 鏈表 + 紅黑樹(JDK1.8 新增)實現的哈希表結構,如下圖如所示:

技術分享圖片

HashMap內部維護的數組類型為Node(jdk 1.8以前為Entry,僅僅換了名字)

transient 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) { ...}
    public final K getKey() { ...}
    public final V getValue() { ...}
    public final String toString() { ...}
    public final int hashCode() { ...}
    public final V setValue(V newValue) { ...}
    public final boolean equals(Object o) { ...}
}

由next字段可以看出,Node其實還是一個鏈表,即數組中的每個位置都被當成一個桶,一個桶存放一個鏈表,鏈表中存放哈希值相同的元素。

執行代碼時,Java 會調用 key 的 hashCode 方法,計算哈希值,而後通過 Hash 算法的後兩步運算(高位運算和取模運算)來定位該鍵值對在數組中的存儲位置。而後遍歷該位置上的鏈表,即可得到所要的值。

key 的哈希值有可能相同,造成 Hash 碰撞。避免 Hash 碰撞的方法主要有兩種:采用更好的哈希函數(根據數據計算哈希值的函數)和更大的哈希數組。當然,數組過大時會造成空間的浪費,因此在效率和空間上需要做一個權衡。Java 采用了擴容機制來權衡數組大小。具體而言,每個哈希數組有一個上限大小和一個負載因子,當數據達到上限* 負載因子後,數組大小翻倍。

默認數組大小為 16,負載因子為 0.75。

需要註意的是,哈希表的大小一定為 2 的整數次方。所以當調用 new HashMap<>(19) 時,哈希表的大小為 32。(原因後面解釋)

2.1 解決哈希沖突

哈希表為解決 Hash 沖突,可以采用開放地址法鏈地址法來解決問題。HashMap 采用了鏈地址法。鏈地址法,簡單來說,就是數組加鏈表的結合。在每個數組元素上都一個鏈表結構,當數據被 Hash 後,得到數組下標,把數據放在對應下標元素的鏈表上。

2.2 具體實現

2.2.1. 確定數組中的位置

不管增加、刪除、查找鍵值對,定位到哈希數組的位置都是很關鍵的第一步。我們希望哈希值盡可能少的沖突,最好是數組中的每個位置只有一個元素,這樣就可以直接定位,而不用去遍歷鏈表。為了達到這個目的,一個好的定位方法是必須的。

Java 中 Hash 算法本質上就是三步:取 key 的 hashCode 值、高位運算、取模運算(結果作為數組下標)

static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 為第一步 取 hashCode 值
     // h ^ (h >>> 16)  為第二步 高位參與運算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

首先,hash() 方法返回 key 的散列值(int 類型)。如果直接拿散列值作為下標訪問話,範圍從 -2147483648 到 2147483648,這麽大的數組當然不能直接拿來使用。Java 是將它進行取模運算後當做數組下標使用的。模運算是在 indexFor(hash, length) 函數中完成的:

// 第三步,取模運算,利用與運算來實現,效率比 % 高
static int indexFor(int h, int length) {
    return h & (length - 1);
}

不是說模運算麽?為什麽用的與運算?這是出於性能考慮的。模運算雖然簡單便捷,但效率低下。為了更高效的實現模運算,Java 開發團隊采用了一種「取巧」的方式來計算模運算的結果。

首先,HashMap 數組的長度必須為 2 的整數次冪,這樣數組長度 -1正好相當於一個“低位掩碼”。與操作的結果就是散列值的高位全部歸零,只保留低位值用來做數組下標訪問。(解釋了為什麽擴容總是翻2倍)

例如長度為 16 時,16-1 = 15,位表示為:00000000 00000000 00001111,與散列值做與運算後,只保留了低位的值。但是這樣又會造成問題,要是只取最後幾位的話,碰撞會很嚴重。在這裏,高位運算就起了作用:

h = key.hashCode() ^ (h >>> 16);

右位移 16 位,正好是 32bit 的一半,自己的高半區和低半區做異或,就是為了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特征,這樣高位的信息也被變相保留下來。

整個過程如下圖所示:

技術分享圖片

3. 並發環境下 HashMap 會碰到的問題

多線程下 HashMap 會有線程安全的問題,主要是因為 HashMap 需要進行resize的操作。HashMap的容量是有限的,當元素個數超過負載上限*負載因子後,需要將大小變更為原來的兩倍。這個操作的具體流程如下:

  1. 擴容:創建一個新的Entry數組,大小為原來的2倍

    void resize(int newCapacity) {  
        Entry[] oldTable = table;                  //引用擴容前的Entry數組
        int oldCapacity = oldTable.length;         
        if (oldCapacity == MAXIMUM_CAPACITY) {    //擴容前的數組大小如果已經達到最大(2^30)了
            threshold = Integer.MAX_VALUE;    //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
            return;
        }
    
        Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
        transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
        table = newTable;                           //HashMap的table屬性引用新的Entry數組
        threshold = (int)(newCapacity * loadFactor);//修改閾值
    }
  2. ReHash,因為數組大小不同,Hash的規則也不同了,所以需要進行重新Hash

     void transfer(Entry[] newTable) {
         Entry[] src = table;                   //src引用了舊的Entry數組
         int newCapacity = newTable.length;
         for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
             Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
             if (e != null) {
                 src[j] = null;  //釋放舊Entry數組的對象引用
                 do {
                     Entry<K,V> next = e.next;
                     int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
                     e.next = newTable[i]; //標記[1]
                     newTable[i] = e;      //將元素放在數組上
                     e = next;             //訪問下一個Entry鏈上的元素
                 } while (e != null);
             }
         }
     }

當多個線程同時對HashMap進行put操作時,如果同時出發了rehash操作,會導致HashMap中可能出現循環節點。

深入理解 HashMap