1. 程式人生 > >JAVA常用集合原始碼分析:HashMap

JAVA常用集合原始碼分析:HashMap

我們這篇文章就來試著分析下 HashMap 的原始碼,由於 HashMap 底層涉及到太多方面,一篇文章總是不能面面俱到,所以我們可以帶著面試官常問的幾個問題去看原始碼:

  1. 瞭解底層如何儲存資料的
  2. HashMap 的幾個主要方法
  3. HashMap 是如何確定元素儲存位置的以及如何處理雜湊衝突的
  4. HashMap 擴容機制是怎樣的
  5. JDK 1.8 在擴容和解決雜湊衝突上對 HashMap 原始碼做了哪些改動?有什麼好處?

HashMap 的內部功能實現很多,本文主要從根據 key 獲取雜湊桶陣列索引位置、put 方法的詳細執行、擴容過程三個具有代表性的點深入展開講解。

1. 儲存結構

JDK1.7 的儲存結構

在 1.7 之前 JDK 採用「拉鍊法」來儲存資料,即陣列和連結串列結合的方式:

「拉鍊法」用專業點的名詞來說叫做鏈地址法。簡單來說,就是陣列加連結串列的結合。在每個陣列元素上儲存的都是一個連結串列。

我們之前說到不同的 key 可能經過 hash 運算可能會得到相同的地址,但是一個數組單位上只能存放一個元素,採用鏈地址法以後,如果遇到相同的 hash 值的 key 的時候,我們可以將它放到作為陣列元素的連結串列上。待我們去取元素的時候通過 hash 運算的結果找到這個連結串列,再在連結串列中找到與 key 相同的節點,就能找到 key 相應的值了。

JDK1.7 中新新增進來的元素總是放在陣列相應的角標位置,而原來處於該角標的位置的節點作為 next 節點放到新節點的後邊。稍後通過原始碼分析我們也能看到這一點。

JDK1.8 的儲存結構

對於 JDK1.8 之後的 HashMap 底層在解決雜湊衝突的時候,就不單單是使用陣列加上單鏈表的組合了,因為當處理如果 hash 值衝突較多的情況下,連結串列的長度就會越來越長,此時通過單鏈表來尋找對應 Key 對應的 Value 的時候就會使得時間複雜度達到 O(n),因此在 JDK1.8 之後,在連結串列新增節點導致連結串列長度超過 TREEIFY_THRESHOLD = 8

 的時候,就會在新增元素的同時將原來的單鏈錶轉化為紅黑樹。

對資料結構很在行的讀者應該,知道紅黑樹是一種易於增刪改查的二叉樹,他對與資料的查詢的時間複雜度是 O(logn) 級別,所以利用紅黑樹的特點就可以更高效的對 HashMap 中的元素進行操作。

 

從結構實現來講,HashMap 是陣列+連結串列+紅黑樹(JDK1.8增加了紅黑樹部分)實現的,如下如所示。

這裡需要講明白兩個問題:資料底層具體儲存的是什麼?這樣的儲存方式有什麼優點呢?

(1)從原始碼可知,HashMap 類中有一個非常重要的欄位,就是 Node[] table,即雜湊桶陣列,明顯它是一個 Node 的陣列。我們來看 Node( JDK1.8 中) 是何物。

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

Node 是 HashMap 的一個內部類,實現了 Map.Entry 介面,本質是就是一個對映(鍵值對)。上圖中的每個黑色圓點就是一個Node物件。

(2)HashMap 就是使用雜湊表來儲存的。雜湊表為解決衝突,可以採用開放地址法鏈地址法等來解決問題, Java 中 HashMap 採用了鏈地址法。鏈地址法,簡單來說,就是陣列加連結串列的結合。在每個陣列元素上都一個連結串列結構,當資料被 Hash 後,得到陣列下標,把資料放在對應下標元素的連結串列上。例如程式執行下面程式碼:

map.put("美團","小美");

系統將呼叫 "美團" 這個 key 的 hashCode() 方法得到其 hashCode 值(該方法適用於每個 Java 物件),然後再通過 Hash 演算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的儲存位置,有時兩個 key 會定位到相同的位置,表示發生了 Hash 碰撞。當然 Hash 演算法計算結果越分散均勻,Hash 碰撞的概率就越小,map 的存取效率就會越高。

如果雜湊桶陣列很大,即使較差的 Hash 演算法也會比較分散,如果雜湊桶陣列陣列很小,即使好的 Hash 演算法也會出現較多碰撞,所以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況確定雜湊桶陣列的大小,並在此基礎上設計好的 hash 演算法減少 Hash 碰撞。

