JAVA集合之HashMap原始碼分析
一、簡介
HashMap
繼承於AbstractMap
,實現了Map
、Cloneable
和Serializable
介面。HashMap
是一個散列表(陣列和連結串列),它儲存的內容是鍵值對(key
-value
)對映,能在查詢和修改方便繼承了陣列的線性查詢和連結串列的定址修改。HashMap
是非synchronized
,所以HashMap
很快。HashMap
的鍵和值都可以為null,而Hashtable
則不能(原因就是equlas(
)方法需要物件,因為HashMap
是後出的API
經過處理才可以),HashMap
中的對映不是有序的。
HashMap
的例項有兩個引數影響其效能:“初始容量”和“載入因子”。
容量 是雜湊表中桶的數量,初始容量
HashMap
初始容量是16
。載入因子 是雜湊表在其容量自動增加之前可以達到多滿的一種尺度。
當雜湊表中的條目數超出了載入因子與當前容量的乘積時,則要對該雜湊表進行擴容(
rehash
)操作(即重建內部資料結構),從而雜湊表將具有大約 2
倍的桶數。通常,預設載入因子是
0.75
,這是在時間和空間成本上尋求一種折衷。載入因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數HashMap
類的操作中,包括get
和put
操作,都反映了這一點)。在設定初始容量時應該考慮到對映中所需的條目數及其載入因子,以便最大限度地減少rehash
操作次數。如果初始容量大於最大條目數除以載入因子,則不會發生rehash
先來看看hashmap
的結構。
二、歷史版本
本文HashMap
原始碼基於JDK8
。不同版本HashMap
的變化還是比較大的,在1.8
之前,HashMap
沒有引入紅黑樹,也就是說HashMap
的桶(桶即hashmap
陣列的一個索引位置)單純的採取連結串列儲存。這種結構雖然簡單,但是當Hash
衝突達到一定程度,連結串列長度過長,會導致時間複雜度無限向O(n)
靠近。比如向HashMap
中插入如下元素,你會神奇的發現,在HashMap
的下表為1
的桶中形成了一個連結串列。
map.put(1,1); map.put(17,17); map.put(33,33); map.put(49,49); map.put(65,65); map.put(81,81); map.put(97,97); ... 16^n + 1
為了解決這種簡單的底層儲存結構帶來的效能問題,引入了紅黑樹。在一定程度上緩解了連結串列儲存帶來的效能問題。引入紅黑樹之後當桶中連結串列長度超過8
將會樹化即轉為紅黑樹(put
觸發)。當紅黑樹元素少於6
會轉為連結串列(remove
觸發)。
在這裡還有一個很重要的知識點,樹化和連結串列化的閾值不一樣?想一個極端情況,假設閾值都是8
,一個桶中連結串列長度為8
時,此時繼續向該桶中put
會進行樹化,然後remove又會連結串列化。如果反覆put
和remove
。每次都會進行極其耗時的資料結構轉換。如果是兩個閾值,將會形成一個緩衝帶,減少這種極端情況發生的概率。
上面這種極端情況也被稱之為複雜度震盪。類似的複雜度震盪問題ArrayList
也存在。
三、基礎知識
3.1 常量和構造方法
// 16預設初始容量(這個容量不是說map能裝多少個元素,而是桶的個數)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量值
static final int MAXIMUM_CAPACITY = 1 << 30;
// 預設負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 樹化閾值 一個桶連結串列長度超過 8 進行樹化
static final int TREEIFY_THRESHOLD = 8;
// 連結串列化閾值 一個桶中紅黑樹元素少於 6 從紅黑樹變成連結串列
static final int UNTREEIFY_THRESHOLD = 6;
// 最小樹化容量,當容量未達到64,即使連結串列長度>8,也不會樹化,而是進行擴容。
static final int MIN_TREEIFY_CAPACITY = 64;
// 桶陣列,bucket. 這個也就是hashmap的底層結構。
transient Node<K,V>[] table;
// 數量,即hashmap中的元素數量
transient int size;
// hashmap進行擴容的閾值(這個表示的元素多少,可不是桶被用了多少哦,比如閾值是16,當有16個元素就進行擴容,而不是說當桶被用了16個)
int threshold;
//當前負載因子,預設是DEFAULT_LOAD_FACTOR=0.75
final float loadFactor;
/************************************四個構造方法***************************************/
public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,負載因子
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)// > 不能大於最大容量
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//總要保持 初始容量為2的整數次冪
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
3.2 桶的兩種資料結構
JDK1.8
的HashMap
採用的是連結串列+紅黑樹。
連結串列結構
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
紅黑樹結構
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
3.3 hash演算法實現
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
計算桶下標方法
(n - 1) & hash; //n表示HashMap的容量。相當於取模運算。等同於`hash % n`。
n
其實就是HashMap
底層陣列的長度。(n-1) & hash
這個與運算,等同於hash % n
。hash()
方法,只是key
的hashCode
的再雜湊,使key
更加雜湊。而元素究竟存在哪個桶中。還是 (n - 1) & hash結果決定的。綜合一下如下,在hashmap
中計算桶索引的方法如下所示。
public static int index(Object key, Integer length) {
int h;
h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
return (length - 1) & h;
}
假設當前hashmap
桶個數即陣列長度為16
,現在插入一個元素key
。
計算過程如上圖所示。得到了桶的索引位置。在上面計算過程中,只有一步是比較難以理解的。也就是為什麼不直接拿key.hashcode() & (n - 1) ,為什麼要用key.hashcode() ^ (key.hashcode() >>> 16為什麼要多一步呢?後面問題總結會詳細介紹。
四、put過程原始碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//put1,懶載入,第一次put的時候初始化table(node陣列)
if ((tab = table) == null || (n = tab.length) == 0)
//resize中會進行table的初始化即hashmap陣列初始化。
n = (tab = resize()).length;
//put2,(n - 1) & hash:計算下標。// put3,判空,為空即沒hash碰撞。直接放入桶中
if ((p = tab[i = (n - 1) & hash]) == null)
//將資料放入桶中
tab[i] = newNode(hash, key, value, null);
else {//put4,有hash碰撞
Node<K,V> e; K k;
//如果key已經存在,覆蓋舊值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//put4-3:如果是紅黑樹直接插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//如果桶是連結串列,存在兩種情況,超過閾值轉換成紅黑樹,否則直接在連結串列後面追加
for (int binCount = 0; ; ++binCount) {
//put4-1:在連結串列尾部追加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//put4-2:連結串列長度超過8,樹化(轉化成紅黑樹)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
//如果key已經存在,覆蓋舊值
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//put5:當key已經存在,執行覆蓋舊值邏輯。
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)//put6,當size > threshold,進行擴容。
resize();
afterNodeInsertion(evict);
return null;
}
其實上面put
的邏輯還算是比較清晰的。(吐槽一下JDK
原始碼,可讀性真的不好,可讀性真的不如Spring
。尤其是JDK
中總是在if
或者for
中對變數進行賦值。可讀性真的差。但是邏輯是真的經典)
總結一下put
的過程大致分為以下8
步。
1、懶漢式,第一次`put`才初始化`table`桶陣列。(節省記憶體,時間換空間)
2、計算`hash`及桶下標。
3、未發生`hash`碰撞,直接放入桶中。
4、發生碰撞
4.1、如果是連結串列,迭代插入到連結串列尾部。
4.2、如果連結串列長度超過8,樹化即轉換為紅黑樹。(當陣列長度小於64時,進行擴容而不是樹化)
4.3、如果是紅黑樹,插入到紅黑樹中。
5、如果在以上過程中發現`key`已經存在,覆蓋舊值。
6、如果`size > threshold`。進行擴容。
以上過程中,當連結串列長度超過8
進行樹化,只是執行樹化方法treeifyBin(tab, hash);。但是在該方法中還有一步判斷,也就是當桶陣列長度<64。並不會進行樹化,而是進行擴容。你想想,假如容量為16
,你就插入了9
個元素,巧了,都在同一個桶裡面,如果這時進行樹化,樹化本身就是一個耗時的過程。時間複雜度會增加,效能下降,不如直接進行擴容,空間換時間。
看看這個方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//如果容量 < 64則直接進行擴容;不轉紅黑樹。(你想想,假如容量為16,你就插入了9個元素,巧了,都在同一個桶裡面,如果這時進行樹化,時間複雜度會增加,效能下降,不如直接進行擴容,空間換時間)
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);
}
}
在put
邏輯中還有最重要的一個過程也就是擴容。
五、擴容
5.1、擴容
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;
}
//擴容為原來的兩倍,<< 位運算
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //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);
}
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) {//一共oldCap個桶
Node<K,V> e;
if ((e = oldTab[j]) != null) {//如果第j個桶沒元素就不管了
oldTab[j] = null;
//只有一個元素,直接移到新的桶中(為什麼不先判斷是不是TreeNode?
//很簡單,因為TreeNode沒有next指標,在此一定為null,也能證明是一個元素。
//對於大多數沒有hash衝突的桶,減少了判斷,處處充滿著智慧)
if (e.next == null)
//計算桶下標,e.hash & (newCap - 1)是newCap哦
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // rehash 原始碼很經典
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 {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}
}
}
}
}
return newTab;
}
從以上原始碼總計一下擴容的過程:
1、建立一個兩倍於原來(`oldTab`)容量的陣列(`newTab`)。
2、遍歷`oldTab`
2.1,如果當前桶沒有元素直接跳過。
2.2,如果當前桶只有一個元素,直接移動到`newTab`中的索引位。(e.hash & (newCap - 1))
2.3,如果當前桶為紅黑樹,在`split()`方法中進行元素的移動。
2.4,如果當前桶為連結串列,執行連結串列的元素移動邏輯。
在以上過程中,我們著重介紹連結串列的元素移動。也就是上述程式碼中的39-68行。
首先,我們看其中
Node<K,V> loHead = null, loTail = null;//下標保持不變的桶
Node<K,V> hiHead = null, hiTail = null;//下標擴容兩倍後的桶
loHead
和loTail
分別對應經過rehash
後下標保持不變的元素形成的連結串列頭和尾。hiHead
和hiTail
分別對應經過rehash
後下標變為原來(n
+ oldIndex
)後的連結串列頭和尾。
經過上面變數,我們不難發現,桶中的資料只有兩個去向。(oldIndex
和 n
+ oldIndex
)
接下來我們思考一個問題。為什麼經過rehash
,一個桶中的元素只有兩個去向?
以下過程很燒腦,但是看懂了保證會收穫很多。更會體會到原始碼之美。
大致畫一下圖,如下所示。
HashMap
的容量總是2的n
次方(n
<= 32)。
假設擴容前桶個數為16。
看擴容前後的結果。觀察擴容前後可以發現,唯一影響索引位的是hash的低第5位。
所以分為兩種情況hash低第5位為0或者1。
當低第5位為0:newIndex = oldIndex
當低第5位為1:newIndex = oldIndex + oldCap
以上過程也就說明了為啥rehash
後一個桶中的元素只有兩個去向。這個過程我看沒有部落格介紹過。為什麼在這裡詳細介紹這個呢?因為這個很重要,不懂這個就看不懂以上rehash
程式碼,也很難體會到JDK
原始碼的經典之處。給ConcurrentHashMap
rehash
時的鎖打一個基礎。
if ((e.hash & oldCap) == 0)
這個判斷成立,則說明該元素在rehash
後下標不變,還在原來的索引位置的桶中。為什麼?
我們先看一下 (e.hash & oldCap)
看結果,如果判斷if((e.hash & oldCap) == 0)成立,也就是說hash
的低第5位為0。
在上個問題我們推導桶中元素的兩個去向的時候,發現低第5位的兩種情況決定了該元素的去向。再觀察上面問題推導中的hash的第一種情況當*為0;
驚不驚喜,意不意外,神奇的發現,當hash
低5位為0時,其新索引為依然為oldIndex
。OK,你不得不佩服作者的腦子為何如此聰明。當然了這一切巧妙的設計都是建立在hashmap
桶的數量總是2的n
次方。
回到原始碼,將新的兩個連結串列分別放到newTab
的oldIndex
位置和newIndex
位置。正如我們上面推導的那樣
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}
以上resize
過程就說完了。
留一個問題,以上resize
過程效能還能不能進一步優化呢?有興趣的可以對比ConcurrentHashMap
的這個rehash
原始碼。你會神奇的發現JDK8
的作者為了效能究竟有多拼。
當然resize過程在併發環境下還是存在一定問題的。接下來繼續往下看。
5.2 JDK7併發環境擴容問題——迴圈連結串列
先看原始碼
//將當前所有的雜湊表資料複製到新的雜湊表
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍歷舊的雜湊表
for (Entry<K,V> e : table) {
while(null != e) {
//儲存舊的雜湊表對應的連結串列頭的下一個結點
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//因為雜湊表的長度變了,需要重新計算索引
int i = indexFor(e.hash, newCapacity);
//第一次迴圈的newTable[i]為空,賦值給當前結點的下一個元素,
e.next = newTable[i];
//將結點賦值到新的雜湊表
newTable[i] = e;
e = next;
}
}
}
JDK7 hashmap
採用的是頭插法,也就是每put
一個元素,總是插入到連結串列的頭部。相對於JDK8
尾插法,插入操作時間複雜度更低。看上面transfer
方法。假設擴容前陣列長度為2,擴容後即長度為4。過程如下。(以下幾張圖片來自慕課網課程)
第一步:處理節點5,resize後還在原來位置。
第二步:處理節點9,resize後還在原來位置。頭插,node(9).next = node(5);
第三步:處理節點11,resize後在索引位置3處。移動到新桶中。
併發環境下的問題
假設此時有兩個執行緒同時put
並同時觸發resize
操作。
執行緒1執行到,只改變了舊的連結串列的連結串列頭,使其指向下一個元素9。此時執行緒1因為分配的時間片已經用完了。
緊接著執行緒2完成了整個resize
過程。
執行緒1再次獲得時間片,繼續執行。解釋下圖,因為節點本身是在堆區。兩個執行緒棧只是調整連結串列指標的指向問題。
當執行緒2執行結束後,table
這個變數將不是我們關注的重點,因為table
是兩個執行緒的共享變數,執行緒2已經將table
中的變數搬運完了。但是由於執行緒1停止的時間如上,執行緒1的工作記憶體中依然有一個變數next
是指向9節點的。明確了這一點繼續往下看。
當執行緒2執行結束。執行緒1繼續執行,newTable[1]
位置是指向節點5的。如下圖。
如上圖執行緒1的第一次while
迴圈結束後,注意 e = next 這行程式碼。經過第一次迴圈後,e指向9。如下圖所示。
按理來說此時如果執行緒1也結束了也沒啥事了,但是經過執行緒2的resize
,9節點時指向5節點的,如上圖。所以執行緒1按照程式碼邏輯來說,依然沒有處理完。然後再將5節點插入到newTable
中,5節點繼續指向9節點,這層迴圈因為5.next==null,所以迴圈結束(自己看程式碼邏輯哦,e是在while之外的,所以這裡不會死迴圈)。如下圖所示,迴圈連結串列形成。
然後在你下一次進行get
的時候,會進入死迴圈。
最後想一下JDK7
會出現死迴圈的根源在哪裡?很重要哦這個問題,根源就在於JDK7
用的是頭插法,而resize
又是從頭開始rehash
,也就是在老的table
中本來是頭的,到新table
中便成為了尾,改變了節點的指向。`
5.3 JDK8的資料丟失問題
上面介紹了JDK7
中迴圈連結串列的形成,然後想想JDK8
中的resize
程式碼,JDK8
中的策略是將oldTab
中的連結串列拆分成兩個連結串列然後再將兩個連結串列分別放到newTab
中即新的陣列中。在JDK8
會出現丟失資料的現象(很好理解,在這裡就不畫圖了,感興趣的自己畫一下),但是不會出現迴圈連結串列。丟資料總比形成死迴圈好吧。另外一點JDK8
的這種策略也間接的保證了節點間的相對順序。好吧,還是說說JDK8
的丟資料問題吧。
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//判斷成立,說明該元素不用移動
if (loTail == null)//尾空,頭插
loHead = e;
else//尾不空,尾插
loTail.next = e;
loTail = e;
}
else {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}
假設兩個執行緒,根據程式碼邏輯,執行緒1執行了4次迴圈讓出時間片,如下圖所示。
此時連結串列table索引1位置的桶如下所示
如果此時執行緒2也進行resize
。此時執行緒2看到的oldTab
是如上圖所示的。很明顯,接下來執行緒1執行完成,並順利將兩個連結串列放到了newTab
中。
此時執行緒2又獲取時間片並繼續執行以下操作相當於之前執行緒1的resize
結果被執行緒2覆蓋了。此時就發生了資料的丟失。
終於介紹完了擴容過程,不容易啊。
六、get()方法原始碼
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;//get1,計算hash
}
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) {// get2,(n - 1) & hash 計算下標
if (first.hash == hash && // always check first node //get3-1,首先檢查第一個元素(頭元素),如果是目標元素,直接返回
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)//get3-2,紅黑樹
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//get3-3,連結串列
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
看完了put
的原始碼,會發現get
過程是何其簡單,大致過程如下
1、計算`hash`
2、計算下標
3、獲取桶的頭節點,如果頭結點`key`等於目標`key`直接返回。
3.1,如果是連結串列,執行連結串列迭代邏輯,找到目標節點返回。
3.2,如果是紅黑樹,執行紅黑樹迭代邏輯,找到目標節點返回。
關於remove
方法,不介紹了,無非就是就是get
過程+紅黑樹到連結串列的轉化過程。不介紹了。
七、問題總結
7.1 為什麼hashmap的容量必須是2的n次方
回顧一下計算下標的方法。即計算key在陣列中的索引位。
hash&(n - 1)
其中n
就是hashmap
的容量也就是陣列的長度。
假設n
是奇數。則n
-1就是偶數。偶數二進位制中最後一位一定是0。所以如上圖所示, hash&(n - 1) 最終結果二進位制中最後一位一定是0,也就意味著結果一定是偶數。這會導致陣列中只有偶數位被用了,而奇數位就白白浪費了。無形中浪費了記憶體,同樣也增加了hash碰撞的概率。
其中n是2的n次方保證了(兩個n不一樣哦,別較真)hash更加雜湊,節省了記憶體。
難道不能是偶數嗎?為啥偏偏是2的n次方?
2的n次方能保證(n - 1)低位都是1,能使hash
低位的特徵得以更好的保留,也就是說當hash
低位相同時兩個元素才能產生hash
碰撞。換句話說就是使hash
更雜湊。
呃。。。個人覺得2在程式中是個特殊的數字,通過上文resize
中的關於二進位制的一堆分析也是建立在容量是2的n
次方的基礎上的。雖然這個解釋有點牽強。如果大家有更好的解釋可以在下方留言。
兩層含義:
1,從奇偶數來解釋。
2,從hash
低位的1能使得hash
本身的特性更容易得到保護方面來說。(很類似原始碼中hash
方法中 <<< 16的做法)
7.2 解決hash衝突的方法
hashmap
中解決hash
衝突採用的是鏈地址法,其實就是有衝突了,在陣列中將衝突的元素放到連結串列中。
一般有以下四種解決方案:
1 鏈地址法
2 開放地址法
3 再雜湊法
4 建立公共溢位區
7.3 HashMap、HashTable、ConcurrentHashMap區別
HashMap是不具備執行緒安全性的。
HashTable是通過Synchronized關鍵字修飾每一個方法達到執行緒安全的。效能很低,不建議使用。
ConcurrentHashMap很經典,Java程式設計師必精通。下篇文章就介紹ConcurrentHashMap。該類位於J.U.C併發包中,為併發而生。
7.4 如何保證HashMap的同步
Map map = Collections.synchronizedMap(new HashMap());其實其就是給HashMap
的每一個方法加Synchronized
關鍵字。
效能遠不如ConcurrentHashMap
。不建議使用。
7.5 為什麼引入紅黑樹
這個問題很簡單,因為紅黑樹的時間複雜度表現更好為O(logN),而連結串列為O(N)。
為什麼紅黑樹這麼好還要用連結串列?
因為大多數情況下hash
碰撞導致的單個桶中的元素不會太多,太多也擴容了。只是極端情況下,當連結串列太長會大大降低HashMap
的效能。所以為了應付這種極端情況才引入的紅黑樹。當桶中元素很少比如小於8,維護一個紅黑樹是比較耗時的,因為紅黑樹需要左旋右旋等,也很耗時。在元素很少的情況下的表現不如連結串列。
一般的HashMap
的時間複雜度用平均時間複雜度來分析。除了極端情況連結串列對HashMap
整體時間複雜度的表現影響比較小。
7.6 為什麼樹轉連結串列和連結串列轉樹閾值不同
其實上文中已經介紹了,因為複雜度震盪。詳情請參考上文。
7.7 Capacity的計算
變相問一下這個問題就是當初始化hashMap
時initialCapacity
引數傳的是18,HashMap
的容量是什麼?是32。
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;
}
該方法大意:如果cap
不是2的n
次方則取大於cap
的最小的2的n
次方的值。當然這個值不能超過MAXIMUM_CAPACITY
。具體請參考HashMap之tableSizeFor方法圖解
7.8 為什麼預設的負載因子loadFactor = 0.75
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
上文大意是說 : 因為TreeNodes
是普通節點佔用空間的2倍,僅當有足夠的節點時才會適當地將普通節點轉為TreeNodes
。當桶的元素變得很少時又轉回普通的Node
。當hashCode
離散型很好的時候,樹型bin
很少概率被用到。因為資料均勻分佈在每個桶中,幾乎不會有bin
中連結串列長度達到閾值。但是在隨機的hashCode
的情況下,離散型可能會變差,然而jdk
又不能阻止使用者實現這種不好的hash
演算法,因此就可能導致不均勻的資料分佈。理想的情況下,隨機hashCode
演算法下所有bin
中的節點分佈頻率會遵循泊松分佈,從資料中可以看到,當一個bin
中的連結串列長度達到8個元素時概率為0.00000006,幾乎是不可能事件。所以選8是根據概率統計決定的。
hashmap
預設的loadFactor
是0.75,官網解釋是說泊松分佈算出來的,其實不然,這裡泊松分佈算出來的樹出現的概率,當樹化的閾值是8,載入係數是0.75的時候出現樹化的概率為0.00000006,jdk
開發設計hashmap
的時候,為了平衡樹和連結串列的效能(樹比連結串列遍歷快,但是樹的結點是連結串列結點大小的兩倍,所以當樹出現的概率比較小的時候的價效比就高了,所以取載入係數的時候平衡了下效能取0.75)。平衡效能其實就是"空間利用率"和"時間複雜度"之間的折衷。
- 原註釋的內容和目的都是為了解釋在java8 HashMap中引入Tree Bin(也就是放入資料的每個陣列bin從連結串列node轉換為red-black tree node)的原因
- 原註釋:Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use(see TREEIFY_THRESHOLD).
- TreeNode雖然改善了連結串列增刪改查的效能,但是其節點大小是連結串列節點的兩倍
- 雖然引入TreeNode但是不會輕易轉變為TreeNode(如果存在大量轉換那麼資源代價比較大),根據泊松分佈來看轉變是小概率事件,價效比是值得的
- 泊松分佈是二項分佈的極限形式,兩個重點:事件獨立、有且只有兩個相互對立的結果
- 泊松分佈是指一段時間或空間中發生成功事件的數量的概率
- 對HashMap table[]中任意一個bin來說,存入一個數據,要麼放入要麼不放入,這個動作滿足二項分佈的兩個重點概念
- 對於HashMap.table[].length的空間來說,放入0.75*length個數據,某一個bin中放入節點數量的概率情況如上圖註釋中給出的資料(表示陣列某一個下標存放資料數量為0~8時的概率情況)
- 舉個例子說明,HashMap預設的table[].length=16,在長度為16的HashMap中放入12(0.75*length)個數據,某一個bin中存放了8個節點的概率是0.00000006
- 擴容一次,16*2=32,在長度為32的HashMap中放入24個數據,某一個bin中存放了8個節點的概率是0.00000006
- 再擴容一次,32*2=64,在長度為64的HashMap中放入48個數據,某一個bin中存放了8個節點的概率是0.00000006
所以,當某一個bin
的節點大於等於8個的時候,就可以從連結串列node
轉換為treenode
,其價效比是值得的。
具體可以參考HashMap
的loadFactor
為什麼是0.75
7.9 HashMap中為什麼用位運算而不是取模運算
主要是位運算在底層計算速度更快。
簡單證明一下
long s1 = System.nanoTime();
System.out.println(2147483640 % 16);//8
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(2147483640 & 15);//8
long e2 = System.nanoTime();
System.out.println("取模時間:" + (e1 - s1));//取模時間:134200
System.out.println("與運算時間:" + (e2 - s2));//與運算時間:15800
題外話:還有一個刷leetcode題,二分法計算中心點。總結的經驗,用除法會導致部分演算法題超時。
long s1 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) / 2);//1073741820
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) >> 1);//1073741820
long e2 = System.nanoTime();
System.out.println("除法時間:" + (e1 - s1));//除法時間:20100
System.out.println("位運算時間:" + (e2 - s2));//位運算時間:15700
注意:一般二分法用left + (right - left)/2;因為如果用(right+left)/2;right + left容易>Integer.MAX_VALUE;