准備知識:hash知識
在分析HashMap之前,先看下圖,理解一下HashMap的結構
我手畫了一個圖,簡單描述一下HashMap的結構,數組+鏈表構成一個HashMap,當我們調用put方法的時候增加一個新的 key-value 的時候,HashMap會通過key的hash值和當前node數據的長度計算出來一個index值,然后在把 hash,key,value 創建一個Node對象,根據index存入Node[]數組中,當計算出來的index上已經存在了Node對象的話。就把新值存在 Node[index].next 上,就像圖中的 a->aa->a1 一樣,這樣的情況我們稱之為hash沖突
HashMap基本用法
Map<String, Object> map = new HashMap<>();
map.put("student", "333");//正常入數組,i=5
map.put("goods", "222");//正常入數據,i=9
map.put("product", "222");//正常入數據,i=2
map.put("hello", "222");//正常入數據,i=11
map.put("what", "222");//正常入數據,i=3
map.put("fuck", "222");//正常入數據,i=7
map.put("a", "222");//正常入數據,i=1
map.put("b", "222");//哈希沖突,i=2,product.next
map.put("c", "222");//哈希沖突,i=3,what.next
map.put("d", "222");//正常入數據,i=4
map.put("e", "222");//哈希沖突,i=5,student.next
map.put("f", "222");//正常入數據,i=6
map.put("g", "222");//哈希沖突,i=7,fuck.next
首先我們都是創建一個Map對象,然后用HashMap來實現,通過調用 put
get
方法就可以實現數據存儲,我們就先從構造方法開始分析
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
初始化負載因子為0.75,負載因子的作用是計算一個擴容閥值,當容器內數量達到閥值時,HashMap會進行一次resize,把容器大小擴大一倍,同時也會重新計算擴容閥值。擴容閥值=容器數量 * 負載因子,具體為啥是0.75別問我,自己查資料吧(其實我是不知道,我覺得這個不重要吧~)
繼續看 put
方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
額,也沒啥可看的,繼續往下看putVal
方法吧
transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//先判斷當前容器內的哈希表是否是空的,如果table都是空的就會觸發resize()擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通過 (n - 1) & hash 計算索引,稍后單獨展開計算過程
if ((p = tab[i = (n - 1) & hash]) == null)
//如果算出來的索引上是空的數據,直接創建Node對象存儲在tab下
tab[i] = newNode(hash, key, value, null);
else {
//如果tab[i]不為空,說明之前已經存有值了
Node<K,V> e; K k;
//如果key相同,則需要先把舊的 Node 對象取出來存儲在e上,下邊會對e做替換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 {
//在這里解決hash沖突,判斷當前 node[index].next 是否是空的,如果為空,就直接
//創建新Node在next上,比如我貼的圖上,a -> aa -> a1
//大概邏輯就是a占了0索引,然后aa通過 (n - 1) & hash 得到的還是0索引
//就會判斷a的next節點,如果a的next節點不為空,就繼續循環next節點。直到為空為止
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//只有替換value的時候,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;
}
雖然寫我加了注釋,但是我還是簡單說一下這個的邏輯吧
1.首先判斷哈希表,是否存在,不存在的時候,通過resize進行創建
2.然后在通過索引算法計算哈希表上是否存在該數據,不存在就新增node節點存儲,然后方法結束
3.如果目標索引上存在數據,則需要用equals方法判斷key的內容,要是判斷命中,就是替換value,方法結束
4.要是key也不一樣,索引一樣,那么就是哈希沖突,HashMap解決哈希沖突的策略就是遍歷鏈表,找到最后一個空節點,存儲值,就像我的圖一樣。靈魂畫手有木有,很生動的表式了HashMap的數據結構
5.最后一步就是判斷是否到擴容閥值,容量達到閥值后,進行一次擴容,按照2倍的規則進行擴容,因為要遵循哈希表的長度必須是2次冪的概念
好,put
告一斷落,我們繼續 get
吧
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get方法,恩,好,很簡單。hash一下key,然后通過getNode來獲取節點,然后返回value,恩。get就講完了,哈哈。開個玩笑。我們繼續看getNode
吧
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//哈希表存在的情況下,根據hash獲取鏈表的頭,也就是first對象
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//檢測第一個first是的hash和key的內容是否匹配,匹配就直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//鏈表的頭部如果不是那就開始遍歷整個鏈表,如果是紅黑樹節點,就用紅黑樹的方式遍歷
//整個鏈表的遍歷就是通過比對hash和equals來實現
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
我們在整理一下,get
方法比put要簡單很多,核心邏輯就是取出來索引上的節點,然后挨個匹配hash和equals,直到找出節點。
那么get方法就搞定了
再來看一下resize
吧。就是HashMap的擴容機制
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;
}
// 對閥值進行擴容更新,左移1位代表一次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);
}
//如果哈希表是空的,這里會進行初始化擴容閥值,
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;
//處理舊數據,把舊數據挪到newTab內,newTab就是擴容后的新數組
if (oldTab != null) {
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 { // preserve order
//對鏈表的索引重新計算,如果還是0,那說明索引沒變化
//如果hash的第5位等於1的情況下,那說明 hash & n - 1 得出來的索引已經發生變化了,變化規則就是 j + oldCap,就是索引內向后偏移16個位置
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;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize
方法的作用就是初始化容器,以及對容器做擴容操作,擴容規則就是double
擴容完了之后還有一個重要的操作就是會對鏈表上的元素重新排列
(e.hash & oldCap) == 0
在講這個公式之前,我先做個鋪墊
16的二進制是 0001 0000
32的二進制是 0010 0000
64的二進制是 0100 0000
我們知道HashMap每次擴容都是左移1位,其實就是2的m+1次冪,也就是說哈希表每次擴容都是 16、32、64........n
然后我們知道HashMap內的索引是 hash & n - 1,n代表哈希表的長度,當n=16的時候,就是hash & 0000 1111,其實就是hash的后四位,當擴容n變成32的時候,就是 hash & 0001 1111,就是后五位
我為啥要說這個,因為跟上邊的 (e.hash & oldCap) == 0 有關,這里其實我們也可以用
假設我們的HashMap從16擴容都了32。
其實可以用 e.hash & newCap -1 的方式來重新計算索引,然后在重排鏈表,但是源碼作者采用的是另外一種方式(其實我覺得性能上應該一樣)作者采用的是直接比對 e.hash 的第五位(16長度是后四位,32長度是后五位)進行 0 1校驗,如果為0那么就可以說明 (hash & n - 1)算出來的索引沒有變化,還是當前位置。要是第五位校驗為1,那么這里(hash & n - 1)的公式得出來的索引就是向數據后偏移了16(oldCap)位。
所以作者在這里定義了兩個鏈表,
loHead低位表頭,loTail低位表尾(靠近索引0)
hiHead高位表頭,hiTail高位表尾(遠離索引0)
然后對鏈表進行拆分,如果計算出來索引沒有變化,那么還讓他停留在這個鏈表上(拼接在loTail.next上)
如果計算索引發生了變化。那么數據就要放置在高位鏈表上(拼接在hiTail.next)上
最后來個靈魂配圖,鏈表重排
重拍完成后的HashMap
好了。HashMap就講完了,可能還需要自己消化消化,反正我是消化完了。