HashMap在面試中經常會被問到,一定會問到它的存儲結構和實現原理,甚至可能還會問到一些源碼
今天就來看一下HashMap
首先得看一下HashMap的存儲結構和底層實現原理
如上圖所示,HashMap底層是用數組+鏈表+紅黑樹實現的,其中紅黑樹是JDK1.8對HashMap優化之後加入的,當鏈表的長度大於8的時候會由鏈表結構轉爲紅黑樹,這些等下在看源碼分析的時候都可以看到具體的實現。
那爲什麼用這幾種數據結構來實現?
這種結構在數據結構上稱爲散列鏈表,其中的數組就相當於一個一個的桶(Bucket),當有數據準備存進去的時候,它會通過一定的散列算法去計算,儘可能的讓數據平均的命中到各個桶上面去,儘可能的避免哈希碰撞。如果發生哈希碰撞,就是不同的數據最後落到了同一個桶上的時候,就採用鏈表的方式來存儲,但是鏈表長度比較長了的時候,去存儲數據,讀取數據都需要不停的去遍歷循環,所以此時再採用鏈表結構的話效率會明顯下降,所以JDK1.8之後做了優化,當鏈表的長度大於8的時候就由鏈表轉爲紅黑樹來存儲。紅黑樹是平衡二叉樹的其中一種實現,它比普通的二叉樹表現更優異,因爲普通的查詢二叉樹在一定條件下也可能會變成鏈表結構,而紅黑樹它是平衡二叉樹的一種,它是通過左旋右旋變色等保持樹的平衡。
簡單的瞭解了HashMap的存儲結構後,下面來講下HashMap其中三個方法的源碼
一、hash()方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這個方法裏看似簡單,卻暗藏玄機。
它是拿到了key本身的hashCode後,又做了一次運算,先將原來的hashCode無符號右位移16位,然後再將原來的hashCode異或(^)上這個位移後的值,最後得到一個值。
補充知識:
>> 表示右移,如果該數爲正,則高位補0,若爲負數,則高位補1。
>>>表示無符號右移,也叫邏輯右移,即若該數爲正,則高位補0,而若該數爲負數,則右移後高位同樣補0。
^ 表示異或運算,每個位相同爲0,不同爲1
比如:
0 ^ 1 得 1
1 ^ 1 得 0
0 ^ 0 得 0
1 ^ 0 得 1
那爲什麼要無符號右移16位後做異或運算?key本身的hashCode直接拿來用不好嗎?
我們做一個簡單演練
將h無符號右移16爲相當於將高區16位移動到了低區的16位,再與原hashcode做異或運算,可以將高低位二進制特徵混合起來
從上文可知高區的16位與原hashcode相比沒有發生變化,低區的16位發生了變化
我們可知通過上面(h = key.hashCode()) ^ (h >>> 16)進行運算可以把高區與低區的二進制特徵混合到低區,那麼爲什麼要這麼做呢?
我們都知道重新計算出的新哈希值在後面將會參與hashmap中數組槽位的計算,計算公式:(n - 1) & hash,假如這時數組槽位有16個,則槽位計算如下:
仔細觀察上文不難發現,高區的16位很有可能會被數組槽位數的二進制碼鎖屏蔽,如果我們不做剛纔移位異或運算,那麼在計算槽位時將丟失高區特徵
也許你可能會說,即使丟失了高區特徵不同hashcode也可以計算出不同的槽位來,但是細想當兩個哈希碼很接近時,那麼這高區的一點點差異就可能導致一次哈希碰撞,所以這也是將性能做到極致的一種體現
使用異或運算的原因
異或運算能更好的保留各部分的特徵,如果採用&運算計算出來的值會向1靠攏,採用|運算計算出來的值會向0靠攏
爲什麼槽位數必須使用2^n
1、爲了讓哈希後的結果更加均勻
這個原因我們繼續用上面的例子來說明
假如槽位數不是16,而是17,則槽位計算公式變成:(17 - 1) & hash
從上文可以看出,計算結果將會大大趨同,hashcode參加&運算後被更多位的0屏蔽,計算結果只剩下兩種0和16,這對於hashmap來說是一種災難
2、可以通過位運算e.hash & (newCap - 1)來計算,a % (2^n) 等價於 a & (2^n - 1) ,位運算的運算效率高於算術運算,原因是算術運算還是會被轉化爲位運算
說了這麼多點,上面提到的所有問題,最終目的還是爲了讓哈希後的結果更均勻的分部,減少哈希碰撞,提升hashmap的運行效率
二、put()方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
這個沒什麼好講的,調用了下邊的putVal()方法
三、putVal()方法
這個方法很重要,是往hashMap裏put值的核心邏輯,下邊源碼裏的每一行我都進行了註釋
/**
* Implements Map.put and related methods
*
* @param hash hash for keyput
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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是不是爲空,如果爲空,則將容量進行初始化,也就是說,初始換操作不是在new HashMap()的時候進行的,而是在第一次put的時候進行的
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
* 初始化操作以後,根據當前key的哈希值算出最終命中到哪個桶上去,並且這個桶上如果沒有元素的話,則直接new一個新節點放進去
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
/**
* 如果對應的桶上已經有元素
*/
else {
Node<K,V> e; K k;
/** 先判斷一下這個桶裏的第一個Node元素的key是不是和即將要存的key值相同,如果相同,則
*把當前桶裏第一個Node元素賦值給e,這個else的最下邊進行了判斷,如果e!=null就執行把
* 新value進行替換的操作
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果和桶裏第一個Node的key不相同,則判斷當前節點是不是TreeNode(紅黑樹),如果是,則進
//行紅黑樹的插入
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值相同(就是key已經存在了)就什麼都不做跳出循環。因爲在上邊e = p.next的時候,已經記錄e的Node值了,而下邊進行了判斷,如果e!=null就執行把新value進行替換的操作
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把當前下標賦值給p並進行下一次循環
p = e;
}
}
/**
只要e不爲空,說明要插入的key已經存在了,覆蓋舊的value值,然後返回原來oldValue
因爲只是替換了舊的value值,並沒有插入新的元素,所以不需要下邊的擴容判斷,直接
return掉
*/
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
/**
* 判斷容量是否已經到了需要擴充的閾值了,如果到了,則進行擴充
* 如果上一步已經判斷key是存在的,只是替換了value值,並沒有插入新的元素,所以不需要判斷
* 擴容,不會走這一步的
*/
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
hashMap中還有其他的一些方法在此就不挨個來說了
可以在下方進行評論,一起學習進步