作者:小傅哥 部落格:https://bugstack.cn
沉澱、分享、成長,讓自己和他人都能有所收穫!?
一、前言
在上一章節我們講解並用資料驗證了,HashMap中的,雜湊表的實現
、擾動函式
、負載因子
以及擴容拆分
等核心知識點以及相應的作用。
除了以上這些知識點外,HashMap還有基本的資料功能;儲存
、刪除
、獲取
、遍歷
,在這些功能中經常會聽到連結串列、紅黑樹、之間轉換等功能。而紅黑樹是在jdk1.8引入到HashMap中解決連結串列過長問題的,簡單說當連結串列長度>=8
時,將連結串列轉換位紅黑樹(當然這裡還有一個擴容的知識點,不一定都會樹化[MIN_TREEIFY_CAPACITY])。
那麼本章節會進行講解以下知識點;
- 資料插入流程和原始碼分析
- 連結串列樹化以及樹轉連結串列
- 遍歷過程中的無序Set的核心知識
?注意: 建議閱讀上一篇後,再閱讀本篇文章《HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習》
二、HashMap原始碼分析
1. 插入
1.1 疑問點&考題
通過上一章節的學習:《HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習》
大家對於一個雜湊表資料結構的HashMap往裡面插入資料時,基本已經有了一個印象。簡單來說就是通過你的Key值取得雜湊再計算下標,之後把相應的資料存放到裡面。
但再這個過程中會遇到一些問題,比如;
- 如果出現雜湊值計算的下標碰撞了怎麼辦?
- 如果碰撞了是擴容陣列還是把值存成連結串列結構,讓一個節點有多個值存放呢?
- 如果存放的資料的連結串列過長,就失去了雜湊表的效能了,怎麼辦呢?
- 如果想解決連結串列過長,什麼時候使用樹結構呢,使用哪種樹呢?
這些疑問點都會在後面的內容中逐步講解,也可以自己思考一下,如果是你來設計,你會怎麼做。
1.2 插入流程和原始碼分析
HashMap插入資料流程圖
visio原版流程圖,可以通過關注公眾號:bugstack蟲洞棧,進行下載
以上就是HashMap中一個資料插入的整體流程,包括了;計算下標、何時擴容、何時連結串列轉紅黑樹等,具體如下;
首先進行雜湊值的擾動,獲取一個新的雜湊值。
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
判斷tab是否位空或者長度為0,如果是則進行擴容操作。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
根據雜湊值計算下標,如果對應小標正好沒有存放資料,則直接插入即可否則需要覆蓋。
tab[i = (n - 1) & hash])
判斷tab[i]是否為樹節點,否則向連結串列中插入資料,是則向樹中插入節點。
如果連結串列中插入節點的時候,連結串列長度大於等於8,則需要把連結串列轉換為紅黑樹。
treeifyBin(tab, hash);
最後所有元素處理完成後,判斷是否超過閾值;
threshold
,超過則擴容。treeifyBin
,是一個連結串列轉樹的方法,但不是所有的連結串列長度為8後都會轉成樹,還需要判斷存放key值的陣列桶長度是否小於64MIN_TREEIFY_CAPACITY
。如果小於則需要擴容,擴容後連結串列上的資料會被拆分雜湊的相應的桶節點上,也就把連結串列長度縮短了。
JDK1.8 HashMap的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;
// 初始化桶陣列 table,table 被延遲到插入新資料時再進行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果桶中不包含鍵值對節點引用,則將新鍵值對節點的引用存入桶中即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果鍵的值以及節點 hash 等於連結串列中的第一個鍵值對節點時,則將 e 指向該鍵值對
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶中的引用型別為 TreeNode,則呼叫紅黑樹的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 對連結串列進行遍歷,並統計連結串列長度
for (int binCount = 0; ; ++binCount) {
// 連結串列中不包含要插入的鍵值對節點時,則將該節點接在連結串列的最後
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果連結串列長度大於或等於樹化閾值,則進行樹化操作
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 條件為 true,表示當前連結串列包含要插入的鍵值對,終止遍歷
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 判斷要插入的鍵值對是否存在 HashMap 中
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 鍵值對數量超過閾值時,則進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1.3 擴容機制
HashMap是基於陣列+連結串列和紅黑樹實現的,但用於存放key值得的陣列桶的長度是固定的,由初始化決定。
那麼,隨著資料的插入數量增加以及負載因子的作用下,就需要擴容來存放更多的資料。而擴容中有一個非常重要的點,就是jdk1.8中的優化操作,可以不需要再重新計算每一個元素的雜湊值,這在上一章節中已經講到,可以閱讀系列專題文章,機制如下圖;
裡我們主要看下擴容的程式碼(註釋部分);
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// Cap 是 capacity 的縮寫,容量。如果容量不為空,則說明已經初始化。
if (oldCap > 0) {
// 如果容量達到最大1 << 30則不再擴容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 按舊容量和閥值的2倍計算新容量和閥值
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
// initial capacity was placed in threshold 翻譯過來的意思,如下;
// 初始化時,將 threshold 的值賦值給 newCap,
// HashMap 使用 threshold 變數暫時儲存 initialCapacity 引數的值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 這一部分也是,原始碼中也有相應的英文註釋
// 呼叫無參構造方法時,陣列桶陣列容量為預設容量 1 << 4; aka 16
// 閥值;是預設容量與負載因子的乘積,0.75
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr為0,則使用閥值公式計算容量
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 初始化陣列桶,用於存放key
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 如果舊陣列桶,oldCap有值,則遍歷將鍵值對映到新陣列桶中
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;
else if (e instanceof TreeNode)
// 這裡split,是紅黑樹拆分操作。在重新對映時操作的。
((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;
// 這裡是連結串列,如果當前是按照連結串列存放的,則將連結串列節點按原順序進行分組{這裡有專門的文章介紹,如何不需要重新計算雜湊值進行拆分《HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習》}
do {
next = e.next;
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);
// 將分組後的連結串列對映到桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
以上的程式碼稍微有些長,但是整體的邏輯還是蠻清晰的,主要包括;
- 擴容時計算出新的newCap、newThr,這是兩個單詞的縮寫,一個是Capacity ,另一個是閥Threshold
- newCap用於創新的陣列桶
new Node[newCap];
- 隨著擴容後,原來那些因為雜湊碰撞,存放成連結串列和紅黑樹的元素,都需要進行拆分存放到新的位置中。
1.4 連結串列樹化
HashMap這種雜湊表的資料結構,最大的效能在於可以O(1)時間複雜度定位到元素,但因為雜湊碰撞不得已在一個下標裡存放多組資料,那麼jdk1.8之前的設計只是採用連結串列的方式進行存放,如果需要從連結串列中定位到資料時間複雜度就是O(n),連結串列越長效能越差。因為在jdk1.8中把過長的連結串列也就是8個,優化為自平衡的紅黑樹結構,以此讓定位元素的時間複雜度優化近似於O(logn),這樣來提升元素查詢的效率。但也不是完全拋棄連結串列,因為在元素相對不多的情況下,連結串列的插入速度更快,所以綜合考慮下設定閾值為8才進行紅黑樹轉換操作。
連結串列轉紅黑樹,如下圖;
以上就是一組連結串列轉換為紅黑樹的情況,元素包括;40、51、62、73、84、95、150、161 這些是經過實際驗證可分配到Idx:12的節點
通過這張圖,基本可以有一個連結串列
換行到紅黑樹
的印象,接下來閱讀下對應的原始碼。
連結串列樹化原始碼
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 這塊就是我們上面提到的,不一定樹化還可能只是擴容。主要桶陣列容量是否小於64 MIN_TREEIFY_CAPACITY
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 又是單詞縮寫;hd = head (頭部),tl = tile (結尾)
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)
// 轉紅黑樹操作,這裡需要迴圈比較,染色、旋轉。關於紅黑樹,在下一章節詳細講解
hd.treeify(tab);
}
}
這一部分連結串列樹化的操作並不複雜,複雜點在於下一層的紅黑樹轉換上,這部分知識點會在後續章節中專門介紹;
以上原始碼主要包括的知識點如下;
- 連結串列樹化的條件有兩點;連結串列長度大於等於8、桶容量大於64,否則只是擴容,不會樹化。
- 連結串列樹化的過程中是先由連結串列轉換為樹節點,此時的樹可能不是一顆平衡樹。同時在樹轉換過程中會記錄連結串列的順序,
tl.next = p
,這主要方便後續樹轉連結串列和拆分更方便。 - 連結串列轉換成樹完成後,在進行紅黑樹的轉換。先簡單介紹下,紅黑樹的轉換需要染色和旋轉,以及比對大小。在比較元素的大小中,有一個比較有意思的方法,
tieBreakOrder
加時賽,這主要是因為HashMap沒有像TreeMap那樣本身就有Comparator的實現。
1.5 紅黑樹轉鏈
在連結串列轉紅黑樹中我們重點介紹了一句,在轉換樹的過程中,記錄了原有連結串列的順序。
那麼,這就簡單了,紅黑樹轉連結串列時候,直接把TreeNode轉換為Node即可,原始碼如下;
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
// 遍歷TreeNode
for (Node<K,V> q = this; q != null; q = q.next) {
// TreeNode替換Node
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
// 替換方法
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
因為記錄了連結串列關係,所以替換過程很容易。所以好的資料結構可以讓操作變得更加容易。
2. 查詢
上圖就是HashMap查詢的一個流程圖,還是比較簡單的,同時也是高效的。
接下來我們在結合程式碼,來分析這段流程,如下;
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 &&
// 計算下標,雜湊值與陣列長度-1
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// TreeNode 節點直接呼叫紅黑樹的查詢方法,時間複雜度O(logn)
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;
}
以上查詢的程式碼還是比較簡單的,主要包括以下知識點;
- 擾動函式的使用,獲取新的雜湊值,這在上一章節已經講過
- 下標的計算,同樣也介紹過
tab[(n - 1) & hash])
- 確定了桶陣列下標位置,接下來就是對紅黑樹和連結串列進行查詢和遍歷操作了
3. 刪除
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 定位桶陣列中的下標位置,index = (n - 1) & hash
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果鍵的值與連結串列第一個節點相等,則將 node 指向該節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 樹節點,呼叫紅黑樹的查詢方法,定位節點。
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 遍歷連結串列,找到待刪除節點
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 刪除節點,以及紅黑樹需要修復,因為刪除後會破壞平衡性。連結串列的刪除更加簡單。
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
- 刪除的操作也比較簡單,這裡面都沒有太多的複雜的邏輯。
- 另外紅黑樹的操作因為被包裝了,只看使用上也是很容易。
4. 遍歷
4.1 問題點
HashMap中的遍歷也是非常常用的API方法,包括;
KeySet
for (String key : map.keySet()) {
System.out.print(key + " ");
}
EntrySet
for (HashMap.Entry entry : map.entrySet()) {
System.out.print(entry + " ");
}
從方法上以及日常使用都知道,KeySet是遍歷是無序的,但每次使用不同方式遍歷包括keys.iterator()
,它們遍歷的結果是固定的。
那麼從實現的角度來看,這些種遍歷都是從雜湊表中的連結串列和紅黑樹獲取集合值,那麼他們有一個什麼固定的規律嗎?
4.2 用程式碼測試
測試的場景和前提;
- 這裡我們要設定一個既有紅黑樹又有連結串列結構的資料場景
- 為了可以有這樣的資料結構,我們最好把HashMap的初始長度設定為64,避免在連結串列超過8位後擴容,而是直接讓其轉換為紅黑樹。
- 找到18個元素,分別放在不同節點(這些資料通過程式計算得來);
- 桶陣列02節點:24、46、68
- 桶陣列07節點:29
- 桶陣列12節點:150、172、194、271、293、370、392、491、590
程式碼測試
@Test
public void test_Iterator() {
Map<String, String> map = new HashMap<String, String>(64);
map.put("24", "Idx:2");
map.put("46", "Idx:2");
map.put("68", "Idx:2");
map.put("29", "Idx:7");
map.put("150", "Idx:12");
map.put("172", "Idx:12");
map.put("194", "Idx:12");
map.put("271", "Idx:12");
System.out.println("排序01:");
for (String key : map.keySet()) {
System.out.print(key + " ");
}
map.put("293", "Idx:12");
map.put("370", "Idx:12");
map.put("392", "Idx:12");
map.put("491", "Idx:12");
map.put("590", "Idx:12");
System.out.println("\n\n排序02:");
for (String key : map.keySet()) {
System.out.print(key + " ");
}
map.remove("293");
map.remove("370");
map.remove("392");
map.remove("491");
map.remove("590");
System.out.println("\n\n排序03:");
for (String key : map.keySet()) {
System.out.print(key + " ");
}
}
這段程式碼分別測試了三種場景,如下;
- 新增元素,在HashMap還是隻連結串列結構時,輸出測試結果01
- 新增元素,在HashMap轉換為紅黑樹時候,輸出測試結果02
- 刪除元素,在HashMap轉換為連結串列結構時,輸出測試結果03
4.3 測試結果分析
排序01:
24 46 68 29 150 172 194 271
排序02:
24 46 68 29 271 150 172 194 293 370 392 491 590
排序03:
24 46 68 29 172 271 150 194
Process finished with exit code 0
從map.keySet()測試結果可以看到,如下資訊;
- 01情況下,排序定位雜湊值下標和連結串列資訊
- 02情況下,因為連結串列轉換為紅黑樹,樹根會移動到陣列頭部。
moveRootToFront()方法
- 03情況下,因為刪除了部分元素,紅黑樹退化成連結串列。
三、總結
- 這一篇API原始碼以及邏輯與上一篇資料結構中擾動函式、負載因子、雜湊表實現等,內容的結合,算是把HashMap基本常用技術點,梳理完成了。但知識絕不止於此,這裡還有紅黑樹的相關技術內容,後續會進行詳細。
- 除了HashMap以外還有TreeMap、ConcurrentHashMap等,每一個核心類都有一些相關的核心知識點,每一個都非常值得深入研究。這個燒腦的過程,是學習獲得知識的最佳方式。
- 可能關於HashMap還有一些疏漏的點,也希望閱讀的小夥伴可以提出更多的問題,互相學習,共同進步,本文就到這裡,感謝您的閱讀!