一、為什么需要HashMap?
在我們寫程序的時候經常會遇到數據檢索等操作,對於幾百個數據的小程序而言,數據的存儲方式或是檢索策略沒有太大影響,但對於大數據,效率就會差很遠。
1、線性檢索:
線性檢索是最為直白的方法,把所有數據都遍歷一遍,然后找到你所需要的數據。其對應的數據結構就是數組,鏈表等線性結構,這種方式對於大數據而言效率極低,其時間復雜度為O(n)。
2、二分搜索:
二分搜索算是對線性搜索的一個改進,比如說對於[1,2,3,4,5,6,7,8],我要搜索一個數(假設是2),我先將這個數與4(這個數一般選中位數比較好)比較,小於4則在4的左邊[1,2,3]中查找,再與2比較,相等,就成功找到了,這種檢索方式好處在於可以省去很多不必要的檢索,每次只用查找集合中一半的元素。其時間復雜度為O(logn)。但其也有限制,數排列本身就需要是有序的。
3、Hash表中的查找:
好了,重點來了,Hash表閃亮登場,這是一種時間復雜度為O(1)的檢索,就是說不管你數據有多少只需要查一次就可以找到目標數據。大家請看下圖。
大家可以看到這個數組中的值就等於其下標,比如說我要存11,我就把它存在a[11]里面,這樣我要找某個數字的時候就直接對應其下標就可以了。這其實是一種犧牲空間換時間的方法,這樣會對內存占用比較大,但檢索速度極快,只需要搜索一次就能查到目標數據。
看了上面的Hash表你肯定想問,如果我只存一個數10000,那我不是要存在a[10000],這樣其他空間不是白白浪廢了嗎,好吧,不存在的。Hash表已經有了其應對方法,那就是Hash函數。Hash表的本質在於可以通過value本身的特征定位到查找集合的元素下標,從而快速查找。一般的Hash函數為:要存入的數 mod(求余) Hash數組長度。比如說對於上面那個長度為9的數組,12的位置為12 mod 9=3,即存在a3,通過這種方式就可以安放比較大的數據了。
4、Hash沖突解決策略
看了上面的講解,有出現了一個問題,通過求余數得到的地址可能是一樣的。這種我們稱為Hash沖突,如果數據量比較大而Hash桶比較小,這種沖突就很嚴重。我們采取如下方式解決沖突問題。
我們可以看到12和0的位置沖突了,然后我們把該數組的每一個元素變成了一個鏈表頭,沖突的元素放在了鏈表中,這樣在找到對應的鏈表頭之后會順着鏈表找下去,至於為什么采用鏈表,是為了節省空間,鏈表在內存中並不是連續存儲,所以我們可以更充分地使用內存。
上面講了那么多,那跟我們今天的主題HashMap有什么關系呢?進入正題。我們知道HashMap中的值都是key,value,這里的存儲與上面的很像,key會被映射成數據所在的地址,而value就在以這個地址為頭的鏈表中,這種數據結構在獲取的時候就很快。
但是又出現了一個問題:如果hash桶較小,數據量較大,就會導致鏈表非常的長。所以就出現了紅黑樹。
二、紅黑樹的出現
在JDK1.6,JDK1.7中,HashMap采用位桶+鏈表實現,即使用鏈表處理沖突,同一hash值的鏈表都存儲在一個鏈表里。但是當位於一個桶中的元素較多,即hash值相等的元素較多時,通過key值依次查找的效率較低。而JDK1.8中,HashMap采用位桶+鏈表+紅黑樹實現,當鏈表長度超過閾值(8)時,將鏈表轉換為紅黑樹,這樣大大減少了查找時間。在jdk1.8版本后,java對HashMap做了改進,在鏈表長度大於8的時候,將后面的數據存在紅黑樹中,以加快檢索速度。
JDK1.8HashMap的紅黑樹是這樣解決的:
如果某個桶中的記錄過大的話(當前是TREEIFY_THRESHOLD = 8),HashMap會動態的使用一個專門的treemap實現來替換掉它。這樣做的結果會更好,是O(logn),而不是糟糕的O(n)。
它是如何工作的?前面產生沖突的那些KEY對應的記錄只是簡單的追加到一個鏈表后面,這些記錄只能通過遍歷來進行查找。但是超過這個閾值后HashMap開始將列表升級成一個二叉樹,使用哈希值作為樹的分支變量,如果兩個哈希值不等,但指向同一個桶的話,較大的那個會插入到右子樹里。如果哈希值相等,HashMap希望key值最好是實現了Comparable接口的,這樣它可以按照順序來進行插入。這對HashMap的key來說並不是必須的,不過如果實現了當然最好。如果沒有實現這個接口,在出現嚴重的哈希碰撞的時候,你就並別指望能獲得性能提升了。
三、實現原理
HashMap可以看成是一個大的數組,然后每個數組元素的類型是Node類。當添加一個元素(key-value)時,就首先計算元素key的hash值,以此確定插入數組中的位置,但是可能存在同一hash值的元素已經被放在數組同一位置了,這時就添加到同一hash值的元素的后面,他們在數組的同一位置,但是形成了鏈表,同一各鏈表上的Hash值是相同的,所以說數組存放的是鏈表。而當鏈表長度太長時,鏈表就轉換為紅黑樹,這樣大大提高了查找的效率。
當鏈表數組的容量超過初始容量的0.75時,再散列將鏈表數組擴大2倍,把原鏈表數組的搬移到新的數組中。
四、數據結構
上面說過HashMap可以看成是一個大的數組,然后每個數組元素的類型是Node類型,源碼里定義如下:
transient Node<K,V>[] table;
注意Node類還有兩個子類:TreeNode和Entry
TreeNode <K,V> extends Entry<K,V> extends Node<K,V>
上圖中的鏈表就是Node類,而紅黑樹正是TreeNode類。
HashMap存取put/get
//對外開發使用
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//存值的真正執行者
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//定義一個數組,一個鏈表,n永遠存放數組長度,i用於存放key的hash計算后的值,即key在數組中的索引
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判斷table是否為空或數組長度為0,如果為空則通過resize()實例化一個數組並讓tab作為其引用,並且讓n等於實例化tab后的長度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根據key經過hash()方法得到的hash值與數組最大索引做與運算得到當前key所在的索引值,並且將當前索引上的Node賦予給p並判斷是否該Node是否存在
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);//若tab[i]不存在,則直接將key-value插入該位置上。
//該位置存在數據的情況
else {
Node<K,V> e; K k; //重新定義一個Node,和一個k
// 該位置上數據Key計算后的hash等於要存放的Key計算后的hash並且該位置上的Key等於要存放的Key
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p; //true,將該位置的Node賦予給e
else if (p instanceof TreeNode) //判斷當前桶類型是否是TreeNode
//ture,進行紅黑樹插值法,寫入數據
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//false, 遍歷當前位置鏈表
for (int binCount = 0; ; ++binCount) {
//查找當前位置鏈表上的表尾,表尾的next節點必然為null,找到表尾將數據賦給下一個節點
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); //是,直接將數據寫到下個節點
// 如果此時已經到第八個了,還沒找個表尾,那么從第八個開始就要進行紅黑樹操作
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash); //紅黑樹插值具體操作
break;
}
//如果當前位置的key與要存放的key的相同,直接跳出,不做任何操作
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
//將下一個給到p進行逐個查找節點為空的Node
p = e;
}
}
//如果e不為空,即找到了一個去存儲Key-value的Node
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//當最后一次調整之后Size大於了臨界值,需要調整數組的容量
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
取值:get(key)方法時獲取key的hash值,計算hash&(n-1)得到在鏈表數組中的位置first=tab[hash&(n-1)],先判斷first的key是否與參數key相等,不等就遍歷后面的鏈表找到相同的key值返回對應的Value值即可
//對外公開方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//實際邏輯控制方法
final Node<K,V> getNode(int hash, Object key) {
//定義相關變量
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//保證Map中的數組不為空,並且存儲的有值,並且查找的key對應的索引位置上有值
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// always check first node 第一次就找到了對應的值
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
//判斷下一個節點是否存在
if ((e = first.next) != null) {
//true,檢測是否是TreeNode
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key); //通過TreeNode的get方法獲取值
//否,遍歷鏈表
do {
//判斷下一個節點是否是要查找的對象
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
}while ((e = e.next) != null);
}
}//未找到,返回null
return null;
}
擴容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //未擴容時數組的容量
int oldThr = threshold;
int newCap, newThr = 0;//定義新的容量和臨界值
//當前Map容量大於零,非第一次put值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { //超過最大容量:2^30
//臨界值等於Integer類型的最大值 0x7fffffff=2^31-1
threshold = Integer.MAX_VALUE;
return oldTab;
}
//當前容量在默認值和最大值的一半之間
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //新臨界值為當前臨界值的兩倍
}
//當前容量為0,但是當前臨界值不為0,讓新的容量等於當前臨界值
else if (oldThr > 0)
newCap = oldThr;
//當前容量和臨界值都為0,讓新的容量為默認值,臨界值=初始容量*默認加載因子
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的臨界值為0
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
//臨界值賦值
threshold = newThr;
//擴容table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = 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;//此時newCap = oldCap*2
else if (e instanceof TreeNode) //節點為紅黑樹,進行切割操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //鏈表的下一個節點還有值,但節點位置又沒有超過8
//lo就是擴容后仍然在原地的元素鏈表
//hi就是擴容后下標為 原位置+原容量 的元素鏈表,從而不需要重新計算hash。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循環鏈表直到鏈表末再無節點
do {
next = e.next;
//e.hash&oldCap == 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);
//循環鏈表結束,通過判斷loTail是否為空來拷貝整個鏈表到擴容后table
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
HashMap put與resize的實例圖
五、為什么是紅黑樹?為什么不直接采用紅黑樹還要用鏈表?
1、因為紅黑樹需要進行左旋,右旋操作, 而單鏈表不需要,
如果元素小於8個,查詢成本高,新增成本低
如果元素大於8個,查詢成本低,新增成本高
2、參考:AVL樹和紅黑樹之間有什么區別?
————————————————
版權聲明:本文為CSDN博主「Lin_Dong_Tian」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/qq_40645822/article/details/91139215