一、HashMap
重要方法原始碼分析
1.put()
方法,即新增元素方法
put()
方法較為複雜的,也是面試中最常問的方法,實現步驟大致如下:
-
計算出需要新增的元素的key的雜湊值;
-
使用該雜湊值結合陣列長度採用位運算
hash&length-1
計算出索引值; -
如果該位置無資料,則直接插入;
-
如果有資料,比較雜湊值是否相等:
不相等:在此索引位置劃出一個節點儲存資料,此為拉鍊法;
相等:發生雜湊衝突,使用連結串列或紅黑樹解決,呼叫equals()比較內容是否相同;
相同:新值覆蓋舊值;
不相同:劃出一個節點直接儲存;
-
如果
size
大於threshold
,進行擴容。
2.put()
方法原始碼:
public V put(K key, V value) {
//呼叫putVal()方法,putVal()呼叫hash(key)
return putVal(hash(key), key, value, false, true);
}
複製程式碼
3.hash()
方法原始碼:
static final int hash(Object key) {
int h;
/**
* 如果key為null,返回0;
* 如果key不為null,計算出key的雜湊值並賦給h,然後與h無符號右移16位後進行按位異或得到最終雜湊值
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-----------------------------------------------------------------------------------------
(h = key.hashCode()) ^ (h >>> 16)的計算過程如下所示:
假設h = key.hashCode()計算出的h為 11111111 11111111 11110000 11101010;
無符號右移,無論是正數還是負數,高位都補0;
^(按位異或運算):相同二進位制數位上相同為0,不同為1。
11111111 11111111 11110000 11101010 //h
^ 00000000 00000000 11111111 11111111 //h >>> 16:
--------------------------------------------------------
11111111 11111111 00001111 00010101 //返回的雜湊值
複製程式碼
在putVal()
方法中使用到了上述hash函式計算的雜湊值。
4.putVal()
方法原始碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//此處省略一千行,如果需要,請自行檢視jdk原始碼
if ((p = tab[i = (n - 1) & hash]) == null) //這裡使用到了上面計算得到的雜湊值
//此處省略一千行,如果需要,請自行檢視jdk原始碼
}
-----------------------------------------------------------------------------------------
利用hash()方法返回的雜湊值計算索引,(n - 1) & hash]);
n為陣列長度,預設為16;
&(按位與運算):相同的二進位制數位都是1,結果為1,否則為0。
00000000 00000000 00000000 00001111 //n - 1 = 15
& 11111111 11111111 00001111 00010101 //hash
--------------------------------------------------------
00000000 00000000 00000000 00000101 //索引為5
複製程式碼
5.為什麼要這樣運算呢?又是無符號右移16位,又是異或,最後還要按位與,這樣不是很麻煩嗎?
這樣做是為了避免發生雜湊衝突。
如果陣列長度n
很小,假設是16
的話,那麼n-1=15
即1111
,這樣的值和雜湊值直接按位與運算,實際上只使用了雜湊值的後4位。如果當雜湊值的高位變化很大,低位變化很小,這樣就很容易造成雜湊衝突了,所以這裡把高低位都利用起來,從而解決了這個問題。
舉例說明這個問題:
key.hashCode()計算出的雜湊值與n - 1直接按位與運算:
11111111 11111111 11110000 11101010 //h
& 00000000 00000000 00000000 00001111 //n - 1 = 15
--------------------------------------------------------
00000000 00000000 00000000 00001010 //索引為10
再儲存一個key.hashCode()計算出的雜湊值,並且高16位變化很大
11000111 10110011 11110000 11101010 //新儲存的雜湊值h,並且高16位變化很大
& 00000000 00000000 00000000 00001111 //n - 1 = 15
--------------------------------------------------------
00000000 00000000 00000000 00001010 //索引仍為10
結論:直接按位與,並且雜湊值的高位變化大,低位變化小甚至不變時,容易出現索引值一樣的情況,進而造成雜湊衝突
複製程式碼
二、HashMap
中的兩個重要方法原始碼分析
1.最難的putVal()
原始碼分析:
transient Node<K,V>[] table; //表示HashMap中的陣列主體
/**
* Implements Map.put and related methods
*
* @param hash hash for key(key的雜湊值)
* @param key the key(key)
* @param value the value to put(新增元素的值)
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/*
1.transient Node<K,V>[] table;表示HashMap中的陣列主體;
2.(tab = table) == null:將空的table賦值給tab,第一次是null,結果為true;
3.(n = tab.length) == 0:表示將陣列的長度0賦值給n,然後判斷n是否等於0,結果為true;
4.由於if使用雙或判斷,一邊為true就為true,所以執行程式碼 n = (tab = resize()).length;
5.n = (tab = resize()).length:呼叫resize方法對陣列進行擴容,並將擴容後的陣列長度賦值給n;
6.執行完 n = (tab = resize()).length 後,陣列tab的每個桶(每個索引位置)都是null。
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
1.i = (n - 1) & hash:計算出索引並賦值給i,即確定元素存放在哪個桶中;
2.p = tab[i = (n - 1) & hash]:將該索引位置上的資料賦值給節點p;
3.(p = tab[i = (n - 1) & hash]) == null:判斷索引位置上的內容是否為null;
4.如果為null,則執行程式碼 tab[i] = newNode(hash, key, value, null);
*/
if ((p = tab[i = (n - 1) & hash]) == null)
//根據鍵值對建立新的節點,並將該節點存入桶中。
tab[i] = newNode(hash, key, value, null);
else {
//執行else,說明tab[i]不等於null,表示這個位置已經有值了。
Node<K,V> e; K k;
/*
1.p.hash == hash:p.hash表示已存在key的雜湊值,hash表示新新增資料key的雜湊值;
2.(k = p.key) == key:將已存在資料的key的地址賦值給k,然後與新新增資料的key的地址進行比較
3.(key != null && key.equals(k)))):執行到這裡說明兩個key的地址值不相等,那麼先判斷後 新增的key是否等於null,如果不等於null再呼叫equals方法判斷兩個key的內容是否相等
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果兩個key的雜湊值相等,並且value值也相等,則將舊的元素整體物件賦值給e,用e來記錄
e = p;
//雜湊值不相等或者key的地址不相等,則判斷p是否為紅黑樹結點
else if (p instanceof TreeNode)
//是紅黑樹幾點,則放入樹中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否則說明是連結串列節點
else {
//是連結串列的話需要遍歷到結尾然後插入(尾插法);採用迴圈遍歷的方式,判斷連結串列中是否有重複的key
for (int binCount = 0; ; ++binCount) {
/*
1.e = p.next:獲取p的下一個元素賦值給e
2.(e = p.next) == null:判斷p.next是否等於null,等於null,說明p沒有下一個元素;那麼此時到達了連結串列的尾部,還沒有找到重複的key,則說明HashMap沒有包含該鍵,則將該鍵值 對插入連結串列中。
*/
if ((e = p.next) == null) {
/*
1.p.next = newNode(hash, key, value, null):建立一個新節點並插入到尾部;
2.這種新增方式也滿足連結串列資料結構的特點,每次向末尾新增新的元素。
*/
p.next = newNode(hash, key, value, null);
/*
1.節點新增完成之後判斷此時節點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將連結串列轉換為紅黑樹;
2.binCount表示for迴圈的初始值,從0開始計數,記錄著遍歷節點的個數;值是0表示第1個節點,1表示第2個節點,以此類推,7就表示第8個節點,8個節點則儲存著9個元素;
3.TREEIFY_THRESHOLD-1 =8-1=7;此時如果binCount的值也是7,則轉換紅黑樹。
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//轉換為紅黑樹
treeifyBin(tab, hash);
//轉化為紅黑樹就跳出迴圈
break;
}
/*
執行到這裡說明e = p.next不是null,不是最後一個元素,繼續判斷連結串列中結點的key值與插入的資料的key值是否相等
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//相等,跳出for迴圈,不用再繼續比較,直接執行下面的if (e != null)語句
break;
p = e;
}
}
/*
為true表示在桶中找到key的雜湊值和key的地址值與插入資料相等的結點;也就是說找到了重複的鍵,所以這裡就是把該鍵的值變為新的值,並返回舊值,使用的是put方法的修改功能。
*/
if (e != null) { // existing mapping for key(存在重複的鍵)
//記錄e的value
V oldValue = e.value;
//如果onlyIfAbsent為false或者舊值為null
if (!onlyIfAbsent || oldValue == null)
//用新值替換舊值
e.value = value;
//訪問後回撥
afterNodeAccess(e);
//返回舊值
return oldValue;
}
}
//記錄修改次數
++modCount;
//判斷實際大小是否大於threshold閾值,如果大於則擴容
if (++size > threshold)
//擴容
resize();
// 插入後回撥
afterNodeInsertion(evict);
return null;
}
複製程式碼
2.將連結串列轉換為紅黑樹的treeifyBin()
方法原始碼分析:
連結串列什麼時候轉化為紅黑樹?在putVal()
方法中給出了答案:
//當連結串列長度大於閾值8時轉化為紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//轉換為紅黑樹,tab表示陣列名,hash表示雜湊值
treeifyBin(tab, hash);
複製程式碼
treeifyBin()
方法原始碼分析:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
/*
1.如果當前陣列為空或者陣列的長度小於64(MIN_TREEIFY_CAPACITY = 64),則進行去擴容,而不是將連結串列變為紅黑樹;
2.原因:如果陣列很小就轉換紅黑樹,遍歷效率要低一些(紅黑樹結構複雜);這時進行擴容,重新計算雜湊值,將資料重新分配到陣列主體中,連結串列長度有可能就變短了,這樣做相對來說效率高一些。
*/
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
//擴容方法
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
/*
1.執行到這裡說明雜湊表中的陣列長度大於64,開始由連結串列轉化為紅黑樹;
2.e = tab[index = (n - 1) & hash]表示將陣列中的元素取出賦值給e,e是雜湊表中指定位置桶裡的連結串列節點,從第一個開始;
3.這裡hd表示紅黑樹的頭結點,tl表示紅黑樹的尾結點,預設都為null。
*/
TreeNode<K,V> hd = null, tl = null;
do {
//新建立一個樹節點,內容和當前連結串列節點e一致
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
//將新創鍵的樹節點p賦值給紅黑樹的頭結點
hd = p;
else {
/*
1.p.prev = tl:將上一個節點p賦值給現在的p的前一個節點;
2.tl.next = p:將現在節點p作為樹的尾結點的下一個節點。
*/
p.prev = tl;
tl.next = p;
}
tl = p;
/*
e = e.next:將當前節點的下一個節點賦值給e,如果下一個節點不等於null,則回到上面繼續取出連結串列中節點轉換為紅黑樹
*/
} while ((e = e.next) != null);
/*
讓桶中的第一個元素即陣列中的元素指向新建的紅黑樹的節點,以後這個桶裡的元素就是紅黑樹而不是連結串列了,至此,連結串列轉化紅黑樹完成
*/
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
複製程式碼
上述操作一共做了如下三件事:
1.根據雜湊表中元素個數確定是擴容還是樹形化;
2.如果是樹形化遍歷桶中的元素,建立相同個數的樹形節點,複製內容,建立起聯絡;
3.然後讓桶中的第一個元素指向新建立的樹根節點,替換桶的連結串列內容為樹形化內容。
三、HashMap
的resize()
擴容方法
1.首先,什麼時候開始擴容?
當HashMap
中的元素個數超過n(陣列長度)*loadFactor(負載因子)
時,就會進行陣列擴容。n
的預設值為16
,loadFactor
的預設值是0.75
,那麼當HashMap
中的元素個數超過16×0.75=12
(邊界值threshold
)時,就把陣列的大小擴大為原來的2倍,即32,然後重新計算每個元素在陣列中的位置,而這是一個非常耗效能的操作,所以如果我們已經知道HashMap
中元素的個數,那麼使用HashMap
的有參構造指定初始化大小是一個不錯的選擇。
說明:
當HashMap
中的一個連結串列長度大於8時,但陣列長度沒有達到64,那麼HashMap
會先擴容解決,如果已經達到了64,那麼這個連結串列會變為紅黑樹,節點型別由Node變成TreeNode型別。當然,如果移除元素使紅黑樹的節點個數小於6時,也會再把紅黑樹轉換為連結串列。
2.擴容的實質是什麼?
說白了,擴容就是一個rehash
的過程,即重新計算HashMap
中元素的位置並分配到擴容後的HashMap
中。
在JDK1.8之後,HashMap對resize()
方法進行了優化,使用到了非常巧妙的rehash
方式進行索引位置的計算。
下面分析一下這個rehash
方式怎麼巧妙?
我們知道,HashMap在擴容的時候,總是擴大為原來的兩倍,這樣的話,與原始HashMap相比,擴容後計算的索引只是比原來的索引多了一個bit位(二進位制位);
所以:擴容後的索引要麼為原來的索引,要麼變為原索引+舊容量。
如果沒有看明白這句話,請看下面的例子:
例如我們將HashMap由原來的16擴充套件為32,變化前後索引的計算過程如下所示:
索引計算公式:index=(n-1) & hash;按位與運算:相同二進位制位都為1,結果為1,否則為0
hash1(key1): 11111111 11111111 00001111 00000101;
hash2(key2): 11111111 11111111 00001111 00010101;
原HashMap容量n=16, 二進位制表示為: 00000000 00000000 00000000 00010000;
擴容後HashMap容量n=32,二進位制表示為: 00000000 00000000 00000000 00100000;
-----------------------------------------------------------------------------------------
原HashMap的key1索引:
00000000 00000000 00000000 00001111 //n-1=16-1=15
& 11111111 11111111 00001111 00000101 //hash1(key1)
------------------------------------------------------
00000000 00000000 00000000 00000101 //索引為5
原HashMap的key2索引:
00000000 00000000 00000000 00001111 //n-1=16-1=15
& 11111111 11111111 00001111 00010101 //hash2(key2)
------------------------------------------------------
00000000 00000000 00000000 00000101 //索引為5
結果:key1和可以key2的索引都為5;
-----------------------------------------------------------------------------------------
擴容後的HashMap的key1索引:
00000000 00000000 00000000 00011111 //n-1=32-1=31
& 11111111 11111111 00001111 00000101 //hash1(key1)
------------------------------------------------------
00000000 00000000 00000000 00000101 //索引為5
原HashMap的key2索引:
00000000 00000000 00000000 00011111 //n-1=32-1=31
& 11111111 11111111 00001111 00010101 //hash2(key2)
------------------------------------------------------
00000000 00000000 00000000 00010101 //索引為5+16
結果:key1的索引為5;key2的索引為 16+5,即為原索引+舊容量
複製程式碼
由上面的推理過程可以得出這樣的結論:
元素在重新計算雜湊值後,因為n變為2倍,那麼n-1的標記範圍在高位多1bit
(紅色),因此新的index就會發生這樣的變化:
即紅色所示的高位為0,還是原來的索引位置;為1,索引變為原索引+舊容量。
因此,在擴容HashMap
時,不需要重新計算雜湊值,只需要看原來的雜湊值新增的那個bit是1還是0就可以了,是0的話索引不變,是1的話索引變成“原索引+oldCap(原位置+舊容量)”,具體可以看下面16擴容32的示意圖:
正是因為這樣巧妙的rehash
方式,省去了重新計算雜湊值的時間,而且由於新增的1bit
是0還是1可以認為是隨機的,在resize
的過程中保證了rehash
之後每個桶上的節點數一定小於等於原來桶上的節點數,保證了rehash之後不會出現更嚴重的雜湊衝突,均勻地把之前衝突的節點分散到新的桶中了。
3.擴容方法resize()
原始碼分析:
看完了上面的擴容原理,再來看原始碼會容易些。
不要看這個方法長,覺得難就看不下去了,靜下心,好好分析完,對自己的技術肯定有提升,我們開始吧!
final Node<K,V>[] resize() {
//得到當前陣列
Node<K,V>[] oldTab = table;
//如果當前陣列為null返回0,否則返回當前陣列長度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//當前邊界值,預設是12(16*0.75)
int oldThr = threshold;
int newCap, newThr = 0;
//如果舊陣列長度大於0,則開始計算擴容後的大小
if (oldCap > 0) {
//如果舊陣列長度大於最大值,就不再擴容,就只好隨你碰撞去吧!
if (oldCap >= MAXIMUM_CAPACITY) {
//修改邊界值為Integer資料型別的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
/*
沒超過最大值,就擴充為原來的2倍;
1.(newCap = oldCap << 1) < MAXIMUM_CAPACITY:擴大到2倍之後容量是否小於最大容量
2.oldCap >= DEFAULT_INITIAL_CAPACITY:舊陣列長度是否大於等於陣列初始化長度16
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//舊邊界值左移一位,相當於擴大一倍
newThr = oldThr << 1; // double threshold
}
//舊邊界值大於0則直接賦值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//計算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//新的邊界值原來預設是12,擴大一倍變為24
threshold = newThr;
//建立新的雜湊表
@SuppressWarnings({"rawtypes","unchecked"})
//newCap是擴容後的陣列長度32
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) {
//將舊陣列中的資料都置為null,便於GC回收
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 {
//不是紅黑樹,則採用連結串列處理衝突
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//通過上述講解的原理來計算節點的新位置
do {
//原索引
next = e.next;
//如果為true,則e這個節點在擴容後還是原索引位置,說明高位為0
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//原索引+舊容量,說明高位為1
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;
}
複製程式碼
四、總結
HashMap
的底層原始碼算是JDK
原始碼中設計最複雜同樣也最優秀的原始碼了,如果能研究、理解了HashMap
的原始碼,相信JDK
的其他原始碼對你來說也不是什麼問題了。
都看到這裡了,給個贊再走吧!哈哈哈!!!