HashMap的設計與優化

深入淺出HashMap的設計與優化

常用的數據結構

ArrayList 是基於數組的數據結構實現的,LinkedList 是基於鏈表的數據結構實現的, HashMap 是基於哈希表的數據結構實現的。我們不妨一起來溫習下常用的數據結構,這樣也有助於你更好地理解後面地內容。

數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲 O(1),但在數組中間以及頭部插入數據時,需要複製移動後面的元素。

鏈表:一種在物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是通過鏈表中的指針鏈接次序實現的。

鏈表由一系列結點(鏈表中每一個元素)組成,結點可以在運行時動態生成。每個結點都包含“存儲數據單元的數據域”和“存儲下一個結點地址的指針域”這兩個部分。

由於鏈表不用必須按順序存儲,所以鏈表在插入的時候可以達到 O(1) 的複雜度,但查找一個結點或者訪問特定編號的結點需要 O(n) 的時間。

哈希表:根據關鍵碼值(Key value)直接進行訪問的數據結構。通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做哈希函數,存放記錄的數組就叫做哈希表。

樹:由 n(n≥1)個有限結點組成的一個具有層次關係的集合,就像是一棵倒掛的樹。

HashMap 的實現結構
瞭解完數據結構後,我們再來看下 HashMap 的實現結構。作爲最常用的 Map 類,它是基於哈希表實現的,繼承了 AbstractMap 並且實現了 Map 接口。

哈希表將鍵的 Hash 值映射到內存地址,即根據鍵獲取對應的值,並將其存儲到內存地址。也就是說 HashMap 是根據鍵的 Hash 值來決定對應值的存儲位置。通過這種索引方式,HashMap 獲取數據的速度會非常快

例如,存儲鍵值對(x,“aa”)時,哈希表會通過哈希函數 f(x) 得到"aa"的實現存儲位置

。但也會有新的問題。如果再來一個 (y,“bb”),哈希函數 f(y) 的哈希值跟之前 f(x) 是一樣的,這樣兩個對象的存儲地址就衝突了,這種現象就被稱爲哈希衝突。那麼哈希表是怎麼解決的呢?方式有很多,比如,開放定址法、再哈希函數法和鏈地址法。

開放定址法很簡單,當發生哈希衝突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那麼可以把 key 存放到衝突位置後面的空位置上去。這種方法存在着很多缺點,例如,查找、擴容等,所以我不建議你作爲解決哈希衝突的首選

再哈希法顧名思義就是在同義詞產生地址衝突時再計算另一個哈希函數地址,直到衝突不再發生,這種方法不易產生“聚集”,但卻增加了計算時間。如果我們不考慮添加元素的時間成本,且對查詢元素的要求極高,就可以考慮使用這種算法設計。

HashMap 則是綜合考慮了所有因素,採用鏈地址法解決哈希衝突問題。這種方法是採用了數組(哈希表)+ 鏈表的數據結構,當發生哈希衝突時,就用一個鏈表結構存儲相同 Hash 值的數據。

HashMap 的重要屬性

從 HashMap 的源碼中,我們可以發現,HashMap 是由一個 Node 數組構成,每個 Node 包含了一個 key-value 鍵值對。

transient Node<K,V>[] table;

Node 類作爲 HashMap 中的一個內部類,除了 key、value 兩個屬性外,還定義了一個 next 指針。當有哈希衝突時,HashMap 會用之前數組當中相同哈希值對應存儲的 Node 對象,通過指針指向新增的相同哈希值的 Node 對象的引用。

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

}

HashMap 還有兩個重要的屬性:加載因子(loadFactor)和邊界值(threshold)。在初始化 HashMap 時,就會涉及到這兩個關鍵初始化參數。

int threshold;

final float loadFactor;

LoadFactor 屬性是用來間接設置 Entry 數組(哈希表)的內存空間大小,在初始 HashMap 不設置參數的情況下,默認 LoadFactor 值爲 0.75。爲什麼是 0.75 這個值呢?

這是因爲對於使用鏈表法的哈希表來說,查找一個元素的平均時間是 O(1+n),這裏的 n 指的是遍歷鏈表的長度,因此加載因子越大,對空間的利用就越充分,這就意味着鏈表的長度越長,查找效率也就越低。如果設置的加載因子太小,那麼哈希表的數據將過於稀疏,對空間造成嚴重浪費。

