HashMap的底層運作和源碼解析
哈希的定義:
- 任意長度的輸入通過散列算法變換成固定長度的輸出,該輸出就是散列值(又稱哈希值)
哈希的作用:
哈希的作用在數據結構和密碼學中,發揮的作用不盡相同。
今天我們主要去了解數據結構中的應用。
Hash表----HashMap
而JAVA中的HashMap和HashTable就是我們常說的Hash表在計算機的表現形式。
生成HashMap的流程:
一:我們先初始化HashMap,此時如果你不加參數時,調用無參方法。
- PS:加參數自然會去初始化容量和加載因子兩項。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 源碼看出此時只生成一個HashMap對象,沒有初始化容量,只設定了它得默認加載因子爲0.75。
二:初始化後我們存值需要傳key,和value值來傳參,運用put方法,put調用putval方法
public V put(K key, V value) {
// 生成key得hash值
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);
}
public int hashCode() {
int h = hash;
//hash default value : 0
if (h == 0 && value.length > 0) {
//value : char storage
char val[] = value;
// 字符串得hash值生成算法得到固定長度值
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
/*
常見的Hash算法:
- 直接定址法
- 平方取中法
- 數字分析法
- 除留餘數法
*/
- 此時我們會去生成key的hash值(hash算法多種多樣)
- 是利用key值來進行調用hashcode方法裏面使用了哈希函數來返回int型的hash值(-2147483648到2147483648)
- 並將其轉爲二進制讓高位向右移16位和自身進行異或操作來完成高低位擾動,最後返回一個製作好得hash值。
- PS:(因爲向右移了16位,本質是讓自身得高位與低位進行異或,這樣當換一個key值時,只要高位或者低位產生一點點變動,都能影響異或結果)。
三:此時我們會去進入putval方法進行傳值,因爲此時沒有容量我們會去動態的生成一個初始容量爲16的Node數組來存key和value,到此HashMap存值已經結束。
-
若結點數量超過閾值(負載因子*容量)我們就會擴容。
-
當某一處hash桶的鏈表結點超過8個,我們就會轉爲紅黑樹存儲。
-
可以看出現在1.8版本基本都用Node數組來替代以前的Entry數組
static final int TREEIFY_THRESHOLD = 8;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 因爲我們沒有初始化容量,我們會去判斷Hashmap是否插入過元素
// 以此來通過resize()擴容函數來進行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 經典與操作,將hash值映射到我初始化0-15的容量上
// 這裏一夥兒細講
if ((p = tab[i = (n - 1) & hash]) == null)
// 存進Node數組
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 這裏使用鏈地址法來解決Hash碰撞問題
// 當hash值相同時,我們會將其存爲鏈表形式或者紅黑樹形式
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)
treeifyBin(tab, hash);
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;
}
JDK1.8源碼
// 插入是會把key值進行轉爲hash值
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 獲取也會將hash值傳入
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 返回被擾動過得hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這裏看完後會產生幾個疑問:
- 爲什麼要用擾動函數?
- 返回hash值之後想存的值怎麼確定在數組得存儲位置?
- 爲什麼是數組+鏈表,之後還要加紅黑樹?
- 怎麼擴容?擴容後原先結點是否要rehash?
- 負載因子爲什麼是0.75?
- 默認數組容量爲什麼是16?
- 爲什麼鏈表長度大於8才轉紅黑樹?
- 如何減少哈希碰撞?
- 爲何HashMap線程不安全?
- 如果你也有這些疑問,請看我接下來的解答:
一:爲什麼要用擾動函數?
擾動函數的目的是爲了讓Hash值這個巨長的值去映射到固定數組0-15長度時,變得更加不規律,來降低數組裏的Hash值映射後出現碰撞的概率。
(h = key.hashCode()) ^ (h >>> 16) // 具體代碼
這裏的hash二進制向右移動了16位將低位信息抹除了只留下了高位信息
1111 1111 1111 1111 0101 1101 —> 0000 0000 0000 0000 1111 1111
並且讓兩者進行進行異或操作,也就是讓它自己高位與低位進行運算,這樣之後如果出現和它相似的hash值,只要這個相似值有一點點變化,最後異或後的結果都會有所不同。從而降低之後映射完發生碰撞的概率。
1.7版擾動了4次,因爲1.8版本加入了紅黑樹,並且本身後3次進行擾動他們的邊際效果不高,統計學上只產生一點的效能提高,加上做異或操作本身就是佔用性能的,所以1.8版本改進之後只擾動了一次,在紅黑樹的加持下,效率幾乎沒有下降。
二:返回hash值之後想存的值怎麼確定在數組得存儲位置?
if ((p = tab[i = (n - 1) & hash]) == null)
// 存進Node數組
tab[i] = newNode(hash, key, value, null);
直接看源碼,存在數組下標用了與操作,HashMap容量-1與hash值的與將其直接映射到數組的下標處(這裏也是爲什麼HashMap的容量是2的整數次冪的原因)
原理:
當HashMap爲2的整數次冪時,並將它減一後
16二進制:0001 0000 ——>15二進制: 0000 1111
一定會變成全1的二進制,這樣與hash值與操作時,其結果全由hash值得二進制後4位來決定存儲位置(一定爲0-15)。
也就是爲什麼需要擾動函數得原因之一-------讓二進制得後4位得隨機性更大
三:爲什麼是數組+鏈表,之後還要加紅黑樹?
爲了解決Hash值類似最後映射到相同數組下標得hash桶裏,我們解決Hash衝突得方法有多種,下面介紹兩種:
-
開放定址法:1.平方探查,2.線性探查,3.僞隨機序列,4.雙Hash函數
-
鏈地址法:數組加數組對應下標後延長鏈表
顯然HashMap用得鏈地址法
同時這裏的紅黑樹是對鏈表進行優化的方式,當出現hash全部撞到一起時,原本的O(1)查找會退化成O(n),我們是爲了去優化O(n)而引入的紅黑樹結構,將其優化成**O(logn)**查找,具體紅黑樹的介紹另開一篇。
四:怎麼擴容?擴容後原先結點是否要rehash?
直接上源碼+加上自己的註解
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;
}
// 不是初始化就擴容兩倍
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);
}
// 閾值 = DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 這裏初始化容量 16
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 初始化時直接跳走,因爲oldcap爲0
// 真正的當達到閾值時,進行擴容的操作
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 將當前不爲空的鏈表傳給e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果就一個值時對新容量的大小進行rehash
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:順序不變
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 遍歷數組鏈表
next = e.next;
// 這裏hash值和原先的容量進行取 與
// 很騷的是這裏結果不是爲1就是爲0
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;
// 因爲是擴容2倍
// 高位存在擴容後的第二倍的相同位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
五:負載因子爲什麼是0.75?
這裏其實源碼有解釋,大致意思就是
- 當負載因子小時,我們數組容量還很大,就會被迫提前進行擴容這個費時又費空間的操作。
- 而負載因子大時,我們空閒的數組容量不夠了,就會發生很多次的hash碰撞,造成查找上的時間浪費。
- 而0.75是我們綜合時間複雜度和空間複雜度的權衡,最終經過多次測試選定的值。
六:默認數組容量爲什麼是16?
其實這個問題主要問的是爲什麼是2的整數次冪,其次問的爲什麼是16,
- 2^n是因爲之後我們需要使用數組容量在插入元素和擴容時都需要與key的hash值進行與操作,只有當2的n次冪長度時,它的長度再減一的二進制形式全爲1,
- 16二進制:0001 0000 ——>15二進制: 0000 1111
- 當與全爲1的二進制進行與時,對於存儲數組在哪個下標的位置的控制權才能全權交給hash值得二進制來控制,並且剛好將hash值映射到數組下標範圍,沒有超出,很騷的操作。
- 其次第二問題,爲什麼是16,不是8,32,原因也很簡單:太小了就有可能頻繁發生擴容,影響效率。太大了又浪費空間,不划算。
七:爲什麼鏈表長度大於8才轉紅黑樹?
JDK1.7版本里僅僅只是數組加鏈表並沒有紅黑樹,1.8才加,所以源碼因此也膨脹了一倍(裏面自己實現了一個treemap),當hashmap產生了鏈表形態。說明產生了hash碰撞,這個本身就是一件不好的現象,那爲什麼不提前轉紅黑呢?雖然紅黑樹查找效率相比鏈表提升到了O(logn),但是建造紅黑和插入元素後維持紅黑的形態本身就太麻煩了,TreeNodes佔用空間是普通Nodes的兩倍,所以只有當bin包含足夠多的節點時纔會轉成TreeNodes
所以得出結論紅黑樹本身就是雙刃劍,雖然查找效率高,但是建造和維護浪費的性能也很大。
同時源碼提到,hashcode受隨機分佈的影響,所以存在數組的下標也是收概率分佈影響,(泊松分佈),如果一個好的hash算法,是會將隨機性,降到很低,所以形成一個長鏈表本身也是一個概率極低的事件。
既然概率極低,一旦發生了說明此事件的嚴重性,甚至說這是人爲攻擊,後續碰撞的概率會很大,那就必須要運用紅黑樹來進行優化了,不然可能後續會造成更嚴重的後果。
Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
搬一波源碼解釋,因爲泊松概率到達結點8時概率不及百萬分之一,對此既然產生了這種情況,機器就會去判斷這次事件比較嚴重,需要紅黑樹優化。
static final int UNTREEIFY_THRESHOLD = 6;
當resize時鏈表結點後續小於6個時,又會回成鏈表。
但是remove時紅黑樹結點必須要將近刪完,纔會將其轉化爲鏈表。
這也是爲了防止轉化紅黑樹時,資源過度浪費。
所以本身用到紅黑樹的情況幾乎很少,大概率是受到了黑客攻擊。
八:爲何HashMap線程不安全?
1.7版本的不安全不想說了,說白了就是擴容的時候轉移鏈表造成了鏈表指針的循環死鎖,數據順序改變。
我們現在用的是1.8版本,其高低位指針本身就優化了這個,但任然還是不安全的,是因爲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)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 當這裏假設有兩個線程A,B,他們各有一個hash值不相同,
// 但是卻進行與操作之後到達了同一個數組下標,
// 此時線程A阻塞,讓線程B執行,線程B將值傳入後,
// 線程B又阻塞,線程A也在這個數組下標存值,
// 最後造成數據覆蓋,不安全
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;