目錄
HashMap
底層結構:哈希表
HashMap底層是一個哈希表(又稱散列表),哈希表的主幹是一個數組。
先過一下哈希表數據結構的相關概念:
- 爲什麼:
數組和鏈表各有自己的優勢和劣勢,那麼我們能不能綜合兩者的特性,做出一種尋址容易,插入刪除也容易的數據結構?哈希表就是爲此而實現的。 - 是什麼:
- 哈希表:根據結點的 關鍵字 通過 Hash函數 直接計算出該結點的存儲地址,時間複雜度爲O(1)
- 關鍵字:數據元素中唯一標識該元素的某個數據項的值,比如學號
- 哈希函數:關鍵字——>對應地址(即哈希值)的函數,構造函數的原則有控制定義域和值域,地址均勻分佈和儘量簡單。常用散列函數有:直接定址法,除留取餘數法,數字分析法,平方取中法,摺疊法等,
- 哈希衝突:也叫哈希碰撞,多個關鍵字通過散列函數得到相同地址,稱作衝突。
- 裝載因子:描述所有關鍵字填充哈希表後飽和的程度,它等於 關鍵字總數/哈希表 的長度。
爲的是減緩哈希衝突,在初始桶還不滿的時候就進行擴容。
- 裝載因子:描述所有關鍵字填充哈希表後飽和的程度,它等於 關鍵字總數/哈希表 的長度。
- 衝突處理:任何設計出的散列函數都不可避免會產生衝突,因此必須考慮衝突發生後如何處理。
即爲產生衝突的關鍵字尋找下一個“空”的Hash地址。常用的方法有如下:- 開放地址法:發生衝突,繼續尋找下一塊未被佔用的存儲地,又分線性探測法,平方探測法,再散列法等
- 鏈地址法:數組連接鏈表,使散列值相同的都在同一鏈表中。HashMap即是採用了該方法
- 哈希表:根據結點的 關鍵字 通過 Hash函數 直接計算出該結點的存儲地址,時間複雜度爲O(1)
底層實現:數組+鏈表+紅黑樹
那麼Java中的哈希表Hashmap是如何實現的呢?
- 衝突處理:採用鏈地址法處理衝突,HashMap底層採取了 數組+鏈表 的實現,
JDK1.8之後,若同值元素超過8個則變爲 數組+紅黑樹 以提高查詢速度
接下來通過源碼來詳細瞭解和驗證一下(JDK8)
首先,數組+鏈表+紅黑樹 體現在哪裏?
// 主體是一個Node類型的數組
transient Node<K,V>[] table;
// 鏈表結點,繼承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //對key的hashcode值進行hash運算後得到的值
final K key;
V value;
Node<K,V> next; //存儲指向下一個Entry的引用,單鏈表結構
// ...
}
// 紅黑樹節點
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
// ...
}
一些基本參數
// 默認容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認負載因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 鏈表節點轉換紅黑樹節點的閾值, 9個節點轉
static final int TREEIFY_THRESHOLD = 8;
// 紅黑樹節點轉換鏈表節點的閾值, 6個節點轉
static final int UNTREEIFY_THRESHOLD = 6;
// 轉紅黑樹時, table的最小長度
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap的構造方法
- public HashMap(int initialCapacity, float loadFactor)
- 重要參數:初始容量(哈希桶數)和裝載因子
- initialCapacity默認爲16,loadFactory默認爲0.75
- 初始容量的設置:2的N次方就可以,實際可以根據自己使用情況進行設置,以避免擴容帶來的開銷。
- 爲什麼?一是在哈希中結合或運算(hash & (n-1))可以達到和取模同樣的效果,實現了均勻分佈。
二是當 n 不爲 2 的 N 次方時,hash 衝突的概率明顯增大。 - 怎麼做?主要是通過位運算和或運算來實現的,計算機底層是二進制的,移位和或運算是非常快的,所以這個方法的效率很高。源碼如下:
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
- 爲什麼?一是在哈希中結合或運算(hash & (n-1))可以達到和取模同樣的效果,實現了均勻分佈。
- 裝載因子的設置:要在時間和空間上進行權衡。如果值較高,例如1,此時會減少空間開銷,但是 hash 衝突的概率會增大,增加查找成本;而如果值較低,例如 0.5 ,此時 hash 衝突會降低,但是有一半的空間會被浪費,所以初始爲 0.75 似乎是一個合理的值。
- public HashMap(int initialCapacity)
- public HashMap()
- public HashMap(Map<? extends K,? extends V> m):
構造一個映射關係與指定 Map 相同的新 HashMap。
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
從上面這段代碼我們可以看出,在常規構造器中,沒有爲數組table分配內存空間(有一個入參爲指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組
put方法的工作流程
- 判斷table[]是否爲空,如果是空的就創建一個table
- 根據hashcode()計算索引位置,判斷table[i]處是否插入過值(有的話看看是key相同還是Hash衝突)
- 判斷鏈表長度是否大於8,如果大於就轉換爲紅黑二叉樹,並插入樹中(詳見轉換紅黑樹)
- 判斷key是否和原有key相同,如果相同就覆蓋原有key的value,並返回原有value
- 如果key不相同,就插入一個key,記錄結構變化一次
- 最後進行擴容判斷(詳見擴容機制)
接下來看一下put操作的具體實現
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) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校驗table是否爲空或者length等於0,如果是則調用resize方法進行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通過hash值計算索引位置,將該索引位置的頭節點賦值給p,如果p爲空則直接在該索引位置新增一個節點即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表該索引位置不爲空,則進行查找
Node<K,V> e; K k;
// 3.判斷p節點的key和hash值是否跟傳入的相等,如果相等, 則p節點即爲要查找的目標節點,將p節點賦值給e節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判斷p節點是否爲TreeNode, 如果是則調用紅黑樹的putTreeVal方法查找目標節點
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到這代表p節點爲普通鏈表節點,則調用普通的鏈表方法進行查找,使用binCount統計鏈表的節點數
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next節點爲空時,則代表找不到目標節點,則新增一個節點並插入鏈表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校驗節點數是否超過8個,如果超過則調用treeifyBin方法將鏈表節點轉爲紅黑樹節點,
// 減一是因爲循環是從p節點的下一個節點開始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e節點存在hash值和key值都與傳入的相同,則e節點即爲目標節點,跳出循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 將p指向下一個節點
}
}
// 9.如果e節點不爲空,則代表目標節點存在,使用傳入的value覆蓋該節點的value,並返回oldValue
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 用於LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入節點後節點數超過閾值,則調用resize方法進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 用於LinkedHashMap
return null;
}
存儲位置的確定流程
key——>hashcode——>hash()——>index
- hashCode():由系統隨機給出的一個十進制的整數
- hash():拿到 key 的 hashCode,並將 hashCode 的高16位和 hashCode 進行異或(XOR)運算,得到最終的 hash 值,
- 爲什麼要將 hashCode 的高16位參與運算?爲了讓高位也參與運算,不能讓索引結果只取決於低位。
- 計算索引位置的公式爲:(n - 1) & hash,當 n 爲 2 的 N 次方時,n - 1 爲低位全是 1 的值,此時任何值跟 n - 1 進行 & 運算會等於其本身,達到了和取模同樣的效果,但比 mod 具有更高的效率,實現了均勻分佈。
//方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 爲第一步 取hashCode值
// h ^ (h >>> 16) 爲第二步 高位參與運算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) { //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的
return h & (length-1); //第三步 取模運算
}
HashMap中Key是可以存入null值的,那此時如何存儲呢?
答:在 hash() 中就對這種情況進行處理了,key等於null時,存在0索引處。
關於紅黑樹的優化
- 爲什麼優化:鏈表過長會使哈希表性能下降,所以當鏈表過長時候,將鏈表轉爲搜索效率更高的紅黑樹。
- 爲什麼不一直使用樹:這是空間與時間之間的權衡。大多數哈希函數將產生非常少的衝突,因此爲大小不到8的桶維護樹將是不划算的。
- 爲什麼會用紅黑樹而不選擇AVL樹?
- 概念:
- 二叉排序樹:元素有大小順序的二叉樹,左子樹小,右子樹大。
- AVL樹:平衡二叉樹是二叉排序樹的改進,爲了避免樹的高度增長過快,降低排序樹的性能,規定在插入和刪除結點時,要保證任意結點左右子樹高度差不超過1。
- 紅黑樹:紅黑樹不追求"完全平衡",紅黑是用非嚴格的平衡來換取增刪節點時候旋轉次數的降低,任何不平衡都會在三次旋轉之內解決
- 平衡判定:紅黑樹使用紅黑二色進行“着色”,只要插入的節點“着色”滿足紅黑二色的規定,最短路徑與最長路徑不會相差的太遠,紅黑樹的節點分佈就能大體上達至均衡。
- 比較:總體各有所長,AVL查詢更快,RB增刪更快。
- 原因(不確定):
- 紅黑樹綜合性能更好
- 概念:
- 怎麼做:
- 什麼時候鏈表會轉紅黑樹
- 鏈表—>紅黑樹:統一索引位置鏈表長度超過8,並且數組長度長度超過64時
- 爲什麼選擇8:時間和空間上權衡的結果,紅黑樹節點大小約爲鏈表節點的2倍,在節點太少時,紅黑樹的查找性能優勢並不明顯;也是根據概率統計決定的,按照泊松分佈,鏈表長度爲8的概率非常小,這點源碼有解釋。
- 紅黑樹—>鏈表:結點數小於6(見前面的基本參數)
- 爲什麼選擇6不是8:6到8有一個過渡,如果也選8,那麼當節點個數在8徘徊時,就會頻繁進行紅黑樹和鏈表的轉換,造成性能的損耗。
- 鏈表—>紅黑樹:統一索引位置鏈表長度超過8,並且數組長度長度超過64時
- 如何將鏈表轉換爲二叉樹:
- treeifyBin:先將鏈表結點轉換爲樹節點,然後點用treeify方法
- treeify:按構造搜索樹的原則插入尾搜索樹;插入完之後在調整爲紅黑樹。
- 二叉搜索樹:一個一個結點比較,一般是左子樹小,右子樹大
- 什麼時候鏈表會轉紅黑樹
treeifyBin源碼
static final int MIN_TREEIFY_CAPACITY = 64;
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//這裏還有一個限制條件,當table的長度小於MIN_TREEIFY_CAPACITY(64)時,只是進行擴容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); //而擴容的時候,鏈表的長度有可能會變短
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//將鏈表中的結點轉換爲樹結點,形成一個新鏈表
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null) //將新的樹結點鏈表賦給第index個桶
hd.treeify(tab); //執行 TreeNode中的treeify()方法
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
treeify( )源碼
時間複雜度的優化:原來查找最壞是O(n),現在最壞是O(logn)
HashMap的擴容機制
- 初始16,每次擴容是原來的2倍,索引要重新計算
- 爲什麼要重新計算:爲了使分佈始終均勻,避免哈希衝突。
- 1.8的優化:擴容時插入方式從“頭插法”改成“尾插法”,避免了併發下的死循環,因爲JDK 1.8 之前存在死循環的根本原因是在擴容後同一索引位置的節點順序會反掉。
resize()方法源碼
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.老表的容量不爲0,即老表不爲空
if (oldCap > 0) {
// 1.1 判斷老表的容量是否超過最大容量值:如果超過則將閾值設置爲Integer.MAX_VALUE,並直接返回老表,
// 此時oldCap * 2比Integer.MAX_VALUE大,因此無法進行重新分佈,只是單純的將閾值擴容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 將newCap賦值爲oldCap的2倍,如果newCap<最大容量並且oldCap>=16, 則將新閾值設置爲原來的兩倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 2.如果老表的容量爲0, 老表的閾值大於0, 是因爲初始容量被放入閾值,則將新表的容量設置爲老表的閾值
else if (oldThr > 0)
newCap = oldThr;
else {
// 3.老表的容量爲0, 老表的閾值爲0,這種情況是沒有傳初始容量的new方法創建的空表,將閾值和容量設置爲默認值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.如果新表的閾值爲空, 則通過新的容量*負載因子獲得閾值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 5.將當前閾值設置爲剛計算出來的新的閾值,定義新表,容量爲剛計算出來的新容量,將table設置爲新定義的表。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 6.如果老表不爲空,則需遍歷所有節點,將節點賦值給新表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 將索引值爲j的老表頭節點賦值給e
oldTab[j] = null; // 將老表的節點設置爲空, 以便垃圾收集器回收空間
// 7.如果e.next爲空, 則代表老表的該位置只有1個節點,計算新表的索引位置, 直接將該節點放在該位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 8.如果是紅黑樹節點,則進行紅黑樹的重hash分佈(跟鏈表的hash分佈基本相同)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 9.如果是普通的鏈表節點,則進行普通的重hash分佈
Node<K,V> loHead = null, loTail = null; // 存儲索引位置爲:“原索引位置”的節點
Node<K,V> hiHead = null, hiTail = null; // 存儲索引位置爲:“原索引位置+oldCap”的節點
Node<K,V> next;
do {
next = e.next;
// 9.1 如果e的hash值與老表的容量進行與運算爲0,則擴容後的索引位置跟老表的索引位置一樣
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail爲空, 代表該節點爲第一個節點
loHead = e; // 則將loHead賦值爲第一個節點
else
loTail.next = e; // 否則將節點添加在loTail後面
loTail = e; // 並將loTail賦值爲新增的節點
}
// 9.2 如果e的hash值與老表的容量進行與運算爲1,則擴容後的索引位置爲:老表的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail爲空, 代表該節點爲第一個節點
hiHead = e; // 則將hiHead賦值爲第一個節點
else
hiTail.next = e; // 否則將節點添加在hiTail後面
hiTail = e; // 並將hiTail賦值爲新增的節點
}
} while ((e = next) != null);
// 10.如果loTail不爲空(說明老表的數據有分佈到新表上“原索引位置”的節點),則將最後一個節點
// 的next設爲空,並將新表上索引位置爲“原索引位置”的節點設置爲對應的頭節點
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 11.如果hiTail不爲空(說明老表的數據有分佈到新表上“原索引+oldCap位置”的節點),則將最後
// 一個節點的next設爲空,並將新表上索引位置爲“原索引+oldCap”的節點設置爲對應的頭節點
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回新表
return newTab;
}
如何實現數據遷移:
元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認爲是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。
1.7與1.8的區別總結
(如何查看不同版本的源碼:https://blog.csdn.net/hblock/article/details/78863214,需要有不同版本的免安裝文件)
1)底層數據結構從“數組+鏈表”改成“數組+鏈表+紅黑樹”,主要是優化了 hash 衝突較嚴重時,鏈表過長的查找性能:O(n) -> O(logn)。
2)計算 table 初始容量的方式發生了改變,老的方式是從1開始不斷向左進行移位運算,直到找到大於等於入參容量的值;新的方式則是通過“5個移位+或等於運算”來計算。
3)優化了 hash 值的計算方式,老的比較複雜,新的只是簡單的讓高16位參與了運算。
4)擴容時插入方式從“頭插法”改成“尾插法”,避免了併發下的死循環。
在擴容的時候,jdk1.8之前是採用頭插法,當兩個線程同時檢測到hashmap需要擴容,在進行同時擴容的時候有可能會造成鏈表的循環,主要原因就是,採用頭插法,新鏈表與舊鏈表的順序是反的,在1.8後採用尾插法就不會出現這種問題。
5)擴容時計算節點在新表的索引位置方式從“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但設計更巧妙、更優雅。
與其他Map的比較
與HashTable的區別
HashTable:底層也是哈希表,除了線程 安全和允許使用 null 之外 與 HashMap 大致相同,已被取代。
- HashMap 允許 key 和 value 爲 null,Hashtable 不允許。
- HashMap 的默認初始容量爲 16,Hashtable 爲 11。
- HashMap 的擴容爲原來的 2 倍,Hashtable 的擴容爲原來的 2 倍加 1。
- HashMap 是非線程安全的,Hashtable是線程安全的。
- HashMap 的 hash 值重新計算過,Hashtable 直接使用 hashCode。
- HashMap 去掉了 Hashtable 中的 contains 方法。
- HashMap 繼承自 AbstractMap 類,Hashtable 繼承自 Dictionary 類。
參考資料
- https://blog.csdn.net/v123411739/article/details/78996181
- https://blog.csdn.net/v123411739/article/details/106324537
- https://blog.csdn.net/ThinkWon/article/details/104588551
- https://blog.csdn.net/pange1991/article/details/82347284
- https://www.bilibili.com/video/BV1ye411s7z8