那有沒有什麼辦法來解決這個因鏈表過長而導致的查詢時間複雜度高的問題呢?你可以先想想,我將在後面的內容中講到。

Entry 數組的 Threshold 是通過初始容量和 LoadFactor 計算所得,在初始 HashMap 不設置參數的情況下,默認邊界值爲 12。如果我們在初始化時,設置的初始化容量較小,HashMap 中 Node 的數量超過邊界值,HashMap 就會調用 resize() 方法重新分配 table 數組。這將會導致 HashMap 的數組複製,遷移到另一塊內存中去,從而影響 HashMap 的效率。

HashMap 添加元素優化

初始化完成後,HashMap 就可以使用 put() 方法添加鍵值對了。從下面源碼可以看出,當程序將一個 key-value 對添加到 HashMap 中,程序首先會根據該 key 的 hashCode() 返回值,再通過 hash() 方法計算出 hash 值,再通過 putVal 方法中的 (n - 1) & hash 決定該 Node 的存儲位置。

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通過putVal方法中的(n - 1) & hash決定該Node的存儲位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

如果你不太清楚 hash() 以及 (n-1)&hash 的算法,就請你看下面的詳述。

我們先來了解下 hash() 方法中的算法。如果我們沒有使用 hash() 方法計算 hashCode,而是直接使用對象的 hashCode 值,會出現什麼問題呢?

假設要添加兩個對象 a 和 b,如果數組長度是 16,這時對象 a 和 b 通過公式 (n - 1) & hash 運算,也就是 (16-1)&a.hashCode 和 (16-1)&b.hashCode,15 的二進制爲 0000000000000000000000000001111,假設對象 A 的 hashCode 爲 1000010001110001000001111000000,對象 B 的 hashCode 爲 0111011100111000101000010100000,你會發現上述與運算結果都是 0。這樣的哈希結果就太讓人失望了,很明顯不是一個好的哈希算法

但如果我們將 hashCode 值右移 16 位(h >>> 16 代表無符號右移 16 位),也就是取 int 類型的一半,剛好可以將該二進制數對半切開,並且使用位異或運算(如果兩個數對應的位置相反,則結果爲 1,反之爲 0),這樣的話,就能避免上面的情況發生。這就是 hash() 方法的具體實現方式。簡而言之,就是儘量打亂 hashCode 真正參與運算的低 16 位。

我再來解釋下 (n - 1) & hash 是怎麼設計的,這裏的 n 代表哈希表的長度,哈希表習慣將長度設置爲 2 的 n 次方,這樣恰好可以保證 (n - 1) & hash 的計算得到的索引值總是位於 table 數組的索引之內。例如:hash=15,n=16 時,結果爲 15;hash=17,n=16 時,結果爲 1。

在獲得 Node 的存儲位置後,如果判斷 Node 不在哈希表中,就新增一個 Node,並添加到哈希表中,整個流程我將用一張圖來說明:

從圖中我們可以看出:在 JDK1.8 中,HashMap 引入了紅黑樹數據結構來提升鏈表的查詢效率。

這是因爲鏈表的長度超過 8 後,紅黑樹的查詢效率要比鏈表高,所以當鏈表超過 8 時,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;
if ((tab = table) == null || (n = tab.length) == 0)
//1、判斷當table爲null或者tab的長度爲0時,即table尚未初始化,此時通過resize()方法得到初始化的table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此處通過(n - 1)& hash 計算出的值作爲tab的下標i,並另p表示tab[i],也就是該鏈表第一個節點的位置。並判斷p是否爲null
tab[i] = newNode(hash, key, value, null);
//1.1.1、當p爲null時,表明tab[i]上沒有任何元素,那麼接下來就new第一個Node節點,調用newNode方法返回新節點賦值給tab[i]
else {
//2.1下面進入p不爲null的情況,有三種情況:p爲鏈表節點;p爲紅黑樹節點;p是鏈表節點但長度爲臨界長度TREEIFY_THRESHOLD,再插入任何元素就要變成紅黑樹了。
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判斷key相同的條件是key的hash相同,並且符合equals方法。這裏判斷了p.key是否和插入的key相等,如果相等,則將p的引用賦給e

            e = p;
        else if (p instanceof TreeNode)

//2.1.2現在開始了第一種情況,p是紅黑樹節點,那麼肯定插入後仍然是紅黑樹節點,所以我們直接強制轉型p後調用TreeNode.putTreeVal方法,返回的引用賦給e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//2.1.3接下里就是p爲鏈表節點的情形,也就是上述說的另外兩類情況:插入後還是鏈表/插入後轉紅黑樹。另外,上行轉型代碼也說明了TreeNode是Node的一個子類
for (int binCount = 0; ; ++binCount) {
//我們需要一個計數器來計算當前鏈表的元素個數,並遍歷鏈表,binCount就是這個計數器

                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) 

// 插入成功後,要判斷是否需要轉換爲紅黑樹,因爲插入後鏈表長度加1,而binCount並不包含新節點,所以判斷時要將臨界閾值減1
treeifyBin(tab, hash);
//當新長度滿足轉換條件時,調用treeifyBin方法,將該鏈表轉換爲紅黑樹
break;
}
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;
}