那麼通過什麼方式來控制 map 使得 Hash 碰撞的概率又小,雜湊桶陣列(Node[] table)佔用空間又少呢?

答案就是好的 Hash 演算法和擴容機制。

在理解 Hash 和擴容流程之前,我們得先了解下 HashMap 的幾個欄位。從 HashMap 的預設建構函式原始碼可知,建構函式就是對下面幾個欄位進行初始化,原始碼如下:

int threshold;             // 所能容納的key-value對極限 
final float loadFactor;    // 負載因子
int modCount;  
int size;

首先,Node[] table的初始化長度 length (預設值是16)Load factor 為負載因子(預設值是0.75),threshold 是 HashMap 所能容納的最大資料量的 Node (鍵值對)個數。threshold = length * Load factor。也就是說,在陣列定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。

結合負載因子的定義公式可知,threshold 就是在此 Load factor 和 length (陣列長度)對應下允許的最大元素數目,超過這個數目就重新 resize(擴容),擴容後的 HashMap 容量是之前容量的兩倍。預設的負載因子 0.75 是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果記憶體空間很多而又對時間效率要求很高,可以降低負載因子 Load factor 的值;相反,如果記憶體空間緊張而對時間效率要求不高,可以增加負載因子 loadFactor 的值,這個值可以大於1。

size 這個欄位其實很好理解,就是 HashMap 中實際存在的鍵值對數量。注意和 table 的長度 length、容納最大鍵值對數量 threshold 的區別。而 modCount 欄位主要用來記錄 HashMap 內部結構發生變化的次數,主要用於迭代的快速失敗。強調一點,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。

在 HashMap 中,雜湊桶陣列 table 的長度 length 大小必須為 2n(一定是合數),這是一種非常規的設計,常規的設計是把桶的大小設計為素數。相對來說素數導致衝突的概率要小於合數,具體證明可以參考 為什麼一般hashtable的桶數會取一個素數? ,Hashtable 初始化桶大小為 11,就是桶大小設計為素數的應用(Hashtable 擴容後不能保證還是素數)。HashMap 採用這種非常規設計,主要是為了在取模和擴容時做優化,同時為了減少衝突,HashMap 定位雜湊桶索引位置時,也加入了高位參與運算的過程

這裡存在一個問題,即使負載因子和 Hash 演算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的效能。於是,在 JDK1.8 版本中,對資料結構做了進一步的優化,引入了紅黑樹。而當連結串列長度太長(預設超過8)時,連結串列就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高 HashMap 的效能,其中會用到紅黑樹的插入、刪除、查詢等演算法。本文不再對紅黑樹展開討論,想了解更多紅黑樹資料結構的工作原理可以參考:教你初步瞭解紅黑樹

2. 重要引數

引數 說明
buckets 在 HashMap 的註釋裡使用雜湊桶來形象的表示陣列中每個地址位置。注意這裡並不是陣列本身,陣列是裝雜湊桶的,他可以被稱為雜湊表
capacity table 的容量大小,預設為 16。需要注意的是 capacity 必須保證為 2 的 n 次方。
size table 的實際使用量。
threshold size 的臨界值,size 必須小於 threshold,如果大於等於,就必須進行擴容操作。
loadFactor 裝載因子,table 能夠使用的比例,threshold = capacity * loadFactor。
TREEIFY_THRESHOLD 樹化閥值,雜湊桶中的節點個數大於該值(預設為8)的時候將會被轉為紅黑樹行儲存結構。
UNTREEIFY_THRESHOLD 非樹化閥值,小於該值(預設為 6)的時候將再次改為單鏈表的格式儲存

3. 確定雜湊桶陣列索引位置

很多操作都需要先確定一個鍵值對所在的桶下標。

int hash = hash(key);
int i = indexFor(hash, table.length);

(一)計算 hash 值

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}

(二)取模

令 x = 1<<4,即 x 為 2 的 4 次方,它具有以下性質:

x   : 00010000
x-1 : 00001111

令一個數 y 與 x-1 做與運算,可以去除 y 位級表示的第 4 位以上數:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

這個性質和 y 對 x 取模效果是一樣的:

y   : 10110010
x   : 00010000
y%x : 00000010

我們知道,位運算的代價比求模運算小的多,因此在進行這種計算時用位運算的話能帶來更高的效能。

確定桶下標的最後一步是將 key 的 hash 值對桶個數取模:hash%capacity,如果能保證 capacity 為 2 的 n 次方,那麼就可以將這個操作轉換為位運算。

