HashMap
是什麼
HashMap是一個用於儲存Key-Value鍵值對的集合,每一個鍵值對也叫做Entry。這些個鍵值對(Entry)分散儲存在一個陣列當中,這個陣列就是HashMap的主幹。HashMap陣列每一個元素的初始值都是Null。
HashMap可以以平均O(1)的時間複雜度去獲取集合中某個元素是否存在。
1.7
陣列 + 連結串列 實現的 每個資料單元是一個Entry
初始化
new 的時候預設建立一個長度為預設值16的Entry[]陣列,如果事先知道大概的資料量大小,可以透過建構函式傳入,以減少動態擴容的次數,這樣可以大大提高效能
插入元素 計算位置
為了提高效能,HashMap在根據物件計算hashcode()後,還進行了擾動計算來減少雜湊衝突
雜湊函式一共有九次擾動計算 四次位運算 五次異或運算 儘量做到任何一位的變化都能對最終得到的結果產生影響 降低雜湊衝突機率
// 獲取物件的 hashCode 值
h = k.hashCode();
// 擾動運算
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
得到擾動函式的返回值後 再計算索引
int index = hash & (capacity - 1);// hash是擾動函式的結果 capacity 是雜湊表的容量
發生衝突
當不同的key計算出的hash值相同時(發生衝突),就用連結串列的形式將Node結點(衝突的key及key對應的value)插在連結串列的頭部(頭插法)
擴容機制
預設負載因子是0.75。當 HashMap 中元素個數超過 0.75*capacity(capacity 表示雜湊表的容量)的時候,就會啟動擴容,每次擴容都會擴容為原來的兩倍大小。
擴容後 HashMap會將舊陣列中的資料重新分配到新陣列中,不需要重新計算雜湊值,但是需要根據新的容量重新計算索引位置。
計算完畢索引位置後,會進行資料轉移,從原連結串列尾部開始轉移,轉移到新連結串列的對應位置的時候採用頭插法,正因為這樣,元素的順序會被顛倒
轉移資料完畢後,插入剛才要插入的新元素。
1.8
JDK1.8的底層由 陣列+連結串列+紅黑樹 組成。每個資料單元都是一個Node結構,裡面有四個欄位分別是 key,value,hash和next 。其中hash就是將key的hashCode()的高16位異或低16位的值,next欄位就是發生hash衝突後,當前桶位中的Node與衝突Node連成一個連結串列用的欄位。
初始化
在首次呼叫put()方法的時候,底層建立長為16的陣列
插入
計算雜湊值
key不為空的話,就呼叫hashcode()方法計算,得到的值,再進行高16位和低16位的異或運算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 兩次擾動 一次位運算 一次異或
}
接著計算索引
index = (hash & (capacity - 1));
發生衝突時的解決方案
當發生衝突時,新節點會掛在連結串列的尾部,這樣就不會向頭插法一樣可能出現死迴圈問題。
ps: 死迴圈問題
在jdk1.7的擴容過程中,使用頭插法進行重建,如果某些情況下,出現多個執行緒同時對連結串列進行操作,可能會導致連結串列斷裂並出現環形結構
當連結串列長度過長的時候,遍歷的效率就會下降到O(n),為了解決這個問題, 當連結串列的長度超過8,並且陣列的容量大於64的時候,HashMap會將連結串列轉化為紅黑樹(紅黑樹是一種自平衡的二叉搜尋樹,增刪改查效率是O(logn)).
擴容機制
當容量超過0.75的時候,觸發擴容機制。 擴容為原來的兩倍。資料遷移相對於1.7有了最佳化。
新位置計算
無需重新計算雜湊值,只需要經過簡單的位運算就可以確定新的位置。 對於每個元素,將他的雜湊值和舊容量進行與運算,如果結果為0,保持舊索引不變,新索引就是舊索引,結果為1,新索引為舊索引加上舊容量值。
資料遷移 使用的是尾插法
① 如果舊陣列的這個位置是null , 就直接給新陣列中他要放到的位置也賦值為null,當然預設值也是null
② 如果舊陣列的這個位置只有一個Node,就直接放到新陣列他要放到的位置就行了
③ 如果舊陣列的這個位置是一個連結串列 ,就初始化一個低位連結串列頭尾指標,一個高位連結串列的頭尾指標,遍歷舊連結串列,雜湊值和舊容量進行與運算等於0的節點,放到低位連結串列中,不為0的放到高位連結串列中。低位連結串列直接放到新索引=舊索引的地方 高位連結串列放到新索引=舊索引+舊容量的位置
④ 如果舊陣列的這個位置是一個樹,就呼叫TreeNode.split() 方法,將紅黑樹拆成兩個樹或者兩個連結串列。分別對應低位和高位。低位的放到新索引=舊索引的地方 高位放到新索引=舊索引+舊容量的位置。如果拆分後樹節點的數量小於等於6,就把紅黑樹退化為連結串列
get(key)操作
根據傳入的key,計算雜湊值 計算索引位置。
static final int hash(Object key) {
int h;
// key.hashCode() 計算原始雜湊值
// 與自身右移16位的值進行異或,混合高低位雜湊碼,減少衝突
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
int index = (n - 1) & hash;
對應位置為空 返回null
對應位置不為空
只有頭結點 用equals()比較頭結點的鍵和目標鍵 一樣則返回頭結點的值 不一樣返回null
頭結點有next 判斷是不是樹形結構 e instanceof TreeNode
是樹 進入紅黑樹的查詢流程 找到返回值 找不到null
是連結串列 遍歷連結串列 逐個節點進行比較 找到返回值 找不到返回null
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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 檢查頭節點
if (first.hash == hash && // 檢查雜湊值是否相等
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
// 紅黑樹查詢
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 連結串列查詢
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
put()操作
檢查雜湊表是不是為空
空的話呼叫resize方法,以預設的16為容量初始化 計算雜湊值 計算索引 插入即可
不是空 計算出索引位置 獲取索引位置上的頭結點
頭結點為空 直接插入即可
頭結點不為空
是樹 呼叫putTreeVal()方法在紅黑樹中進行操作
鍵存在 更新對應值並返回舊值
鍵不存在 插入新節點 維護紅黑樹平衡
是連結串列 遍歷連結串列
鍵存在 更新對應值並返回舊值
鍵不存在 尾插法 記錄連結串列長度 判斷 是否需要樹化 需要則進行樹化
插入操作完成後 執行++size 更新元素數量 判斷是否超過閾值 是否進行擴容
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> e;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0) {
// 初始化 table
n = (tab = resize()).length;
}
// 計算索引位置
if ((e = tab[i = (n - 1) & hash]) == null) {
// 插入新節點
tab[i] = newNode(hash, key, value, null);
} else {
Node<K,V> existing;
K k;
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
// 頭節點的鍵相同,覆蓋值
existing = e;
} else if (e instanceof TreeNode) {
// 紅黑樹節點,呼叫樹的插入方法
existing = ((TreeNode<K,V>)e).putTreeVal(this, tab, hash, key, value);
} else {
// 連結串列節點,遍歷連結串列
for (int binCount = 0; ; ++binCount) {
if ((existing = e.next) == null) {
// 插入到連結串列尾部
e.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) {
// 連結串列長度超過閾值,樹化
treeifyBin(tab, hash);
}
break;
}
if (existing.hash == hash && ((k = existing.key) == key || (key != null && key.equals(k)))) {
// 找到相同的鍵
break;
}
e = existing;
}
}
if (existing != null) {
// 覆蓋值
V oldValue = existing.value;
if (!onlyIfAbsent || oldValue == null) {
existing.value = value;
}
afterNodeAccess(existing);
return oldValue;
}
}
++modCount;
if (++size > threshold) {
resize();
}
afterNodeInsertion(evict);
return null;
}
執行緒安全問題
為什麼執行緒不安全?
- 在多執行緒環境下,JDK1.7的HashMap進行擴容時容易發生死鏈現象,主要因為往連結串列裡面新新增元素的時候使用頭插法。
- 在多執行緒環境下,JDK1.8的HashMap擴容後進行資料遷移使用的時候尾插法,而且會將連結串列拆分成一個低位連結串列和一個高位連結串列,然後分別放在對應的位置上,這樣就能防止死鏈的產生。但是進行擴容時可能發生丟失資料現象。(多執行緒下容易發生資料覆蓋和size不正確)
執行緒不安全怎麼辦?
- 替換成Hashtable,Hashtable透過對整個表上鎖實現執行緒安全,但是效率比較低。
- 使用Collections.synchronizedMap(new HashMap<String, Integer>());底層其實使用裝飾器模式將HashMap的所有方法重寫,然後用synchronized()來修飾每個重寫後的方法,從而保證執行緒安全。
- 使用JUC包下的ConcurrentHashMap,它使用分段鎖來保證執行緒安全。
為什麼HashMap的長度總是2的n次方?
當 length 為 2 的 n 次方時,h & (length-1) 就相當於對 length 取模,而且速度比直接取模快得多,這是 HashMap 在速度上的一個最佳化。而且每次擴容時都是翻倍。
擴容後陣列的長度變成原來的2倍,還是2的冪次方。那麼資料進行遷移時,要麼在原來位置,要麼在原來位置+擴容長度。不需要進行再次雜湊計算,提高效率。
資料會更加均勻
為什麼HashMap中String、Integer這樣的包裝類適合做為Key?
- 都是final型別,即不可變性,保證key的不可更改性,不會存在獲取hash值不同的情況。
- 內部已重寫了equals()、hashCode()等方法,遵守了HashMap內部的規範,不容易出現Hash值計算錯誤的情況。