1、HashMap 概述
在前面的文章中,我們以及介紹了 List
大家族的相關知識:
在接下來的文章,則主要為大家介紹一下Java
集合家庭中另一小分隊 Map
,我們先來看看 Map
家庭的整體架構:
在這篇文章中,我們主要介紹一下HashMap:
HashMap 的依賴關係:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
複製程式碼
- 1、AbstractMap:表明它是一個雜湊表,基於Key-Value 的儲存方式
- 2、Cloneable:支援拷貝功能
- 3、Seriablizable:重寫了write/readObject,支援序列化
從依賴關係上面來看,HashMap
並沒有 List
集合 那麼的複雜,主要是因為在迭代上面,HashMap 區別 key-value 進行迭代,而他們的迭代又依賴與keySet-valueSet 進行,因此,雖然依賴關係上面HashMap 看似簡單,但是內部的依賴關係更為複雜。
2、HashMap 成員變數
預設 桶(陣列) 容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
連結串列轉樹 大小
static final int TREEIFY_THRESHOLD = 8;
樹轉連結串列 大小
static final int UNTREEIFY_THRESHOLD = 6;
最小轉紅黑樹容量
static final int MIN_TREEIFY_CAPACITY = 64;
儲存資料節點
static class Node<K,V> implements Map.Entry<K,V>
節點陣列
transient Node<K,V>[] table;
資料容量
transient int size;
操作次數
transient int modCount;
擴容大小
int threshold;
複製程式碼
對比於JDK8之前的HashMap ,成員變數主要的區別在於多了紅黑樹的相關變數,用於標示我們在什麼時候進行 list
-> Tree
的轉換。
附上Jdk8 中HashMap 的資料結構展示圖:
3、HashMap 建構函式
HashMap 提供了四種建構函式:
- HashMap():預設建構函式,引數均使用預設大小
- HashMap(int initialCapacity):指定初始陣列大小
- HashMap(int initialCapacity, float loadFactor):指定初始陣列大小,載入因子
- HashMap(Map<? extends K, ? extends V> m):建立新的HashMap,並將
m
中內容存入HashMap中
4、HashMap Put 過程
接下來我們主要講解一下,HashMap 在JDK8中的新增資料過程(引用):
4.1、put(K key, V value)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製程式碼
上述方法是我們在開發過程中最常使用到的方法,但是卻很少人知道,其實內部真正呼叫的方法是這個putVal(hash(key), key, value, false, true)
方法。這裡稍微介紹一下這幾個引數:
- hash 值,用於確定儲存位置
- key:存入鍵值
- value:存入資料
- onlyIfAbsent:是否覆蓋原本資料,如果為true 則不覆蓋
- onlyIfAbsent:table 是否處於建立模式
4.1.1 hash(Object key)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
這裡的Hash演算法本質上就是三步:取key的hashCode值、高位運算、取模運算。 這裡引用一張圖,易於大家瞭解相關機制
這裡可能會比較疑惑,為什麼需要對自身的hashCode 進行運算,這麼做可以在陣列table 比較小的時候,讓高位bit 也能參與到hash 運算中,同時不會又太大的開銷。4.2、putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
由於原始碼篇幅過長,這裡我進行分開講解,同學們可以對照原始碼進行閱讀
4.2.1 宣告成員變數(第一步)
Node<K,V>[] tab; Node<K,V> p; int n, i;
複製程式碼
第一部分主要縣宣告幾個需要使用到的成員變數:
- tab:對應table 用於儲存資料
- p:我們需要儲存的資料,將轉化為該物件
- n:陣列(table) 長度
- i:陣列下標
4.2.2 Table 為 null,初始化Table(第二步)
table 為空說明當前操作為第一次操作,通過上面建構函式的閱讀,我們可以瞭解到,我們並沒有對table 進行初始化,因此在第一次put 操作的時候,我們需要先將table 進行初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
複製程式碼
從上述程式碼可以看到,table 的初始化和擴容,都依賴於 resize()
方法,在後面我們會對該方法進行詳細分析。
4.2.3 Hash碰撞確認下標(True)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
複製程式碼
在上一步我們以及確認當前table不為空,然後我們需要計算我們物件需要儲存的下標了。
如果該下標中並沒有資料,我們只需建立一個新的節點,然後將其存入 tab[]
即可。
4.2.4 Hash碰撞確認下標(False)
與上述過程相反,Hash碰撞結果後,發現該下標有儲存元素,將其儲存到變數 p = tab[i = (n - 1) & hash]
,現在 p
儲存的是目標陣列下標中的元素。如上圖所示(引用):
4.2.4.1 key 值相同覆蓋
在獲取到 p
後,我們首先判斷它的 key 是否與我們這次插入的key 相同,如果相同,我們將其引用傳遞給 e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
複製程式碼
4.2.4.2 紅黑樹節點處理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
複製程式碼
由於在JDK 8後,會對過長的連結串列進行處理,即 連結串列 -> 紅黑樹,因此對應的節點也會進行相關的處理。紅黑樹的節點則為TreeNode,因此在獲取到p
後,如果他跟首位元素不匹配,那麼他就有可能為紅黑樹的內容。所以進行putTreeVal(this, tab, hash, key, value)
操作。該操作的原始碼,將會在後續進行細述。
4.2.4.3 連結串列節點處理
else {
//for 迴圈遍歷連結串列,binCount 用於記錄長度,如果過長則進行樹的轉化
for (int binCount = 0; ; ++binCount) {
// 如果發現p.next 為空,說明下一個節點為插入節點
if ((e = p.next) == null) {
//建立一個新的節點
p.next = newNode(hash, key, value, null);
//判斷是否需要轉樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//結束遍歷
break;
}
//如果插入的key 相同,退出遍歷
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//替換 p
p = e;
}
}
複製程式碼
連結串列遍歷處理,整個過程就是,遍歷所有節點,當發現如果存在key 與插入的key 相同,那麼退出遍歷,否則在最後插入新的節點。判斷連結串列長度是否大於8,大於8的話把連結串列轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行連結串列的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
4.2.4.3 判斷是否覆蓋
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
複製程式碼
如果 e
不為空,說明在校驗 key 的hash 值,發現存在相同的 key,那麼將會在這裡進行判斷是否對其進行覆蓋。
4.2.5 容量判斷
if (++size > threshold)
resize();
複製程式碼
如果 size
大於 threshold
則進行擴容處理。
5、Resize()擴容
在上面的建構函式,和 put
過程都有呼叫過resize()
方法,那麼,我們接下來將會分析一下 resize()
過程。由於JDK 8
引入了紅黑樹,我們先從JDK 7
開始閱讀 resize()
過程。下面部分內容參考:傳送門
5.1 JDK 7 resize()
在 JDK 7
中,擴容主要分為了兩個步驟:
- 容器擴充套件
- 內容拷貝
5.1.1 容器擴充套件
1 void resize(int newCapacity) { //傳入新的容量
2 Entry[] oldTable = table; //引用擴容前的Entry陣列
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的陣列大小如果已經達到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry陣列
10 transfer(newTable); //!!將資料轉移到新的Entry陣列裡
11 table = newTable; //HashMap的table屬性引用新的Entry陣列
12 threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }
複製程式碼
5.1.2 內容拷貝
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了舊的Entry陣列
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍歷舊的Entry陣列
5 Entry<K,V> e = src[j]; //取得舊Entry陣列的每個元素
6 if (e != null) {
7 src[j] = null;//釋放舊Entry陣列的物件引用(for迴圈後,舊的Entry陣列不再引用任何物件)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在陣列中的位置
11 e.next = newTable[i]; //標記[1]
12 newTable[i] = e; //將元素放在陣列上
13 e = next; //訪問下一個Entry鏈上的元素
14 } while (e != null);
15 }
16 }
17 }
複製程式碼
5.1.3 擴容過程展示(引用)
下面舉個例子說明下擴容過程。假設了我們的hash演算法就是簡單的用key mod 一下表的大小(也就是陣列的長度)。其中的雜湊桶陣列table的size=2, 所以key = 3、7、5,put順序依次為 5、7、3。在mod 2以後都衝突在table[1]這裡了。這裡假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是雜湊桶陣列 resize成4,然後所有的Node重新rehash的過程。
5.2 JDK 8 resize()
由於擴容部分程式碼篇幅比較長,童鞋們可以對比著部落格與原始碼進行閱讀。
與上述流程相似,JDK 8
中擴容過程主要分成兩個部分:
- 容器擴充套件
- 內容拷貝
5.2.1 容器擴充套件
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);
}
// 第二步,建立新陣列
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
複製程式碼
從上面的流程分析,我們可以看到在 JDK 8 HashMap
中,開始使用位運算進行擴容計算,主要優點將會在後續資料拷貝中具體表現。
5.2.2 內容拷貝
在上述容器擴容結束後,如果發現 oldTab
不為空,那麼接下來將會進行內容拷貝:
if (oldTab != null) {
//對舊陣列進行遍歷
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//
if ((e = oldTab[j]) != null) {
//將舊陣列中的內容清空
oldTab[j] = null;
//如果 e 沒有後續內容,只處理當前值即可
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;
//高位 與運算,確定索引為原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//高位與運算,確認索引為 願索引+ oldCap
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;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
複製程式碼
內容拷貝,在JDK 8
中優化,主要是:
- 通過高位與運算確認儲存地址
- 連結串列不會出現導致,JDK 8 通過建立新連結串列方式進行轉移
我們來看一下 JDK 8
是如何通過高位與運算確認儲存位置的:
6、小結
HashMap中,如果key經過hash演算法得出的陣列索引位置全部不相同,即Hash演算法非常好,那樣的話,getKey方法的時間複雜度就是O(1),如果Hash演算法技術的結果碰撞非常多,假如Hash算極其差,所有的Hash演算法結果得出的索引位置一樣,那樣所有的鍵值對都集中到一個桶中,或者在一個連結串列中,或者在一個紅黑樹中,時間複雜度分別為O(n)和O(lgn)。
(1) 擴容是一個特別耗效能的操作,所以當程式設計師在使用HashMap的時候,估算map的大小,初始化的時候給一個大致的數值,避免map進行頻繁的擴容。
(2) 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。
(3) HashMap是執行緒不安全的,不要在併發的環境中同時操作HashMap,建議使用ConcurrentHashMap。
(4) JDK1.8引入紅黑樹大程度優化了HashMap的效能。
(5) 還沒升級JDK1.8的,現在開始升級吧。HashMap的效能提升僅僅是JDK1.8的冰山一角。
參考
- https://tech.meituan.com/java-hashmap.html
- https://www.2cto.com/kf/201505/401433.html