概述
HashMap 是 Map 接口下一個線程不安全的,基於哈希表的實現類。由於他解決哈希衝突的方式是分離鏈表法,也就是拉鍊法,因此他的數據結構是數組+鏈表,在 JDK8 以後,當哈希衝突嚴重時,HashMap 的鏈表會在一定條件下轉爲紅黑樹以優化查詢性能,因此在 JDK8 以後,他的數據結構是數組+鏈表+紅黑樹。
對於 HashMap ,作爲集合容器,我們需要關注其數據的存儲結構,迭代方式,能否存放空值;作爲使用了數組作爲底層結構的集合,我們還需要關注其擴容的實現;同時,針對哈希表的特性,我們還需要關注它如何通過哈希算法取模快速定位下標。
這是關於 java 集合類源碼的第六篇文章。往期文章:
一、HashMap 的數據結構
在 JDK8 之前,HashMap 的數據結構是數組+鏈表。在 JDK8 以後是數組 + 鏈表 + 紅黑樹。
在 HashMap 中,每一個 value 都被存儲在一個 Node 或 TreeNode 實例中,容器中有一個 Node[] table
數組成員變量,數組中的每一格稱爲一個“桶”。當添加元素時,根據元素的 key 通過哈希值計算得到對應下標,將 Node 類的形式存入“桶”中。如果 table 容量不足時,就會發生擴容,同時對容器內部的元素進行重哈希。
當發生哈希衝突,也就是不同元素計算得到了相同的下標時,會將節點接到“桶”的中的第一個元素後,後續操作亦同,最後就會形成鏈表。
在 JDK8 以後,由於考慮到哈希衝突嚴重時,“桶”中的鏈表會影響查詢效率,因此在一定條件下,鏈表元素多到一定程度,Node 就會轉爲 TreeNode,也就是把鏈表轉爲紅黑樹。
對於紅黑樹,可以簡單理解爲不要求嚴格平衡的平衡二叉樹,他保證了查找效率的同時,又保持了較低的的旋轉次數。通過這種數據結構,保證了哈希衝突嚴重的情況下的查找效率。
二、HashMap的成員變量
由於 HashMap 本身繼承了 AbstractMap 抽象類的成員變量,再加上自身的成員變量,以及由於擴容時的重哈希需要的參數,因此 HashMap 的成員變量比較複雜。按照來源以及用途,我們將他的成員變量分爲三類:
1.來自父類的變量
/**
* 1.存放key的Set集合視圖,通過 keySet()方法獲取
*/
transient Set<K> keySet;
/**
* 1.存放value的Collection集合視圖,通過values()方法獲取
*/
transient Collection<V> values;
2.自己的變量
/**
* 1.結構更改次數。用於實現併發修改情況下的fast-fail機制,同AbstractList
*/
transient int modCount;
/**
* 2.集合中的元素個數
*/
transient int size;
/**
* 3.存放集合中鍵值對對象Entry的Set集合視圖,通過entrySet()獲取
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 4.集合中的桶數組。桶即是當鏈表或者紅黑樹的容器
*/
transient Node<K,V>[] table;
3.擴容相關的變量和常量
/**
* 1.默認初始容量。必須爲2的冪,默認爲16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 2.最大容量。不能超過1073741824,即Integer.MAX_VALUE的一半
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 3.擴容閾值。負載係數與容量的乘積,當元素個數超過該值則擴容。默認爲0
*/
int threshold;
/**
* 4.負載係數。當容器內元素數量/容器容量大於等於該值時發生擴容
*/
final float loadFactor;
/**
* 5.默認負載係數。未在構造函數中指定則默認爲0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 6.容器中桶的最小樹化閾值。當容器中元素個數大於等於該值時,桶纔會發生樹化。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 7.桶的樹化閾值。當容器元素個數大於等於MIN_TREEIFY_CAPACITY,並且桶中元素個數大於等於該值以後,將鏈表轉爲紅黑樹
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 8.桶的鏈化閾值。當桶中元素個數,或者說鏈表長度小於等於該值以後,將紅黑樹轉爲鏈表
*/
static final int UNTREEIFY_THRESHOLD = 6;
三、構造方法
HashMap 一共提供了四個構造方法:
1.指定容量和負載係數
public HashMap(int initialCapacity, float loadFactor) {
// 指定初始容量是否小於0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 若指定初始容量大於最大容量,則初始容量爲最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 初始容量是否爲小於0或未初始化
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 指定初始容量
this.loadFactor = loadFactor;
// 下一擴容大小爲loadFactor或最接近的2的冪
this.threshold = tableSizeFor(initialCapacity);
}
這裏涉及到一個取值的方法 tableSizeFor()
:
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;
}
這個方法的用於得到指定容量最接近的2的冪,比如傳入1會得到2,傳入7會得到8。
2.只指定容量
public HashMap(int initialCapacity) {
// 使用默認負載係數0.75
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3.不指定任何係數
public HashMap() {
// 下一擴容大小爲默認大小16,其負載係數默認爲0.75,初始容量默認爲16
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
4.根據指定Map集合構建
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
這裏涉及到一個將合併集合的方法 putMapEntries()
,putAll()
方法也是基於這個方法實現的。由於添加還涉及到擴容以及其他方法,這裏暫不介紹,等下面再詳細的瞭解。
四、HashMap的內部類
基於前文java集合源碼分析(五):Map與AbstractMap中第五部分 “AbstractMap 的視圖”裏對 AbstractMap 的分析,我們知道,HashMap 作爲繼承了 AbstractMap 的子類,因此它內部會擁有三個集合視圖
- 存放 key 的 Set 集合:
Set<K> keySet
- 存放 value 的 Collection 集合:
Collection<V> valuse
- 存放 Entry 對象的 Set 集合:
Set<Map.Entry<K,V>> entrySet
同時還需要一個實現了 Entry 接口的內部類作爲 entrySet 的元素使用。
因此 HashMap 作爲 AbstractMap 的子類,他最少需要 3種集合視圖 + 3種結合視圖的迭代器 + Entry 實現類
7種內部類。
實際上,由於 JDK8 以後紅黑樹和並行迭代的需求,他還需要新增 1種Entry紅黑樹節點實現 + 3種視圖容器對應的並行迭代器
2種內部類。
由於針對迭代器和並行迭代器又各提取了一個抽象類,所以 HashMap 中一共會有 :
3種視圖容器 + 1種迭代器抽象類 + 3種視圖容器的迭代器 + 1種並行迭代器抽象類 + 3種視圖容器對應的並行迭代器 + 1種Entry實現類 + 1種Entry的紅黑樹節點實現類
總計 13 種內部類
1. Node / TreeNode
Node 是 HashMap 中的節點類,在 JDK8 之前對應的是 Entry 類。他是 Map 接口中 Entry 的實現類。
在 HashMap 中數組的每一個位置都是一個“桶”,而“桶”中存放的就是帶有數據的節點對象 Node。當哈希衝突時,多個 Node 會在同一個“桶”中形成鏈表。
static class Node<K,V> implements Map.Entry<K,V> {
// 節點的hashcode
final int hash;
// key
final K key;
// value
V value;
// 下一節點
Node<K,V> next;
}
在 JDK8,當容器中元素數量大於等於64,並且桶中節點大於等於8的時候,會在擴容前觸發紅黑樹化,Node 類會被轉變爲 TreeNode ,鏈表會變成:
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;
}
值得一提的是,TreeNode 繼承了 LinkedHashMap.Entry 類,但是 LinkedHashMap.Entry 類又繼承了 HashMap.Node,因此,實際上 TreeNode 也是 Node 類的子類,這是 Node 轉變爲 TreeNode 的結構基礎。
另外,TreeNode 儘管是樹,但是他仍然通過 prev 維持了隱式的鏈表結構,理論上每一個節點都可以獲取他上一次插入的節點,這仍然可以理解爲單向鏈表。
2. KeySet / KeyIterator
Set<K> keySet
是在 AbstractMap 中已經定義好了變量,它是一個存放 key 的集合,HashMap 的哈希算法保證了 key 的唯一性,這恰好也符合 Set 集合的特徵。在 HashMap 中,爲其提供了實現類 KeySet 。
KeySet 繼承了 AbstractSet 抽象類,並且直接使用 HashMap 中的方法去實現了抽象類中的大多數抽象方法。值得一提的是,他實現的 iterator()
返回的也是 HashMap 的一個內部類 KeyIterator。
3. Values / ValueIterator
和 KeySet 類一樣,Values 也是給 AbstractMap 中的 Collection<V> values
提供的實現類,他繼承了 AbstractCollection 抽象類,並且使用 HashMap 的方法實現了大部分抽象方法。
同樣的,它的iterator()
返回的也是 HashMap 的一個內部類 ValueIterator。
4. EntrySet / EntryIterator
AbstractMap 中有一個留給子類去實現的核心抽象方法 entrySet()
,而 EntrySet 就是爲了實現該方法而創建的類。它繼承了 AbstractSet<Map.Entry<K,V>>
,表示的是容器中的一對鍵值對對象。在註釋中,作者將其稱爲視圖。
通過 EntrySet 類,我們就可以像 Collection 的 toArray 一樣,將 Map 以 Set 集合視圖的形式表現出來。
同樣的,作爲一個 AbstractSet 的實現類,HashMap 也專門爲其實現了一個內部迭代器類 EntryIterator 。EntrySet 的iterator()
方法返回的就是該類。
5. HashIterator
HashIterator 類是一個用於迭代 Node 節點的迭代器抽象類,他也是上述 KeyIterator,ValueIterator,EntryIterator 三種內部迭代器類的父類。
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
public final boolean hasNext() {}
final Node<K,V> nextNode() {}
public final void remove() {}
}
雖然它叫 HashIterator ,但是它並沒有實現 Iterator
接口,而是讓他的子類自己去實現接口。並且只值提供迭代和刪除兩種功能的三個方法。
此外,他的子類 KeyIterator,ValueIterator,EntryIterator 也非常樸素,只在它的基礎上重寫包裝了一下 nextNode()
作爲自己的 next()
方法,這裏不妨也看成適配器的一種。
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
6. Spliterator
跟 Iterator 一樣,HashMap 也提供了 HashMapSpliterator,KeySpliterator,ValueSpliterator,EntrySpliterator 四種並行迭代器。後面三者都是 HashMapSpliterator 的子類。
五、HashMap 獲取插入下標
HashMap 是基於哈希表實現的,因此添加元素和擴容時通過哈希算法獲取 key 對應的數組下標是整整個類進行添加操作的基礎。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
1. 計算哈希值
這裏涉及到兩個方法,一個是計算哈希值的 hash()
方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這是來自知乎大佬一個非常詳細的回答:JDK 源碼中 HashMap 的 hash 方法原理是什麼? - 知乎;
這裏我簡單的概括一下:
該方法實際上是一個“擾動函數”,作用是對Object.hashCode()
獲取到的 hash 值進行高低位混淆。
我們可以看到,符號右移16位後,新二進制數的前16位都爲0,後16位就是原始 hashcode 的高16位。
將原始 hashcode 與 位運算得到的二進制數再進行異或運算以後,我們就得到的 hash 前16全部都爲1,後16位則同時混淆了高16位和低16位的特徵,進一步增加了隨機性。
現在我們得到了 key 的 hashcode,這是計算下標的基礎。
2. 計算下標
接下來進入putVal()
方法,實際上包括 putAll()
在內,所有添加/替換元素的方法,都依賴於 putVal()
實現。putVal()
需要傳入 key 的 hash 作爲參數,它將根據 hash 值和 key 進行進一步的計算,獲取實際 value 要插入的下標。
我們先忽略計算下標以外的其他方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
... ...
// n 即爲當前數組長度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根據 n 與 hash 計算插入下標
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
... ...
}
也就是說,當通過擾動函數hash()
獲取到了已經混淆高低位的 key 的 hashcode 以後, 會將其與數組長度-1進行與運算:(n - 1) & hash
。
以默認是容量16爲例,它轉換爲二進制數是 10000,而 (16-1)轉換爲二進制數就是 1111,補零以後它與 hash()
計算得到的 hashcode 進行與運算過程如下:
在這個過程,之前留下的兩個問題就得到了解答:
爲什麼容量需要是2的冪?
我們可以看到,按位的與運算只有 1&1 = 1,由於數組長度轉爲二進制只有4位,所有高於4位的位數都爲0,因此運算結果高於4位的位置也都會是0,這裏巧妙的實現了取模的效果,數組長度起到了低位掩碼的作用。這也整是爲什麼 HashMap 的容量要是2的冪的原因。
爲什麼要hash()
要混淆高低位?
再回頭看看 hash()
函數,他混合了原始 hashcode 的高位和低位的特徵,我們說他增加了隨機性,在點要怎麼理解呢?
我們舉個例子:
key | hashCode | 不混淆取後四位 | 混淆後取後四位 |
---|---|---|---|
808321199 | 110000001011100000000010101111 | 1111 | 0001 |
7015199 | 11010110000101100011111 | 1111 | 0100 |
9999 | 10011100001111 | 1111 | 1111 |
實際上,由於取模運算最終只看數組長度轉成的二進制數的有效位數,也就是說,數組有效位是4位,那麼 key 的 hash 就只看4位,如果是18位,那麼 hash 就只看18位。
在這種情況下,如果數組夠長,那麼 hash 有效位夠多,散列度就會很好;但是如果有效位非常短,比如只有4位,那麼對於區分度在高位數字的值來說就無法區分開,比如表格所示的 808321199,7015199,461539999 三個低位相同的數字,最後取模的時候都會被看成 1111,而混合高低位以後就是 0001,0100,1111,這就可以區分開來了。
六、HashMap 添加元素
在之前獲取下標的例子中,我們知道 put()
方法依賴於 putVal()
方法,事實上,包括 putAll()
在內,所有添加元素的方法都需要依賴於 putVal()
。
由於添加元素涉及到整個結構的改變,因而 putVal()
中除了需要計算下標,還包含擴容,鏈表的樹化與樹的鏈表化在內的多個過程。
1. putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 當前table數組
Node<K,V>[] tab; Node<K,V> p;
// 當前數組長度,當前要插入數組位置的下標
int n, i;
// 若集合未擴容,則進行第一次擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 獲取key對應要插入的下標(即桶)
if ((p = tab[i = (n - 1) & hash]) == null)
// 若是桶中沒有元素,則添加第一個
tab[i] = newNode(hash, key, value, null);
else {
// 若已經桶中已經存在元素
Node<K,V> e; K k;
// 1.插入元素與第一個元素是否有相同key
if (p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k))))
e = p;
// 2.桶中的鏈表是否已經轉換爲紅黑樹
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3.遍歷鏈表,添加到尾端
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;
}
// 是否遇到了key相同的節點
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果key已經存在對應的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 是否要覆蓋value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 空方法,用於LinkedHashMap插入後的回調
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 是否需要擴容
if (++size > threshold)
resize();
// 空方法,用於LinkedHashMap插入後的回調
afterNodeInsertion(evict);
return null;
}
2.鏈表的樹化
在上述過程,涉及到了判斷桶中是否已經轉爲紅黑樹的操作:
else if (p instanceof TreeNode)
// 將Node轉爲TreeNode,並且添加到紅黑樹
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
以及將鏈表轉爲紅黑樹的操作:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
其中,putTreeVal()
是添加節點到紅黑樹的方法,而 treeifyBin()
是一個將鏈表轉爲紅黑樹的方法。我們暫且只看看 HashMap 鏈表是如何轉爲紅黑樹的:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// table是否小於最小樹化閾值64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果不到64就直接擴容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 否則看看桶中是否存在元素
TreeNode<K,V> hd = null, tl = null;
// 將桶中鏈表的所有節點Node轉爲TreeNode
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);
}
}
上面過程實現了將鏈表的節點 Node 轉爲 TreeNode 的過程,接下來 TreeNode.treeify()
方法會真正將鏈表轉爲紅黑樹:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
// 遍歷鏈表
for (TreeNode<K,V> x = this, next; x != null; x = next) {
// 獲取下一節點
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
// 隊首元素爲根節點
if (root == null) {
x.parent = null;
x.red = false;
root = x;
} else {
// 不是隊首元素,則構建子節點
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
// 向右
if ((ph = p.hash) > h)
dir = -1;
// 向左
else if (ph < h)
dir = 1;
// 使用比較器進行比較
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
// 構建子節點
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 再平衡
root = balanceInsertion(root, x);
break;
}
}
}
}
// 再平衡,保證鏈表頭節點是樹的根節點
moveRootToFront(tab, root);
}
以上是鏈表樹化的過程,雖然實現過程不簡單,但是流程很簡單:
- 判斷是否鏈表是否大於8;
- 判斷元素總數量是否大於最小樹化閾值64;
- 將原本鏈表的Node節點轉爲TreeNode節點;
- 構建樹,添加每一個子節點的時候判斷是否需要再平衡;
- 構建完後,若原本鏈表的頭結點不是樹的根節點,則再平衡確保頭節點變爲根節點
鏈表轉爲紅黑樹的條件
這裏我們也理清楚了鏈表樹化的條件:一個是鏈表添加完元素後是否大於8,並且當前總元素數量大於64。
當不滿足這個條件的時候,再添加元素就會直接擴容,利用擴容過程中的重哈希來緩解哈希衝突,而不是轉爲紅黑樹。
3.爲什麼key可以爲null
我們回顧一下 hash()
方法:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
可以看到,這裏對 key == null
的情況做了處理,當 key 是 null 的時候,哈希值會直接被作爲 hash 爲 0 的元素看待,在 putVal()
中添加元素的時候還會判斷:
if (p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k))))
由於除了比較 hash 值,還會比較內存地址並調用 equals 比較,所以 null 會被篩出來,作爲有且僅有一個的 key 使用。
七、HashMap 的擴容
現在我們知道了 HashMap 是如何計算下標的,也明白了 HashMap 是如何添加元素的,現在我們該瞭解添加元素過程中,擴容方法 resize()
的原理了。
1. resize
resize()
是 HashMap 的擴容方法:
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) {
// 是否大於理論允許最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 擴容閾值設置爲Integer.MAX_VALUE,本次以後不會再觸發擴容
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 若未達到理論允許最大值,並且:
// (1)本次擴容目標容量的兩邊小於理論允許最大值
// (2)當前容量大於默認初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新擴容閾值爲當前擴容閾值的兩倍
newThr = oldThr << 1;
}
// 2.若本次擴容容量大於0(即還是初始狀態,指定了容量,但是是第一次擴容)
else if (oldThr > 0)
// 新容量爲上一次指定的擴容閾值
newCap = oldThr;
// 3.若當前容量和上一次的擴容閾值都爲0(即還是初始狀態,未指定容量而且也沒擴容過)
else {
// 使用默認值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//======二、根據指定大小擴容======
// 根據負載係數檢驗新容量是否可用
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
// 如果乘上負載係數大於理論允許最大容量,則直接擴容到Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
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)
// 重新計算節點在新HashMap桶數組的下標
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是紅黑樹,判斷是否需要鏈化
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 如果是鏈表
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;
}
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;
}
上述過程代碼一大串,其實就是確定容量和擴容兩個步驟。
2.確認擴容大小
擴容的時機
在瞭解 HashMap 如何確認擴容大小之前,我們需要明白 HashMap 是什麼時候會認爲需要擴容。
我們在前面知道了,value 的下標由 key 的高低位混合後與數組長度-1進行與運算獲得,也就是說,如果數組長度不夠大——或者說容量不夠大,就會導致與運算後得到的隨機值範圍受限,因此更可能造成哈希衝突。
爲此,HashMap 引入負載係數 loadFactor
,當不指定時默認爲0.75,則有擴容閾值 threshold = 容量*負載係數
,達到擴容閾值——而不是容量大小——的時候就會進行擴容。
假如我們都使用初始值,即默認容量16,默認負載係數0.75,則第一次擴容後,當元素個數達到 0.75*16=12
時,就會進行一次擴容變爲原來的兩倍,也就是32,並且將 threshold
更新爲32*0.75=24
。如此反覆。
擴容的大小
擴容的時候,分爲兩種情況:已經擴容過,還未擴容過。
我們僅針對獲取新容量 newCap
與新擴容閾值 newThr
這段代碼邏輯,畫出大致流程圖:
這裏比較需要注意的是,當 oldCap 已經大於等於理論最大值的時候,會在設置 newThr=Integer.MAX_VALUE
後直接返回,不會執行後序擴容過程。
另外,當新擴容閾值被設置爲 Integer.MAX_VALUE
以後,由於該值已經是最大的整數值了,所以設置爲該值以後 HashMap 就不會再觸發擴容了。
3.重哈希過程
我們知道,如果桶數組擴容了,那麼數組長度也就變了,那麼根據長度與哈希值進行與運算的時候計算出來的下標就不一樣。在 JDK7 中 HashMap 擴容移動舊容器的數據的時候,會直接進行重哈希獲得新索引,並且打亂所有元素的排布。而在JDK8進行了優化,只移動部分元素。
我們可以回去看看擴容部分的代碼,其中有這兩處判斷:
// 判斷擴容後是否需要移動位置
if ((e.hash & oldCap) == 0) {
//... ...
}else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
// 擴容後移動位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
前面有提到 HashMap 取下標,是通過將 key 的哈希值與長度做與運算,也就是 (n-1) & hash
,而這裏通過計算 n & hash
是否爲 0 判斷是否需要位移。
他的思路是這樣的:
假如從16擴容到32,擴容前通過(n-1) & hash
取模是取後4位,而擴容後取後5位,因爲01111和1111沒區別,所以如果多出來這一位是0,那麼最後用新長度去與運算得到的座標是不變的,那麼就不用移動。否則,多出來這一位相當於多了10000,轉爲十進制就是在原基礎上加16,也就是加上了原桶數組的長度,那麼直接在原基礎上移動原桶數組長度就行了。
以初始容量 oldCap = 16,newCap = 32
爲例,我們先看看他的換算過程:
十進制 Cap | 二進制 Cap | 二進制 Cap-1 | 十進制 Cap-1 | |
---|---|---|---|---|
oldCap | 16 | 10000 | 1111 | 15 |
newCap | 32 | 100000 | 11111 | 31 |
以上述數據爲基礎,我們模擬下面三個 key 在擴容過程中的計算:
key | hash | (oldCap-1) & hash | oldCap & hash | (newCap-1) & hash |
---|---|---|---|---|
808321199 | 110000001011100000000010101111 | 1111(15) | 0 | 01111(15) |
7015199 | 11010110000101100011111 | 1111(15) | 10000 | 11111(31) |
9999 | 10011100001111 | 1111(15) | 0 | 01111(15) |
不難看出,只有當 oldCap & hash > 0
的時候元素才需要移動,而由於容量必然是2的冥,每次擴容新容量都是舊容量的兩倍,換成二進制,相同的 hash 值與運算算出來的座標總是多1,因此相當於每次需要移動的距離都是舊容量。
也就是說,如果 oldCap & hash > 0
,那麼就有 新座標=原下標+oldCap
,這個邏輯對應的代碼就是 newTab[j + oldCap] = hiHead;
這一行。
這樣做的好處顯而易見,少移動一些元素可以減少擴容的性能消耗,同時同一桶中的元素也有可能在重哈希之後被移動,使得哈希衝突得以在擴容後減緩,元素散列更均勻。
八、HashMap 獲取元素
和put()
方法和 putVal()
的關係一樣,get()
方法以及其他獲取元素的方法最終都依賴於 getNode()
方法。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
1. getNode
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果桶數組不爲空,並且桶中不爲null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果桶中第一個元素的key與要查找的key相同,返回第一個元素
if (first.hash == hash && // always check first node
((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;
}
2.爲什麼元素要同時重寫equals和hashcode?
首先,不被原本的的hashCode和equals是這樣的
hashCode()
是根據內存地址換算出來的一個值equals()
方法是判斷兩個對象內存地址是否相等
我們回顧一下上文,可以看到無論put()
還是get()
都會有類似這樣的語句:
// putVal
p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k)));
// getNode
p.hash == hash && (key != null && key.equals(k));
因爲可能存在哈希衝突,或者爲 null 的 key,因此所以光判斷哈希值是不夠的,事實上,當我們試圖添加或者找到一個 key 的時候,方法會根據三方面來確定一個唯一的 key:
- 比較
hashCode()
是否相等:代碼是比較內部hash()
方法算出來的值 hash 是否相等,但是由於該方法內部還是調用hashCode()
,所以實際上是比較的仍然是hashCode()
算出來的值; - 比較
equlas()
是否相等:Object.equlas()
方法在不重寫的時候,默認比較的是內存地址; - 比較 key 是否爲 null;
爲什麼要重寫equals和hashcode方法?
當我們使用 HashMap 提供的默認的流程時,這三處校驗已經足以保證 key 是唯一的。但是這也帶來了一些問題,當我們使用一些未重寫了 Object.hashCode()
或者 Object.equlas()
方法的類的實例作爲 key 的時候,由於 Object 類中的方法默認比較的都是內存地址,因此必須持有當初作爲 key 的實例才能拿到 value。
我們舉個例子:
假設我們有一個 Student 類
public class Student {
String name;
Integer age;
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}
}
現在我們使用 Student 的實例作爲 key:
Map<Object,Object> map = new HashMap<>(2);
map.put(new Student("xx",16), "a");
map.put(new Student("xx",16), "a");
for(Map.Entry<Object, Object> entry : map.entrySet()){
System.out.print(entry.getValue());
};
// aa
因此,如果我們希望使用對象作爲 key,那麼大多數時候都需要重寫equals()
和hashcode()
的。
爲什麼要同時重寫兩個方法?
這個也很好理解,判斷 key 需要把 equals()
和hashcode()
兩個的返回值都判斷一遍,如果只重寫其中一個,那麼最後還是不會被認爲是同一個 key。
當我們爲 Student 重寫 equals()
和hashcode()
以後,結果運行以後輸出就是隻有一個 a 了。
@Override
public int hashCode() {
return this.name.hashCode() + age;
}
@Override
public boolean equals(Object obj) {
return obj instanceof Student &&
this.name.equals(((Student) obj).name);
}
九、HashMap 刪除元素
在 HashMap 中,get,put 和 remove 行爲各自都有一個統一的底層方法。在 remove()
中,這個方法就是 removeNode()
,所有的刪除行爲最終都要通過調用它來實現。
1. removeNode
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;
// 若集合不爲空
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;
// 若桶中第一個就是目標元素
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;
}
2.紅黑樹在刪除過程的鏈化
在擴容部分我們瞭解了鏈表是如何轉爲紅黑樹的,事實上紅黑樹也可以在必要的時候轉化爲鏈表。在 removeNode()
方法中,可以看到調用了 removeTreeNode()
以刪除紅黑樹節點,實際上在這個過程中會發生紅黑樹的鏈化。
我們暫且只關注鏈化的判斷條件,也就是在 removeTreeNode()
中的這一段代碼:
// 根節點爲null,根節點的左或右子節點爲null,根節點左子節點的左子節點爲null
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too small
return;
}
也就是說,如果可能存在的最小紅黑樹如下:
可以看到,此時樹共有四個及節點,需要再刪除一個節點纔會導致鏈化,也就是說,在 remove 中,觸發鏈化的最小樹可能只有3個節點,而最大樹需要考慮到變色和平衡,是十個(待考證)。
也就是說,和網上所說的小於6就鏈化不同,在刪除中,鏈化觸發值是一個範圍,在 [3,10] 之間。
3.紅黑樹在擴容過程的鏈化
我們知道,擴容經過重哈希有可能會拆分鏈表,樹也一樣。在擴容時, split()
方法會對紅黑樹進行拆分,以便重哈希後變更位置,在裏頭有這麼一段邏輯:
// 左頭結點不爲空,並且長度小於鏈化閾值 6
if (lc <= UNTREEIFY_THRESHOLD)
// 將紅黑樹轉爲鏈表
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
在擴容時,是否鏈化的標準就是樹中元素個數是否小於鏈化閾值6。
十、HashMap 的迭代
由於 Map 集合本質上表示的是一組鍵值對之間的映射關係,並且 HashMap 的數據結構是數組+鏈表/樹,因此 HashMap 集合並無法直接像 Collection 接口的實現類那樣直接迭代。
而在本文的第四部分,我們瞭解了 HashMap 中的幾個主要內部類,其中四大視圖類就是其中三個集合視圖的 KeySet,Values,EntrySet,與一個鍵值對視圖 Entry。當我們要迭代 HashMap 的時候,就需要通過迭代三個集合視圖來實現,並且通過 key,value 或者 Entry 對象來接受迭代得到的對象。
值得一提的是,和 ArrayList 一樣,HashMap 也實現了 fast-fail 機制,因此最好不要在迭代的時候進行結構性操作。
1.迭代器迭代
所有集合都可以通過迭代器迭代器(集合的增強 for 循環在編譯以後也是迭代器跌迭代)。
所以,在 HashMap 中,三種視圖集合都可以通過迭代器或增強 for 循環迭代器,但是 HashMap 本身雖然有迭代器,但是由於沒有 iterator()
方法,所以無法通過迭代器或者增強 for 直接迭代,必須通過三種視圖集合來實現迭代。
以 EntrySet 視圖爲例:
Map<Object,Object> map = new HashMap<>();
// 迭代器
Iterator<Map.Entry<Object, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Object, Object> entry = iterator.next();
System.out.print(entry.getValue());
}
// 增強for迭代,等價於迭代器迭代
for(Map.Entry<Object, Object> entry : map.entrySet()){
System.out.print(entry.getValue());
};
// forEach迭代
map.entrySet().forEach(s -> {
System.out.println(s.getValue());
});
KeySet
視圖和 values
同理,但是 values 是 Collection 集合,所以寫法會稍微有點區別。
2.視圖集合中的數據從何處來
我們雖然通過 entrySet()
,values()
和 keySet()
三個方法獲取了視圖集合並且迭代成功了,但是回頭看源碼,卻發現源碼中返回的只是一個空集合,裏面並沒有任何裝填數據的操作,但是當我們直接拿到視圖集合的時候,卻能直接遍歷,原因在於他們的迭代器:
final class KeyIterator extends HashIterator
implements Iterator<K> {
// 獲取迭代器返回的node的key
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
// 獲取迭代器返回的node的value
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
// 獲取迭代器返回的node
public final Map.Entry<K,V> next() { return nextNode(); }
}
而這個 nextNode()
方法來自於他們的父類HashIterator
,這裏需要連着它的構造方法一起看:
// 構造方法
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 獲取第一個不爲空的桶中的第一個節點
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
// nextNode
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
// 接着構造方法獲取到的第一個非空桶中的節點,遍歷以該節點爲頭結點的鏈表
// 當遍歷完,接着查看下一個桶中的節點
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
可以看到,這個 HashIterator 就是 HashMap 真正意義上的迭代器,它會從桶數組中第一個非空桶的第一個節點開始,迭代完全部桶數組中的每一個節點。但是它並不直接使用,而是作爲而三個視圖集合的迭代器的父類。
三個視圖集合自己的迭代器通過把HashIterator
的nextNode()
方法的基礎重新適配爲 next()
,分別把它從返回 Node 節點類變爲了返回節點、節點的 key、節點的 value,這就是集合視圖迭代的原理。
由於 Node 本身就攜帶了 key,value和 hash,因此刪除或者添加就可以直接通過 HashMap 類的方法去操作,這就是迭代器增刪改的原理。
3. forEach迭代
HashMap 重寫了 forEach() 方法,三個視圖集合也自己重寫了各自的 forEach()
方法。
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
// 若集合不爲空
if (size > 0 && (tab = table) != null) {
int mc = modCount;
// 遍歷桶
for (int i = 0; i < tab.length; ++i) {
// 遍歷鏈表
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
forEach()
邏輯與迭代器類似,但是寫法更直白,就是遍歷桶數組然後遍歷桶數組中的鏈表。三個視圖集合的 forEach()
寫法與 HashMap 的基本一樣,這裏就不再贅述了。
十一、總結
結構與擴容
HashMap 底層結構是數組+鏈表/紅黑樹。
HashMap 在不指定初始容量和負載係數的時候,默認容量爲16,默認負載係數爲0.75,擴容閾值爲當前容量*負載係數,當容器中的元素數量大於等於擴容閾值的時候就會擴容爲原來的兩倍。
樹化與鏈化
紅黑樹的樹化一般發生在添加元素的時候。由於擴容本身就可以緩解哈希衝突,因此要讓 HashMap 選擇樹化而不是優先擴容,需要同時滿足兩個條件:
- 當容器中總元素的數量大於等於64;
- 添加元素後桶中鏈表長度大於等於8。
此時會將鏈表轉爲紅黑樹。
而紅黑樹的鏈化既發生在擴容過程,也發生在刪除過程,擴容過程中的鏈化觸發條件是樹的節點數量小於鏈化閾值6,而刪除過程中的鏈化觸發條件要求是左子節點、左子節點的左子節點或右子節點爲null。由於可能存在的最小樹或者最大樹,因此在刪除時鏈化觸發值的範圍處於 [3,10] 之間。
哈希算法
HashMap 獲取下標的過程分兩步:
- 位運算混淆 hashCode 的高低位:
(h = key.hashCode()) ^ (h >>> 16)
,作用是保證取模後的隨機性; - 與運算計算下標:
(n - 1) & hash
,作用是取模獲取下標。
其中,長度之所以是2的冥,就是爲了在此處將長度作爲哈希值的低位掩碼,巧妙實現取模效果。
擴容重哈希
假如從16擴容到32,擴容前通過(n-1) & hash
取模是取後4位,而擴容後取後5位,因爲01111和1111沒區別,所以如果多出來這一位是0,那麼最後用新長度去與運算得到的座標是不變的,那麼就不用移動。否則,多出來這一位相當於多了10000,轉爲十進制就是在原基礎上加16,也就是加上了原桶數組的長度,那麼直接在原基礎上移動原桶數組長度就行了。
迭代
HashMap 本身有迭代器 HashIterator
,但是沒有 iterator()
方法,所以無法直接通過增強 for 循環或者獲取迭代器進行迭代,只能藉助三個視圖集合的迭代器或增強 for 來迭代器。但是視圖迭代器本身也是 HashIterator
子類,因此視圖本身只是空集合,它的迭代能力來自於它們自己的迭代器的父類HashIterator
。
HashMap 和他的三個集合視圖都重寫了 forEach()
方法,所以可以通過 forEach()
迭代器。
HashMap 也實現了 fast-fail 機制,因此最好不要在迭代的時候進行結構性操作。
equals和hashCode方法
HashMap 在get()
和set()
的時候都會通過 Object.equals()
和 Object.hashCode()
方法來確定唯一 key。由於默認使用的 Object 的實現比較是內存地址,因此使用自建對象作爲 key 會很不方便,因此需要重寫兩個方法。但是由於校驗唯一性的時候兩個方法都會用到,因此若要重寫equals()
和hashCode()
必須同時重寫兩個方法,不能重寫其中一個。