HashMap的底層實現(xiàn)原理分析
一、HashMap底層實現(xiàn)結(jié)構(gòu)
在JDK1.7以前,HashMap的底層數(shù)據(jù)結(jié)構(gòu)的實現(xiàn)是數(shù)組 + 鏈表的實現(xiàn)方式。
但是在1.8之后HashMap的實現(xiàn)是數(shù)組 + 鏈表 + 紅黑樹
在了解HashMap實現(xiàn)原理之前,我們先要知道:
- HashMap數(shù)據(jù)底層具體存儲的是什么?
- 這樣的存儲方式有什么優(yōu)點?
1.1、HashMap數(shù)據(jù)底層具體存儲的是什么
從源碼可知,HashMap類中有一個非常重要的字段,就是Node[] table,即哈系桶數(shù)組,明顯它是一個Node 數(shù)組,在JDK1.8中的實現(xiàn)如下:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //?來定位數(shù)組索引位置 final K key; // // 當前節(jié)點的 key V value; // 當前節(jié)點的 value Node<K,V> next; //鏈表的下?個node,在整個節(jié)點對象中, 僅有一個 next 節(jié)點, 說明該鏈表是一個單向鏈表 Node(int hash, K key, V value, Node<K,V> next) { … } public final K getKey(){ … } public final V getValue() { … } public final String toString() { … } public final int hashCode() { … } public final V setValue(V newValue) { … } public final boolean equals(Object o) { … } }
Node是HashMap的一個內(nèi)部類,實現(xiàn)Map.Entry接口,本質(zhì)上就是一個鍵值對的類型。
1.2、這樣的存儲方式有什么優(yōu)點
HashMap就是使用哈希表來存儲的。哈希表為解決沖突,可以采用開放地址法和鏈地址法等來解決 問題, Java中HashMap采用了鏈地址法。鏈地址法,簡單來說,就是數(shù)組加鏈表的結(jié)合。
在每個數(shù)組元 素上都一個鏈表結(jié)構(gòu),當數(shù)據(jù)被Hash后,得到數(shù)組下標,把數(shù)據(jù)放在對應(yīng)下標元素的鏈表上。
例如程序 執(zhí)行下面代碼知:
map .put("美團" ,"?美");
系統(tǒng)將調(diào)用"美團"這個key的hashCode()方法得到其hashCode 值,該方法適用于每個Java對象
然后再通過Hash算法的后兩步運算樣(高位運算和取模運算)來定位該鍵值對的存儲位置,有時兩個key會定位到相同的位置,表示發(fā)生了Hash碰撞。當然Hash算法計算結(jié)果越分散均勻, Hash碰撞的概率就越小,map的存取效率就會越高。
如果哈希桶數(shù)組很大,即使較差的Hash算法也會比較分散,如果哈希桶數(shù)組數(shù)組很小,即使好的Hash算法也會出現(xiàn)較多碰撞,所以就需要在空間成本和時間成本之間權(quán)衡,其實就是在根據(jù)實際情況確定哈希桶數(shù)組的大小,并在此基礎(chǔ)上設(shè)計好的hash算法減少Hash碰撞。那么通過什么方式來控制map使得Hash碰撞的概率又小,哈希桶數(shù)組(Node[] table)占用空間又少呢?答案就是好的Hash算法和擴容機制。
在理解Hash和擴容流程之前,我們得先了解下HashMap的幾個字段:
int threshold; // 所能容納的key-value對極限 final float loadFactor; // 負載因? int modCount; int size;
loadFactor
:負載因子- 默認為0.75 ,是決定擴容閾值的重要因素之一
threshold
:擴容的閾值- 擴容閾值的計算公式:
threshold = length * LoadFactor
,length為數(shù)組的長度 - threshold就是在此loadFactor和length(數(shù)組長度)對應(yīng)下允許的最大元 素數(shù)目,超過這個數(shù)目就重新resize(擴容) ,擴容后的HashMap容量是之前容量的兩倍
modCount
:記錄內(nèi)部結(jié)構(gòu)發(fā)生變化的次數(shù)- 內(nèi)部結(jié)構(gòu)發(fā)生變化指的是結(jié)構(gòu)發(fā)生變化,例如put新鍵值 對,但是某個key對應(yīng)的value值被覆蓋不屬于結(jié)構(gòu)變化
size
:HashMap中存在的鍵值對數(shù)量- 注意和table的長度length 、容納 最大鍵值對數(shù)量threshold的區(qū)別
這里存在一個問題,即使負載因子和Hash算法設(shè)計的再合理,也免不了會出現(xiàn)拉鏈過長的情況,一旦出 現(xiàn)拉鏈過長,則會嚴重影響HashMap的性能。于是,在JDK1.8版本中,對數(shù)據(jù)結(jié)構(gòu)做了進一步的優(yōu) 化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉(zhuǎn)換為紅黑樹,利用紅黑樹快速增刪 改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。
二、功能實現(xiàn)
2.1、確定哈希桶數(shù)組索引位置
不管增加、刪除、查找鍵值對,定位到哈希桶數(shù)組的位置都是很關(guān)鍵的第一步。前面說過HashMap的 數(shù)據(jù)結(jié)構(gòu)是數(shù)組和鏈表的結(jié)合,所以我們當然希望這個HashMap里面的元素位置盡量分布均勻些,盡 量使得每個位置上的元素數(shù)量只有一個,那么當我們用hash算法求得這個位置的時候,馬上就可以知道 對應(yīng)位置的元素就是我們要的,不用遍歷鏈表,大大優(yōu)化了查詢的效率。 HashMap定位數(shù)組索引位 置,直接決定了hash方法的離散性能。
先看看源碼的實現(xiàn)(方法一+方法二):
?法?: static final int hash(Object key) { //jdk1.8 & jdk1.7 int h; // h = key.hashCode() 為第?步 取hashCode值 // h ^ (h >>> 16) 為第?步 ?位參與運算 // 1001 0111 0011 0101 1101 1110 1001 1111 => hash code // 0000 0000 0000 0000 1001 0111 0011 0101 => 右移 16 位 // 1001 0111 0011 0101 0100 1001 1010 1010 => 異或運算 // 讓同一個 key 的高位與地位都參與 hash 運算 // 目的: 降低 hash 碰撞的概率 // 最終返回一個 異或運算 后的一個更混亂的 hash 值 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ?法?: static int indexFor(int h, int length) { //jdk1.7的源碼,jdk1.8沒有這個?法,但是實現(xiàn)原理?樣的 return h & (length-1); //第三步 取模運算 }
對于任意給定的對象,只要它的hashCode()返回值相同,那么程序調(diào)用方法一所計算得到的Hash碼值總是相同的。我們首先想到的就是把hash值對數(shù)組長度取模運算,這樣一來,元素的分布相對來說是比較均勻的。但是,模運算的消耗還是比較大的,在HashMap中是這樣做的:調(diào)用方法二來計算該對象 應(yīng)該保存在table數(shù)組的哪個索引處。
這個方法非常巧妙,它通過h & (table.length -1)
來得到該對象的保存位,而HashMap底層數(shù)組的長度總 是2的n次方,這是HashMap在速度上的優(yōu)化。當length總是2的n次方時, h& (length-1)
運算等價于對 length取模,也就是h%length
,但是&比%具有更高的效率。
在JDK1.8的實現(xiàn)中,優(yōu)化了高位運算的算法,通過hashCode()的高16位異或低16位實現(xiàn)的知(h = k.hashCode()) ^ (h >>> 16)
,主要是從速度、功效、質(zhì)量來考慮的,這么做可以在數(shù)組table的length 比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。
下面舉例說明下, n為table的長度:
2.2、HashMap的put方法
HashMap的put方法執(zhí)行過程可以通過下圖來理解:
下面是JDK1.8 的HashMap put方法源碼
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
實際上是putVal
方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K, V>[] tab; // Node 數(shù)組 => hash 表, 額外的變量 Node<K, V> p; // 當前 key 存放索引位置的元素 int n, // table 數(shù)組的長度 i; // 當前 key 經(jīng)過運算后的索引位置 => (長度 - 1) & hash // 如果 table 數(shù)組為 null 或數(shù)組的長度為 0, 就進行擴容 => 初始化 if ((tab = table) == null || (n = tab.length) == 0) // 第一次擴容實際就是 table 的初始化 n = (tab = resize()).length; // 索引 = (長度 - 1) & hash // 例子: // hash = 1001 0111 0011 0101 0100 1001 1010 1010 // length - 1 = 0000 0000 0000 0000 0000 0000 0000 1111 // 索引 = 0000 0000 0000 0000 0000 0000 0000 1010 // 計算索引位置, 并訪問該索引位置得到節(jié)點賦值給 p, 且判斷 p 是否為 null if ((p = tab[i = (n - 1) & hash]) == null) // 說明索引位置沒有值 // 如果該索引位置沒有值, 就直接創(chuàng)建一個新的節(jié)點, 并將其存在這個位置上 tab[i] = newNode(hash, key, value, null); else { // 如果這個索引位置已經(jīng)有值 Node<K, V> e; // 上一個相同 key 的節(jié)點對象, 與本次存入的新的 key 完全相同的 Node 對象 K k; // 當前索引位置上的 key // 判斷當前索引位置的元素與新存入的 key 是否完全相同 // 第一種情況: 直接拿頭節(jié)點進行比較, 不關(guān)心他是樹節(jié)點, 還是鏈表節(jié)點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 如果這個位置上的 key 與即將存入的 key 完全相同, 就直接將當前節(jié)點賦值給 e e = p; // 第二種情況: 判斷是否是樹節(jié)點, 也就是判斷當前節(jié)點是否已經(jīng)發(fā)生過 hash 沖突, 且已經(jīng)變成紅黑樹 else if (p instanceof TreeNode) // 判斷當前節(jié)點是否已經(jīng)變成樹節(jié)點 => 判斷是否已經(jīng)變成紅黑樹 // 往紅黑樹中存入當前新的 key/value, 并返回存入后的對象賦值給 e e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value); else { // 第三種情況: 如果以上兩者都不是, 就默認為鏈表, 判斷是否有下一節(jié)點, 進行循環(huán)判斷 for (int binCount = 0; ; ++binCount) { // 計數(shù), 計算鏈表的長度 // 判斷當前節(jié)點是否有下一個節(jié)點, 如果為 null if ((e = p.next) == null) { // 將即將存入的 key/value 存入一個新的節(jié)點, 并設(shè)置到當前節(jié)點的 next p.next = newNode(hash, key, value, null); // 判斷當前鏈表長度是否大于樹化的閾值(7) if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 鏈表長度 >= 8, 那么就準備進行轉(zhuǎn)成樹 treeifyBin(tab, hash); break; // 這次循環(huán)結(jié)束 } // 此時 e = 當前正在遍歷的鏈表元素 // 判斷該元素的 hash 與 key 是否與即將存入的 key 完全相同 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果相同, 直接結(jié)束循環(huán) p = e; // 將當前遍歷的節(jié)點, 賦值給之前索引位置的節(jié)點 } } // 如果找到了與本次要存入的 key 完全相同的節(jié)點, 直接將本次存入新的 value 替換舊節(jié)點的 value if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) // 如果配置了只有在不存在時才存入 或 原來的值為 空 e.value = value; // 將新的 value 覆蓋原來舊的值 afterNodeAccess(e); return oldValue; } } ++modCount; // 并發(fā)修改數(shù)的記錄 if (++size > threshold) // 存入鍵值對的數(shù)量+1, 且判斷是否大于擴容閾值 resize(); // 擴容 afterNodeInsertion(evict); return null; }
2.3、HashMap的擴容原理
擴容(resize)就是重新計算容量,向HashMap對象里不停的添加元素,而HashMap對象內(nèi)部的數(shù)組無法裝載更多的元素時,對象就需要擴大數(shù)組的長度,以便能裝入更多的元素。當然Java里的數(shù)組是無法自動擴容的,方法是使用一個新的數(shù)組代替已有的容量小的數(shù)組,就像我們用一個小桶裝水,如果想裝更 多的水,就得換大水桶。
接下來就是JDK1.8的有關(guān)HashMap擴容的源碼:
final Node<K, V>[] resize() { // 將當前 Map 中的 hash 表保存到 oldTab 變量 Node<K, V>[] oldTab = table; // 再基于舊的數(shù)組長度得到容量, 如果舊的數(shù)組為 null(剛 new 的對象, 還沒初始化數(shù)組), 返回容量為 0 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 將當前的擴容閾值作為舊的擴容閾值 int oldThr = threshold; // 用于存儲新的容量以及擴容閾值 int newCap, newThr = 0; // 已經(jīng)初始化: 舊的容量肯定大于 0 if (oldCap > 0) { // 判斷舊的容量是否已經(jīng)大于最大容量 if (oldCap >= MAXIMUM_CAPACITY) { // 如果大于, 就將閾值設(shè)置為 Integer 的最大值 threshold = Integer.MAX_VALUE; // 并且直接返回舊的數(shù)組 return oldTab; /** * 下方計算擴容后的新容量以及新閾值 * 新的容量 = 舊的容量左移 1 位 === 舊的容量 * 2 * 判斷: 如果新容量 < 最大容量 AND 舊容量 >= 16 * 如果判斷成立, 閾值也是 * 2 */ } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // // 新的閾值為舊閾值的兩倍 newThr = oldThr << 1; // double threshold } /** * 如果跳過上面的 if 沒有進入, 走到下方任意一個邏輯, 都代表當前的 table 沒有被初始化過 * 1. new HashMap(容量, 加載因子) * 2. new HashMap(容量) */ else if (oldThr > 0) // initial capacity was placed in threshold // 將舊的閾值作為新的容量 newCap = oldThr; else { // zero initial threshold signifies using defaults // new HashMap() => 進入這個邏輯 // 默認新的容量為 16 newCap = DEFAULT_INITIAL_CAPACITY; // 新的閾值 = 默認的負載因子(0.75) * 默認容量(16) newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 如果在上方的 if 判斷中, 進入的是中間的邏輯, 那么就會進入下方的邏輯 if (newThr == 0) { // 臨時閾值 = 新的容量 * 負載因子 float ft = (float) newCap * loadFactor; // 新的閾值或新的容量 > 最大值, 就返回 Integer 的最大值 // 否則就為上面計算的臨時閾值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ? (int) ft : Integer.MAX_VALUE); } // 將計算后的新閾值, 覆蓋當前 map 中的閾值變量 threshold = newThr; // 基于新的容量創(chuàng)建一個新的 Node 數(shù)組 @SuppressWarnings({"rawtypes", "unchecked"}) Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; // 直接將新創(chuàng)建的數(shù)組覆蓋當前 map 中的數(shù)組 table = newTab; // 已經(jīng)初始化過了, 肯定就不會為 null, 同樣也說明如果未初始化, 該邏輯就不會執(zhí)行 if (oldTab != null) { // 對數(shù)組舊的容量進行遍歷 for (int j = 0; j < oldCap; ++j) { // e == 當前遍歷的元素 Node<K, V> e; if ((e = oldTab[j]) != null) { // 將當前位置重置為 null oldTab[j] = null; // 判斷當前是否為一個鏈表, 如果沒有下一個說明該元素就是單個節(jié)點, 還未變成鏈表 if (e.next == null) // 重新計算該元素在新數(shù)組中的索引位置, 并將該元素存入新數(shù)組 newTab[e.hash & (newCap - 1)] = e; // 如果當前的 e 已經(jīng)是紅黑樹的根節(jié)點 else if (e instanceof TreeNode) // 如果是紅黑樹, 就將這棵樹做切割, 如果切割后單顆樹的長度 < 6 會重新轉(zhuǎn)換為鏈表 ((TreeNode<K, V>) e).split(this, newTab, j, oldCap); else { // preserve order // 既不是單個節(jié)點, 也不是紅黑樹, 就一定是鏈表 // 對鏈表進行拆分, 拆成高位鏈表與地位鏈表兩個 // 下方定義的變量為低位鏈表的 頭/尾 節(jié)點 Node<K, V> loHead = null, loTail = null; // 下方定義的變量為高位鏈表的 頭/尾 節(jié)點 Node<K, V> hiHead = null, hiTail = null; // 臨時變量, 用于作為遍歷鏈表時的下一個節(jié)點 Node<K, V> next; do { next = e.next; // 將當前元素的 hash 與舊數(shù)組長度做 & 運算 // 實際是在拿 hash 的第5位數(shù)與舊數(shù)組長度的第五位數(shù)做 & 運算 // 最終得到的結(jié)果, 要么就是 0, 要么就是 1 // 如果最終結(jié)果為 0: 那么該元素就會被存入低位鏈表 // 如果最終結(jié)果為 1: 那么該元素就會被存入高位鏈表 if ((e.hash & oldCap) == 0) { if (loTail == null) // 如果當前鏈表還沒初始化, 意味著尾節(jié)點不存在 loHead = e; // 如果尾節(jié)點不存在, 那么基于尾插法, 就是將當前節(jié)點直接作為低位鏈表的頭節(jié)點 else loTail.next = e; // 如果已經(jīng)初始化過了, 就將當前元素直接插入到鏈表的最后 loTail = e; // 并且將當前節(jié)點設(shè)置為尾節(jié)點 } else { if (hiTail == null) // 如果當前尾節(jié)點不存在, 說明鏈表還沒初始化 hiHead = e; // 因此將當前節(jié)點作為頭節(jié)點 else hiTail.next = e; // 如果已經(jīng)初始化了, 就將當前節(jié)點作為鏈表的最后一個節(jié)點 hiTail = e; // 同時由于是尾插法, 索引每次移動的這個元素一定是鏈表的最后一個元素, 因此直接作為尾節(jié)點 } } while ((e = next) != null); // 如果還有下一個節(jié)點, 就繼續(xù)遍歷 if (loTail != null) { // 判斷低位的尾不為空 ==> 實際就是在判斷低位鏈表是有值的 loTail.next = null; // 將尾的下一個設(shè)置為 null newTab[j] = loHead; // 將低位鏈表的頭節(jié)點, 重新連接到新數(shù)組的當前位置 } if (hiTail != null) { // 如果高位鏈表為空 ==> 實際就是在判斷高位鏈表是有值的 hiTail.next = null; // 將高位鏈表的尾節(jié)點設(shè)置為 空 newTab[j + oldCap] = hiHead; // 將高位鏈表的頭節(jié)點設(shè)置到新數(shù)組的當前位置 + 舊的長度的位置去 } } } } } return newTab; }
當初看源碼的時候不清楚這里為什么要將鏈表拆分為高位鏈表和低位鏈表
后面查資料才發(fā)現(xiàn)HashMap在擴容的時候是擴容到原來的2倍,所以,元素的位置要么在原位置,要么就是根據(jù)原位置再移動2次冪的位置(也就是原位置兩倍的地方),所以就是因為有這兩個區(qū)別,所以在對元素重新進行索引的計算的時候分了高位鏈表和低位鏈表。
而元素在重新計算hash之后,因為n變?yōu)?倍,那么n-1的mask范圍在高位多1bit(紅色) ,因此新的index就會發(fā)生這樣的變化:
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現(xiàn)那樣重新計算hash ,只需要看看原來的 hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成原索引+oldCap
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
解決IDEA集成Docker插件后出現(xiàn)日志亂碼的問題
這篇文章主要介紹了解決IDEA集成Docker插件后出現(xiàn)日志亂碼的問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-11-11簡單了解Java關(guān)鍵字throw和throws的區(qū)別
這篇文章主要介紹了簡單了解Java關(guān)鍵字throw和throws的區(qū)別,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2019-11-11SpringBoot @PropertySource與@ImportResource有什么區(qū)別
這篇文章主要介紹了SpringBoot @PropertySource與@ImportResource有什么區(qū)別,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習吧2023-01-01Java Volatile應(yīng)用單例模式實現(xiàn)過程解析
這篇文章主要介紹了Java Volatile應(yīng)用單例模式實現(xiàn)過程解析,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-11-11MybatisPlus將自定義的sql列表查詢返回改為分頁查詢
本文主要介紹了MybatisPlus將自定義的sql列表查詢返回改為分頁查詢,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2024-04-04