HashMap 獲取元素優化

當 HashMap 中只存在數組,而數組中沒有 Node 鏈表時,是 HashMap 查詢數據性能最好的時候。一旦發生大量的哈希衝突,就會產生 Node 鏈表,這個時候每次查詢元素都可能遍歷 Node 鏈表,從而降低查詢數據的性能。

特別是在鏈表長度過長的情況下,性能將明顯降低,紅黑樹的使用很好地解決了這個問題,使得查詢的平均複雜度降低到了 O(log(n)),鏈表越長,使用黑紅樹替換後的查詢效率提升就越明顯。

我們在編碼中也可以優化 HashMap 的性能,例如,重寫 key 值的 hashCode() 方法,降低哈希衝突,從而減少鏈表的產生,高效利用哈希表,達到提高性能的效果。

HashMap 擴容優化HashMap 也是數組類型的數據結構,所以一樣存在擴容的情況。

在 JDK1.7 中,HashMap 整個擴容過程就是分別取出數組元素,一般該元素是最後一個放入鏈表中的元素,然後遍歷以該元素爲頭的單向鏈表元素,依據每個被遍歷元素的 hash 值計算其在新數組中的下標,然後進行交換。這樣的擴容方式會將原來哈希衝突的單向鏈表尾部變成擴容後單向鏈表的頭部。

而在 JDK 1.8 中,HashMap 對擴容操作做了優化。由於擴容數組的長度是 2 倍關係,所以對於假設初始 tableSize = 4 要擴容到 8 來說就是 0100 到 1000 的變化(左移一位就是 2 倍),在擴容中只用判斷原來的 hash 值和左移動的一位(newtable 的值)按位與操作是 0 或 1 就行,0 的話索引不變,1 的話索引變成原索引加上擴容前數組。

之所以能通過這種“與運算“來重新分配索引,是因爲 hash 值本來就是隨機的,而 hash 按位與上 newTable 得到的 0(擴容前的索引位置)和 1(擴容前索引位置加上擴容前數組長度的數值索引處)就是隨機的,所以擴容的過程就能把之前哈希衝突的元素再隨機分佈到不同的索引中去。

總結
HashMap 通過哈希表數據結構的形式來存儲鍵值對,這種設計的好處就是查詢鍵值對的效率高。

我們在使用 HashMap 時,可以結合自己的場景來設置初始容量和加載因子兩個參數。當查詢操作較爲頻繁時,我們可以適當地減少加載因子;如果對內存利用率要求比較高,我可以適當的增加加載因子。

我們還可以在預知存儲數據量的情況下,提前設置初始容量(初始容量 = 預知數據量 / 加載因子)。這樣做的好處是可以減少 resize() 操作,提高 HashMap 的效率。

HashMap 還使用了數組 + 鏈表這兩種數據結構相結合的方式實現了鏈地址法,當有哈希值衝突時,就可以將衝突的鍵值對鏈成一個鏈表。

但這種方式又存在一個性能問題,如果鏈表過長,查詢數據的時間複雜度就會增加。HashMap 就在 Java8 中使用了紅黑樹來解決鏈表過長導致的查詢性能下降問題。以下是 HashMap 的數據結構圖:

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