HashMap的原理及實現
- 個人對
HashMap
的總結,有錯誤請留言. - 本文是純文字介紹的,如果有朋友喜歡結合程式碼的話也可以直接點選文末連結。
- 感謝閱讀.
概述
HashMap
是在JDK1.2中引入的一種K/V對
形式的集合類.- 在底層,
HashMap
通過陣列和單連結串列組合的結構形式來儲存資料,陣列在這作為一個外部結構,陣列中的每個節點被稱做Bucket(桶)
,而桶是由在單連結串列構成,JDK1.8
之後為了解決長連結串列下,查詢和插入效率低下的情況,又引入了紅黑樹的作為桶的實現方式, - 桶中的各節點是由
HashMap
定義的Node
內部類生成的,是普通的連結串列節點類.
- 注意:
HashMap
是執行緒不安全的,在JDK1.8
之前多執行緒情況下甚至可能會出現環路(後面會講),所以多執行緒狀態下還是要使用ConcurrentHashMap
的.
重點引數
HashMap
的引數不多,除去當做預設屬性的靜態常量和底層陣列物件,就只有以下五個
transient Node<K,V>[] table;
transient int size
transient int modCount;
int threshold;
final float loadFactor;
複製程式碼
-
table
就是整個HashMap
的底層陣列,table
的初始化並不在建構函式中完成,而是在resize()
方法中完成.table
的初始化可能有點繞,建構函式中最多指定了閾值threshold
和負載因子loadFactor
並沒有容量相關,但是在resize()
方法中會根據舊容量和舊閾值判斷新容量是等於預設容量,舊閾值或者兩倍舊容量,最後根據新容量建立新陣列
-
loadFactor
就是所謂的負載因子,預設為0.75,是控制擴容時機的關鍵屬性,因為擴容發生在當前元素個數超過閾值時,而閾值等於當前容量乘以負載因子. -
modCount
為修改計數,是fast-fail
機制的關鍵引數.在對Map
中的元素做新增/刪除操作時會自增,但修改不會(putVal()方法中覆蓋原值)
新增邏輯
HashMap
的新增過程重點主要還是定位,如何確定元素在陣列中的位置,HashMap
採用的就是Hash演算法- 首先
HashMap
會根據Key
的hash值,按照表示式(n - 1) & hash
計算出桶的下標 - 如果此時桶為空,會建立一個新的
Node
,作為連結串列的第一個元素,直接存放在陣列中.(以前還聽說過什麼連結串列首節點為空的情況,是假的.) - 如果節點存在又會區分樹節點(TreeNode)和普通節點(Node)兩種情況.
- 普通節點會直接從首節點往下遍歷找到尾節點,並將帶插入節點新增到末尾
- 樹節點會呼叫,
TreeNode
的方法插入到樹中.
- 首先
- 另外新增前會判斷底層陣列
table
是否初始化,新增後會判斷該桶大小是否超過的8,超過則轉化為紅黑樹,再判斷整個陣列是否需要擴容. Hash
同時也叫雜湊,可以把任意長度的輸入通過演算法,換算成固定長度的輸出,不同元素通過Hash
演算法獲得的下標一致可以被稱之為衝突或者碰撞
,Hash
演算法的要求就是使元素儘量少的發生碰撞,從而均勻的散佈在陣列中
.而發生碰撞時,像HashMap
這種以一個列表下掛的方式可以被稱為拉鍊法
.
查詢邏輯
- 此處的查詢邏輯是指呼叫
get()
方法,通過key
值查詢的情況,如果自己遍歷的另說.- 同樣是根據表示式
(n - 1) & hash
計算出桶的下標(可以說是相當重要了),若得到的桶為空,直接返回null - 不為空時則會遍歷整個桶,並根據
key.equals(k)
判斷是否相等 - 遍歷的方法也會根據節點型別的不同而不同,但是區分節點前直接存放在陣列中的頭結點是要先進行判斷的.感覺上效能影響不大吧
- 同樣是根據表示式
- 從查詢的過程可以看出,確定桶下標的計算不存在隨機性,時間複雜度就為O(1),具體的效能體現在遍歷這一塊,連結串列查詢的時間複雜度為O(n),所以連結串列越長遍歷時間也就越長,插入和查詢的效率也就越低.所以在
JDK1.8
之後引入的紅黑樹作為桶的另一種實現方法.當連結串列長度大於8
時,桶的實現會轉化為紅黑樹
. HashMap
的效能很大一部分取決於Hash
演算法..
RESIZE邏輯
-
通過插入和查詢我們可以知道,在陣列大小不變的情況下,連結串列越長或者說樹的高度越高都會導致操作效能降低,所以此時很有必要通過擴容陣列的方式,重新排列桶中元素,降低連結串列長度,減少樹的高度.
-
首先,觸發擴容的情況是
size > threshold
即元素個數大於閾值.整個擴容過程可以簡單的拆分為以下幾步:- 對陣列進行擴充,一般情況下是陣列容量和閾值都變為原來的兩倍,此間會有上限判斷,容量最大為
1 << 30
也就是2^30. - 遍歷舊陣列,重新判斷元素的位置並散佈到新陣列.
- 對陣列進行擴充,一般情況下是陣列容量和閾值都變為原來的兩倍,此間會有上限判斷,容量最大為
-
resize()
方法中重新散佈元素的方法還是很有意思的(除去單元素連結串列和紅黑樹(桶的容量在1~7之間)- 首先將新陣列分為兩部分
lo
和hi
(原始碼是loHead和hiHead,我猜是low和high,怎麼縮寫這麼隨意),lo
表示0到舊容量大小部分,hi
表示餘下算是新加入的部分,並以此建立兩個連結串列的節點 - 根據表示式
e.hash & oldCap
判斷元素是否分佈在lo
部分,是就掛到lo
連結串列下面,否就掛到hi
連結串列下面. lo
連結串列掛到和舊陣列相同位置的桶,而hi
則掛到下標為原下標 + 舊陣列容量
的桶.- 此處的依據就是
e.hash & (oldCap - 1) + oldCap == e.hash & (oldCap << 1) -1
- 首先將新陣列分為兩部分
-
可以看出
resize()
方法會調整全部的元素雜湊情況,因此過於頻繁的resize
會降低HashMap
的效能,因此如果一開始可以大概知道所需要存放的元素個數時,儘量直接指定容量大小. -
JDK1.7
之前的resize()
方法在併發條件下可能會發生閉環問題,但在JDK1.8
之後不會在出現,但並不代表HashMap
可以在併發條件下使用了,小部分情況還是會出現資料丟失等問題. -
介紹
JDK1.8
之前的閉環問題詳情的文章 -
HashMap的懶載入問題
- 檢視
HashMap
的原始碼,你會發現底層陣列table
的建立其實並不是在建構函式中完成的,而是resize()
方法中,這就是所謂的懶載入
,陣列物件並非是在一開始就建立的,而是在第一次插入操作之前完成的。
- 檢視
關於HashMap一些問題
擾動函式
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
擾動函式的邏輯很簡單就是將hashCode
的高16位和低16位異或.
擾動函式的作用就是增加雜湊的隨機性,使元素能夠更均勻的分佈在陣列中,減少衝突從而捎帶提高效能.
至於為什麼,可以看hash(*)
用到的地方,hash(*)
被用來計算元素的下標.而下標的計算公式如下
tab[i = (n - 1) & hash] // n表示陣列的長度
複製程式碼
因為HashMap
的容量一定會是2的次冪,所以減1之後轉化為二進位制會變為一串0加一串1的,例如長度為4時,減去1,就會變為000…00011
(前面30個0),再結合&
可以發現他只使用了hashCode
的末尾幾位,高位是全部沒用.
而經過擾動函式,將高16位和低16位異或之後相當於高低位都用到了,其雜湊的隨機性也就增加了.
HashMap的容量為什麼一定要是2的次冪
- 容量為2次冪有兩個優點
- 在下標運算的時候使用
(length - 1) & hash)
代替hash % length
,相對來說位運算效能更佳,速度更快。 - 而在採用
(length - 1) & hash
的方式計算下標之後,如果不是二次冪的容量,出現碰撞的機率將會大大增加,例如我們取17作為容量((17 -1) => 0001000
),經過&
與運算,可以想象會有一大批的元素直接掛在0號桶。
- 在下標運算的時候使用
- 可以說這是一整套的策略,如果使用
hash & length
的話,也不用要求容量一定是二次冪,但各方面的效能總是會差一點的。
HashMap和HashTable的區別
HashTable
都沒用過了,但以前還稍微看過
- 最大的區別就是
HashTable
是執行緒安全的,暴力的加方法級synchronized
.而HashMap
是執行緒不安全的,併發情況下可能會出現資料丟失等情況. HashTable
不允許null值,而HashMap
允許null值.(包括key和value)HashCode
的使用不同,HashTable
是直接呼叫hashCode
,而HashMap
會經過擾動函式.而且HashMap
中用&
代替了%
HashTable
陣列預設是11,且增長為2n+1
,而HashMap
預設為16,增長為2n
,且硬性要求長度為2的次冪.HashTable
並不是和HashMap
一樣繼承自AbstractMap
的,它繼承自一個獨立的父類AbstractDictionary
- 還有就是遍歷方法的不同.瞭解不深先不說話.
- 最後附上完整的原始碼閱讀,蠻久之前寫的,不過被朋友吐槽說大段的程式碼混著註釋實在看不下去,所以寫了這篇總結性的