HashMap的相關問題
文章目錄
解決哈希衝突的一些方法
- 開放定址法:
- 開放定址法就是一旦發生了衝突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。
- 鏈地址法
- 將哈希表的每個單元作爲鏈表的頭結點,所有哈希地址爲i的元素構成一個同義詞鏈表。即發生衝突時就把該關鍵字鏈在以該單元爲頭結點的鏈表的尾部。
- 再哈希法
- 當哈希地址發生衝突用其他的函數計算另一個哈希函數地址,直到衝突不再產生爲止。
- 建立公共溢出區
- 將哈希表分爲基本表和溢出表兩部分,發生衝突的元素都放入溢出表中。
HashMap的簡單介紹
HashMap的底層採用的是數組+鏈表的形式。
簡單來說,數組就是hash桶,然後每一個數組都存放一個鏈表。
然後在jdk1.8中進行了底層的優化,如果hash桶的數量大於64且鏈表長度大於8,那麼就會將鏈表進行樹化,構成一個紅黑樹,將查詢的複雜度從O(n)將爲了O(lgn)。
爲什麼不將鏈表全部換成紅黑樹
很明顯,紅黑樹從時間複雜度上是要優於鏈表的,但是爲什麼沒有全部替換成紅黑樹,原因大概有一下幾點:
- 鏈表的結構簡單,紅黑樹的結構複雜
- 在小範圍的數據上,紅黑樹的複雜度並沒有優於鏈表很多
- 在HashMap擴容的時候,會對紅黑樹進行拆分和重組,這其中的操作比較耗時。
爲什麼長度一定是2的冪次
在jdk1.7中,計算某個哈希值對應的數組索引採用原理是取模。
舉個例子:如果當前計算出的哈希值是9,哈希桶的數量是8,那麼9對應的數組下標就是9%8=1
但是,取模的運算速度是很慢的。因此有了一種快速計算索引的方法。那就是9&(8-1)=1。(位運算)
hash%length = hash&(length-1)
就是用位運算代替取模,但是這種方法只有在模數是2的冪的時候才起作用,所以要求容量大小是2的冪次,就是爲了加快速度,提高性能。同時在另外一方面,也可以減少衝突的可能性。
HashMap中的一些類常量介紹
//默認hash桶初始長度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//hash表最大容量2的30次冪
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認負載因子 0.75,主要是用於計算閾值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//鏈表的數量大於等於8個並且桶的數量大於等於64時鏈表樹化
static final int TREEIFY_THRESHOLD = 8;
//hash表某個節點鏈表的數量小於等於6時樹拆分
static final int UNTREEIFY_THRESHOLD = 6;
//樹化時最小桶的數量
static final int MIN_TREEIFY_CAPACITY = 64;
代碼問題,關於哈希算法
//通過移位和異或運算,可以讓 hash 變得更復雜,進而影響 hash 的分佈性。
/*
JDK 1.8 中,是通過 hashCode() 的高 16 位異或低 16 位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度,功效和質量來考慮的,減少系統的開銷,也不會造成因爲高位沒有參與下標的計算,從而引起的碰撞。
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
爲什麼要用異或運算符
保證了對象的 hashCode 的 32 位值只要有一位發生改變,整個 hash() 返回值就會改變。儘可能的減少碰撞。
關於comparableClass
For的作用解釋
參考https://blog.csdn.net/weixin_42340670/article/details/80673127
/**
* 如果對象x的類是C,如果C實現了Comparable<C>接口,那麼返回C,否則返回null
*/
static Class<?> comparableClassFor(Object x) {
if (x instanceof Comparable) {
Class<?> c; Type[] ts, as; Type t; ParameterizedType p;
if ((c = x.getClass()) == String.class) // 如果x是個字符串對象
return c; // 返回String.class
/*
* 爲什麼如果x是個字符串就直接返回c了呢 ? 因爲String 實現了 Comparable 接口,可參考如下String類的定義
* public final class String implements java.io.Serializable, Comparable<String>, CharSequence
*/
// 如果 c 不是字符串類,獲取c直接實現的接口(如果是泛型接口則附帶泛型信息)
if ((ts = c.getGenericInterfaces()) != null) {
for (int i = 0; i < ts.length; ++i) { // 遍歷接口數組
// 如果當前接口t是個泛型接口
// 如果該泛型接口t的原始類型p 是 Comparable 接口
// 如果該Comparable接口p只定義了一個泛型參數
// 如果這一個泛型參數的類型就是c,那麼返回c
if (((t = ts[i]) instanceof ParameterizedType) &&
((p = (ParameterizedType)t).getRawType() ==
Comparable.class) &&
(as = p.getActualTypeArguments()) != null &&
as.length == 1 && as[0] == c) // type arg is c
return c;
}
// 上面for循環的目的就是爲了看看x的class是否 implements Comparable<x的class>
}
}
return null; // 如果c並沒有實現 Comparable<c> 那麼返回空
}
關於loadFactor 負載因子大小的調整
在源碼種,loadFactor 的默認值是0.75,但是有一個構造器也提供了修改loadFactor的。但是源碼中並沒有對loadFactor 進行任何的限制,也就是說允許很小,也允許很大。
那麼這就可以牽扯到不同的場景,如果loadFactor 很小,那麼閾值就會很小,也就是稍微添加幾個元素就會觸發擴容機制,將容量和閾值擴大兩倍,這麼做帶來的好處在於減小哈希衝突的可能性,但是增大了空間的消耗,是典型的空間換時間。
那麼loadFactor 也可以大於1,那麼這時候閾值就會大於cap,也就是可以理解就算是把每一個哈希桶裝滿,也還是能夠容納元素,而不觸發擴容機制。也就是典型的時間換空間的做法。
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;
}
這個方法的作用就是在初始化HashMap的時候,因爲要求容量是2的冪次,但是如果你傳入的不是一個2的冪次的數,代碼底層就會把你傳入的那個數字優化爲一個2的冪次。
首先是n = cap-1,減一的目的是如果傳入的cap恰好爲2的冪次,那麼通過下面的算法的時候會多擴大一倍,模擬一下就知道效果了。
下面操作的原理大概是這樣的,對於一個數,它的二進制最高爲爲1,那麼將這個數右移一位再或起來,那麼高兩位就一定爲1。然後將新得到的數或上右移兩位,那麼高4爲就一定爲1。以此類推。
最後得到的就是一個2進制上全爲1的數了,最後再加1,那麼返回的就是一個2的冪次數。
至於爲什麼沒有 n |= n >>> 32,那是因爲容量最大是2的32次。
初始化threshold的地方
- 構造器傳入一個初始容量的時候,會設置閾值
this.threshold = tableSizeFor(initialCapacity);
- 構造器傳入一個Map的時候,會設置閾值
threshold = tableSizeFor(t);
核心方法putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//這就是所謂的延遲創建,在第一次put元素的時候,初始化底層數組
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果當前元素對應的數組位置是空的,那麼就直接把對應的Node節點加進去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//發現數組的對應位置有值,但是發現要put的鍵值對的key和當前位置的相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//當前節點是紅黑樹節點了,那就要調用添加紅黑樹節點的方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//不然就遍歷整個鏈表,看有沒有和要put的鍵值對一樣的key
for (int binCount = 0; ; ++binCount) {
//發現鏈表到底了還沒有找到一樣的key,那就直接添加新的鏈表節點元素
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,那就用傳進來的value值更新value屬性
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//長度到達了閾值,擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
核心方法resize
首先有一點,1.8中,把初始化的操作延遲到了第一次put的時候。
這個resize方法的作用很多,不僅在每個HashMap首次添加元素的時候會調用,而且在容量超過閾值的時候,都會調用,基本是每次擴大2倍。
說明一下構造器
便於解釋一下下方的註釋
總共有4種,但是大體可以分爲3類
public HashMap(int initialCapacity, float loadFactor) {}
public HashMap(int initialCapacity) {}
public HashMap() {}
public HashMap(Map<? extends K, ? extends V> m) {}
第一類:帶初始容量的,設置了閾值
第二類:無參構造器,什麼都沒有
第三類:傳入集合,既設置閾值,同時也會調用resize()
e.hash & oldCap
這個表達式的作用是這樣的,我們知道oldCap一定是一個2的冪次,所以它的二進制上只有一個1,也就是形如000010000,這種形式。所以e.hash & oldCap這個表達式的結果只可能有2種,要麼是0,要麼是oldCap,而且從概率上來說,兩者的概率應該是五五開的。那麼這樣子就就可以用於下方鏈表的分裂操作了,也即是把一個鏈表拆分成兩個鏈表,拆分的依據就是這兩種不同的結果。
還有一點,關於怎麼擴容
由於容器是2的冪次的,擴容也是每次擴大兩倍,那麼就會有一個很好的特點。
假設舊容器大小時oldcap,新容器newcap的大小爲newcap=oldcap*2
那麼,對於old數組下標爲index上的內容,擴容到新的數組上就只有2種取值,要麼是原位置,要麼是原位置+oldcap
舉個例子:
如果之前的oldcap是4,那麼原來哈希值爲1 % 4 = 1,5 %4 = 1,9 % 4 = 1,13 % 4 = 1的就都會映射到index爲1的位置。
現在經過擴容之後,newcap = 8,那麼1 % 8 = 1,5 % 8 = 5,9 %8 = 1,13 % 8 = 5,結果就很明顯了。
關於增加鏈表長度的三步
-
首先要記錄起始節點和尾節點
-
每當增加一個元素,那麼就要更新尾節點.next 屬性,同時要將尾節點往後移
-
所有元素添加完畢之後,將尾節點的next屬性置爲null,不然就死循環了。
源碼註釋
final Node<K,V>[] resize() {
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;
}
//正常的2倍擴容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
//初始化調用,設置新容量爲就閾值
else if (oldThr > 0)
newCap = oldThr;
//初始化調用,且沒有設置新閾值,就是無參構造器
else {
//新容量就是默認的16
newCap = DEFAULT_INITIAL_CAPACITY;
//新閾就是16*0.75=12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//這個就是對另外兩類構造器的完善,設置新閾值
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"})
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)
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;
//loHead和loTail可以理解爲第一個鏈表的首節點和尾節點
//hiHead和hiTail可以理解爲第二個鏈表的首節點和尾節點
do {
next = e.next;
//這就是上面所說的,根據表達式的值,分兩類
if ((e.hash & oldCap) == 0) {
//第一個鏈表記錄首節點
if (loTail == null)
loHead = e;
else
//相當於鏈表長度+1
loTail.next = e;
//更新尾節點
loTail = e;
}
//下面和上面同理
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//將第一個鏈表接到新數組的j位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//將第二個鏈表接到新數組的j+oldcap位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
treeifyBin(樹化)的部分細節
如何判斷節點在紅黑樹的左邊還是右邊
- 先判斷hash值,小的往左,大的往右,相等就看第二步
- 判斷能不能通過comparable接口去判斷,如果不能或者還是相等,就第三步
- 調用tieBreakOrder方法,最後一定能夠區分是往左還是往右
爲什麼判斷左右是根據哈希值//
源碼分析
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//必須滿足鏈表長度大於8(進入該函數已滿足),並且哈希桶的數量大於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)
//真正的樹化
hd.treeify(tab);
}
}
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);
//dir的過程標題開頭已經介紹過了
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);
}
可以看一下源碼中紅黑樹平衡是怎麼寫的
關於紅黑樹的概念以及理解,可以看我的這篇博客
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
//插入的節點默認是紅色
x.red = true;
/*
關於這些變量:
xp:x節點的父親節點
xpp:xp節點的父親節點,也就是x節點的祖父節點
xppl:x的祖父節點的左兒子
xppr:x的祖父節點的右兒子
*/
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
//發現是根節點,設爲黑色,返回
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
//如果父親節點是黑色或者祖父爲空,其實也就是父親節點是根節點
else if (!xp.red || (xpp = xp.parent) == null)
return root;
//x的父親節點是x的祖父節點的左兒子,當然了x的父節點是紅色的
if (xp == (xppl = xpp.left)) {
//x的叔叔節點存在並且是紅色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
//x的叔叔節點不存在或者是爲黑色
else {
//x是x的父節點的右兒子
if (x == xp.right) {
//左旋,同時修改x節點爲x的父節點(x=xp)
root = rotateLeft(root, x = xp);
//不知道爲什麼要再次確認xpp,在我的認知裏,應該不會修改xpp的值
xpp = (xp = x.parent) == null ? null : xp.parent;
}
//修改xp
if (xp != null) {
xp.red = false;
if (xpp != null) {
//修改xpp
xpp.red = true;
//對xpp進行右旋
root = rotateRight(root, xpp);
}
}
}
}
//下面和上面是差不多的
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
removeNode方法分析
/*
matchValue 如果爲true,則當key對應的鍵值對的值equals(value)爲true時才刪除;否則不關心value的值
movable 刪除後是否移動節點,如果爲false,則不移動
*/
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;
//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 {
//遍歷鏈表,找到一個相匹配的節點,p的作用是記錄要刪除節點的前驅節點
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;
}
內部迭代器的分析
//這是抽象類,內部沒有實現Next方法,因爲HashMap的迭代器可以有多種,遍歷key的,value的,entry的,所以交給子類實現
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
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);
}
}
public final boolean hasNext() {
return next != null;
}
//關鍵方法
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();
//要麼找到鏈表的下一個位置,要麼是找到下一個數組index,這個是不會用到紅黑樹節點的,因爲沒必要
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
//刪除節點
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
//三種具體的實現類,分別迭代key,value,entry
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的時候