1. 程式人生 > >HashMap 源碼分析

HashMap 源碼分析

trac youdao pre 分享 保存 參考 next http map對象

HashMap 介紹

HashMap 是一個散列表,它存儲的內容是鍵值對(key-value)映射。

HashMap 繼承於AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。

HashMap 的實現不是同步的,這意味著它不是線程安全的。它的key、value都可以為null。此外,HashMap中的映射不是有序的。

HashMap 的實例有兩個參數影響其性能:“初始容量” 和 “加載因子”。容量 是哈希表中桶的數量,初始容量 只是哈希表在創建時的容量。加載因子 是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 rehash 操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數。

通常,默認加載因子是 0.75, 這是在時間和空間成本上尋求一種折衷。加載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數 HashMap 類的操作中,包括 get 和 put 操作,都反映了這一點)。在設置初始容量時應該考慮到映射中所需的條目數及其加載因子,以便最大限度地減少 rehash 操作次數。如果初始容量大於最大條目數除以加載因子,則不會發生 rehash 操作。

HashMap是通過"拉鏈法"實現的哈希表。它包括幾個重要的成員變量:table, size, threshold, loadFactor, modCount。

  table是一個Entry[]數組類型,而Entry實際上就是一個單向鏈表。哈希表的"key-value鍵值對"都是存儲在Entry數組中的。

  size是HashMap的大小,它是HashMap保存的鍵值對的數量。

  threshold是HashMap的閾值,用於判斷是否需要調整HashMap的容量。threshold的值="容量*加載因子",當HashMap中存儲數據的數量達到threshold時,就需要將HashMap的容量加倍。

  loadFactor就是加載因子。

  modCount是用來實現fail-fast機制的。

源碼分析

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

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

put源碼解析

技術分享圖片


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

  2. 根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向⑥,如果table[i]不為空,轉向③;

  3. 判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向④,這裏的相同指的是hashCode以及equals;

  4. 判斷table[i] 是否為treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;

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

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

Resize 擴容

擴容(resize)就是重新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java裏的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。

我們分析下resize的源碼,鑒於JDK1.8融入了紅黑樹,較復雜,為了便於理解我們仍然使用JDK1.7的代碼,好理解一些,本質上區別不大,具體區別後文再說。

 1 void resize(int newCapacity) {   //傳入新的容量
 2     Entry[] oldTable = table;    //引用擴容前的Entry數組
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
10     transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
11     table = newTable;                           //HashMap的table屬性引用新的Entry數組
12     threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }

這裏就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()方法將原有Entry數組的元素拷貝到新的Entry數組裏。

 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了舊的Entry數組
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
 5         Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
 6         if (e != null) {
 7             src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
11                 e.next = newTable[i]; //標記[1]
12                 newTable[i] = e;      //將元素放在數組上
13                 e = next;             //訪問下一個Entry鏈上的元素
14             } while (e != null);
15         }
16     }
17 }

newTable[i]的引用賦給了e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash沖突的話),這一點和Jdk1.8有區別,下文詳解。在舊數組中同一條Entry鏈上的元素,通過重新計算索引位置後,有可能被放到了新數組的不同位置上。

下面舉個例子說明下擴容過程。假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。其中的哈希桶數組table的size=2, 所以key = 3、7、5,put順序依次為 5、7、3。在mod 2以後都沖突在table[1]這裏了。這裏假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是哈希桶數組 resize成4,然後所有的Node重新rehash的過程。

技術分享圖片

jdk1.8 做的優化

下面我們講解下JDK1.8做了哪些優化。經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要麽是在原位置,要麽是在原位置再移動2次冪的位置。看下圖可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
技術分享圖片
元素在重新計算hash之後,因為n變為2倍,那麽n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
技術分享圖片

length 設計成2倍的好處

這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的沖突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。

  1. 一方面 indexFor 尋找的時候,h & (length-1) 比 取余運算%快很多
static int indexFor(int h, int length) {  //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的
     return h & (length-1);  //第三步 取模運算
}
  1. 另一方面, 設計成2的冪次,有利於resize時候,鏈表移動時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。

HashMap 多線程操作導致死循環問題

在多線程下,進行 put 操作會導致 HashMap 死循環,原因在於 HashMap 的擴容 resize()方法。由於擴容是新建一個數組,復制原數據到數組。由於數組下標掛有鏈表,所以需要復制鏈表,但是多線程操作有可能導致環形鏈表。復制鏈表過程如下:
以下模擬2個線程同時擴容。假設,當前 HashMap 的空間為2(臨界值為1),hashcode 分別為 0 和 1,在散列地址 0 處有元素 A 和 B,這時候要添加元素 C,C 經過 hash 運算,得到散列地址為 1,這時候由於超過了臨界值,空間不夠,需要調用 resize 方法進行擴容,那麽在多線程條件下,會出現條件競爭,模擬過程如下:

線程一:讀取到當前的 HashMap 情況,在準備擴容時,線程二介入

技術分享圖片

線程二:讀取 HashMap,進行擴容

技術分享圖片

線程一:繼續執行

技術分享圖片

這個過程為,先將 A 復制到新的 hash 表中,然後接著復制 B 到鏈頭(A 的前邊:B.next=A),本來 B.next=null,到此也就結束了(跟線程二一樣的過程),但是,由於線程二擴容的原因,將 B.next=A,所以,這裏繼續復制A,讓 A.next=B,由此,環形鏈表出現:B.next=A; A.next=B

參考文章

Java 8系列之重新認識HashMap

Java集合:HashMap詳解(JDK 1.8)

HashMap 源碼分析