static int indexFor(int h, int length) {
    return h & (length-1);
}

4. 分析HashMap的put方法

  HashMap 的 put 方法執行過程可以通過下圖來理解,自己有興趣可以去對比原始碼更清楚地研究學習。

 

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

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

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

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

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

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

JDK1.8 HashMap 的 put 方法原始碼如下:

public V put(K key, V value) {
    // 對key的hashCode()做hash
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 步驟①:tab為空則建立
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步驟②:計算index,並對null做處理 
    if ((p = tab[i = (n - 1) & hash]) == null) 
        tab[i] = newNode(hash, key, value, null);
    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)
            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);
                     //連結串列長度大於8轉換為紅黑樹進行處理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                        treeifyBin(tab, hash);
                    break;
                }
                 // key已經存在直接覆蓋value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k)))) 
                           break;
                p = e;
            }
        }
        
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 步驟⑥:超過最大容量 就擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

5. 擴容機制

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

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

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);//修改閾值
}
這裡就是使用一個容量更大的陣列來代替已有的容量小的陣列,transfer() 方法將原有 Entry 陣列的元素拷貝到新的 Entry 數組裡。

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陣列的物件引用(for迴圈後,舊的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);
        }
    }
}

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 做了哪些優化。經過觀測可以發現,我們使用的是 2 次冪的擴充套件 (指長度擴為原來 2 倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動 2 次冪的位置。看下圖可以明白這句話的意思,n 為 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容後 key1 和 key2 兩種 key 確定索引位置的示例,其中 hash1 是 key1 對應的雜湊與高位運算結果。

 

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

因此,我們在擴充 HashMap 的時候,不需要像 JDK1.7 的實現那樣重新計算 hash,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成“原索引+oldCap”,可以看看下圖為 16 擴充為 32 的 resize 示意圖:

 

這個設計確實非常的巧妙,既省去了重新計算 hash 值的時間,而且同時,由於新增的 1bit 是 0 還是 1 可以認為是隨機的,因此 resize 的過程,均勻的把之前的衝突的節點分散到新的 bucket 了。這一塊就是 JDK1.8 新增的優化點。有一點注意區別,JDK1.7 中 rehash 的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置,但是從上圖可以看出,JDK1.8 不會倒置。有興趣的同學可以研究下 JDK1.8 的 resize源 碼,寫的很贊,如下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超過最大值就不再擴充了,就只好隨你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 沒超過最大值,就擴充為原來的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 計算新的resize上限
    if (newThr == 0) {

        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 把每個bucket都移動到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 連結串列優化重hash的程式碼塊
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket裡
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket裡
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

6. 執行緒安全性

在多執行緒使用場景中,應該儘量避免使用執行緒不安全的 HashMap,而使用執行緒安全的 ConcurrentHashMap。那麼為什麼說 HashMap 是執行緒不安全的,下面舉例子說明在併發的多執行緒使用場景中使用 HashMap 可能造成死迴圈。程式碼例子如下(便於理解,仍然使用 JDK1.7 的環境):

public class HashMapInfiniteLoop {  

    private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);  
    public static void main(String[] args) {  
        map.put(5, "C");  

        new Thread("Thread1") {  
            public void run() {  
                map.put(7, "B");  
                System.out.println(map);  
            };  
        }.start();  
        new Thread("Thread2") {  
            public void run() {  
                map.put(3, "A);  
                System.out.println(map);  
            };  
        }.start();        
    }  
}

其中,map初始化為一個長度為2的陣列,loadFactor=0.75,threshold=2*0.75=1,也就是說當put第二個key的時候,map就需要進行resize。

通過設定斷點讓執行緒1和執行緒2同時debug到transfer方法(3.3小節程式碼塊)的首行。注意此時兩個執行緒已經成功新增資料。放開thread1的斷點至transfer方法的“Entry next = e.next;” 這一行;然後放開執行緒2的的斷點,讓執行緒2進行resize。結果如下圖。

注意,Thread1的 e 指向了key(3),而next指向了key(7),其線上程二rehash後,指向了執行緒二重組後的連結串列。

執行緒一被排程回來執行,先是執行 newTalbe[i] = e, 然後是e = next,導致了e指向了key(7),而下一次迴圈的next = e.next導致了next指向了key(3)。

e.next = newTable[i] 導致 key(3).next 指向了 key(7)。注意:此時的key(7).next 已經指向了key(3), 環形連結串列就這樣出現了。

於是,當我們用執行緒一呼叫map.get(11)時,悲劇就出現了——Infinite Loop。

7. JDK1.8與JDK1.7的效能對比

HashMap中,如果key經過hash演算法得出的陣列索引位置全部不相同,即Hash演算法非常好,那樣的話,getKey方法的時間複雜度就是O(1),如果Hash演算法技術的結果碰撞非常多,假如Hash算極其差,所有的Hash演算法結果得出的索引位置一樣,那樣所有的鍵值對都集中到一個桶中,或者在一個連結串列中,或者在一個紅黑樹中,時間複雜度分別為O(n)和O(lgn)。 鑑於JDK1.8做了多方面的優化,總體效能優於JDK1.7,下面我們從兩個方面用例子證明這一點。

8. Hash較均勻的情況

為了便於測試,我們先寫一個類Key,如下:

class Key implements Comparable<Key> {

    private final int value;

    Key(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }

    @Override
    public int hashCode() {
        return value;
    }
}

這個類複寫了equals方法,並且提供了相當好的hashCode函式,任何一個值的hashCode都不會相同,因為直接使用value當做hashcode。為了避免頻繁的GC,我將不變的Key例項快取了起來,而不是一遍一遍的建立它們。程式碼如下:

public class Keys {

    public static final int MAX_KEY = 10_000_000;
    private static final Key[] KEYS_CACHE = new Key[MAX_KEY];

    static {
        for (int i = 0; i < MAX_KEY; ++i) {
            KEYS_CACHE[i] = new Key(i);
        }
    }

    public static Key of(int value) {
        return KEYS_CACHE[value];
    }
}

現在開始我們的試驗,測試需要做的僅僅是,建立不同size的HashMap(1、10、100、......10000000),遮蔽了擴容的情況,程式碼如下:

static void test(int mapSize) {

    HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
    for (int i = 0; i < mapSize; ++i) {
        map.put(Keys.of(i), i);
    }

    long beginTime = System.nanoTime(); //獲取納秒
    for (int i = 0; i < mapSize; i++) {
        map.get(Keys.of(i));
    }
    long endTime = System.nanoTime();
    System.out.println(endTime - beginTime);
}

public static void main(String[] args) {
    for(int i=10;i<= 1000 0000;i*= 10){
        test(i);
    }
}

在測試中會查詢不同的值,然後度量花費的時間,為了計算getKey的平均時間,我們遍歷所有的get方法,計算總的時間,除以key的數量,計算一個平均值,主要用來比較,絕對值可能會受很多環境因素的影響。結果如下:

 

通過觀測測試結果可知,JDK1.8的效能要高於JDK1.7 15%以上,在某些size的區域上,甚至高於100%。由於Hash演算法較均勻,JDK1.8引入的紅黑樹效果不明顯,下面我們看看Hash不均勻的的情況。

9. Hash極不均勻的情況

假設我們又一個非常差的Key,它們所有的例項都返回相同的hashCode值。這是使用HashMap最壞的情況。程式碼修改如下:

class Key implements Comparable<Key> {

    //...

    @Override
    public int hashCode() {
        return 1;
    }
}

仍然執行main方法,得出的結果如下表所示:

 

從表中結果中可知,隨著size的變大,JDK1.7的花費時間是增長的趨勢,而JDK1.8是明顯的降低趨勢,並且呈現對數增長穩定。當一個連結串列太長的時候,HashMap會動態的將它替換成一個紅黑樹,這話的話會將時間複雜度從O(n)降為O(logn)。hash演算法均勻和不均勻所花費的時間明顯也不相同,這兩種情況的相對比較,可以說明一個好的hash演算法的重要性。

測試環境:處理器為2.2 GHz Intel Core i7,記憶體為16 GB 1600 MHz DDR3,SSD硬碟,使用預設的JVM引數,執行在64位的OS X 10.10.1上。

10. HashMap與HashTable

  1. HashTable 使用 synchronized 來進行同步。
  2. HashMap 可以插入鍵為 null 的 Entry。
  3. HashMap 的迭代器是 fail-fast 迭代器。
  4. HashMap 不能保證隨著時間的推移 Map 中的元素次序是不變的。

11. 小結

  1. 擴容是一個特別耗效能的操作,所以當程式設計師在使用 HashMap 的時候,估算 map 的大小,初始化的時候給一個大致的數值,避免 map 進行頻繁的擴容。
  2. 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。
  3. HashMap 是執行緒不安全的,不要在併發的環境中同時操作 HashMap,建議使用 ConcurrentHashMap。
  4. JDK1.8 引入紅黑樹大程度優化了 HashMap 的效能。

參考資料: