前言
????HashMap在java程序中使用頗為頻繁,因此掌握HashMap的底層實(shí)現(xiàn)顯得格外重要。相信大家也知道HashMap是非線程安全的,在多線程環(huán)境下應(yīng)該避免使用HashMap,而應(yīng)該使用線程安全的ConcurrentHashMap。那么HashMap在多線程環(huán)境下到底有什么問(wèn)題,為什么存在這樣的問(wèn)題,以及jdk1.6及1.8以上實(shí)現(xiàn)的差異在哪?本文圍繞這幾個(gè)問(wèn)題展開,逐一解答所有疑問(wèn)。
HashMap底層數(shù)據(jù)結(jié)構(gòu)及問(wèn)題分析
jdk1.6
????jdk1.6的HashMap底層數(shù)據(jù)結(jié)構(gòu)相當(dāng)簡(jiǎn)單,也是大家比較熟悉的數(shù)據(jù)結(jié)構(gòu)---數(shù)組+單向鏈表。jdk1.6通過(guò)數(shù)組和鏈表的組合以拉鏈法的方式實(shí)現(xiàn)HashMap。結(jié)構(gòu)如下圖所示:
????key位置確定方式為pos=hashcode(key) % len,所有沖突的key通過(guò)單鏈表串起來(lái)。當(dāng)HashMap的元素個(gè)數(shù)超過(guò)某個(gè)負(fù)載值(由負(fù)載因素決定)時(shí),會(huì)進(jìn)行擴(kuò)容,并將所有元素rehash后放置在擴(kuò)容后的哈希表中,jdk1.6的擴(kuò)容邏輯如下:
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
這段代碼邏輯不難,這里簡(jiǎn)單解讀下上述代碼做的事情:遍歷舊哈希表中所有單鏈表,找到元素e在新哈希表的位置,然后將其插入到該位置對(duì)應(yīng)鏈表的頭部。循環(huán)完后,所有的元素都搬到新哈希表中。由此可以看出,這里采用的是頭插入法,這樣每次插入時(shí)不用遍歷鏈表,提高了性能。但是這里存在一個(gè)潛在的風(fēng)險(xiǎn):在多線程環(huán)境下,容易出現(xiàn)循環(huán)引用導(dǎo)致死循環(huán)。下面具體分析下這種場(chǎng)景。
????假設(shè)有兩個(gè)線程A、B同時(shí)對(duì)同一個(gè)HashMap進(jìn)行插值操作,并且HashMap容量已到達(dá)閾值需要擴(kuò)容。這時(shí),便會(huì)出現(xiàn)并發(fā)擴(kuò)容的情況。假設(shè)線程A先執(zhí)行到Entry<K,V> next = e.next;此時(shí)A線程對(duì)應(yīng)哈希表的狀態(tài)如下:
接著,操作系統(tǒng)將A線程掛起,B線程被調(diào)度執(zhí)行,并執(zhí)行完do while循環(huán)中的所有指令,此時(shí)B線程對(duì)應(yīng)哈希表狀態(tài)為:
我們可以看到,B執(zhí)行完一把while循環(huán)后,12和20的指向恰好反過(guò)來(lái)了,這正是頭插入法的效果。現(xiàn)在20的下一個(gè)元素是12。這時(shí),如果線程A再次被調(diào)度執(zhí)行,那么意外就出現(xiàn)了,因?yàn)榇藭r(shí)在A線程看來(lái)e指向的是12,此時(shí)執(zhí)行e.next = newTable[i];那么其實(shí)就是將12的下一個(gè)元素再次指向了20,此時(shí)的轉(zhuǎn)態(tài)如下:
從上圖可以看出,12的next引用20,20的next引用12,導(dǎo)致死循環(huán)。
jdk1.8
????上一小節(jié)我們分析了jdk1.6的HashMap在并發(fā)擴(kuò)容時(shí)會(huì)導(dǎo)致死循環(huán),本小節(jié)我們來(lái)看看jdk1.8中HashMap的擴(kuò)容時(shí)如何實(shí)現(xiàn)的。和jdk1.6中的HashMap類似,底層數(shù)據(jù)結(jié)構(gòu)也是采用拉鏈法實(shí)現(xiàn)的hash表,不同的是當(dāng)鏈表的元素個(gè)數(shù)達(dá)到8時(shí),鏈表會(huì)轉(zhuǎn)換為紅黑樹以提高查詢的性能。本文的重點(diǎn)是分析擴(kuò)容問(wèn)題,因此對(duì)于紅黑樹暫且不討論(網(wǎng)上一搜一大把)。jdk1.8擴(kuò)容的邏輯如下:
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; // double 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) {
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;
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;
}
代碼有點(diǎn)長(zhǎng),我們只關(guān)心主要擴(kuò)容執(zhí)行部分--for循環(huán)里。jdk1.8這塊的實(shí)現(xiàn)邏輯比1.6繞,很多同學(xué)一開始很難理清楚,這里簡(jiǎn)單解釋下:和1.6一樣也是遍歷原始hash表中所有鏈表,不同的是,在1.8中不是每遍歷一個(gè)元素就往新hash表中插,而是提前將一個(gè)鏈表中的元素分別組織成低槽位和高槽位的鏈表,然后再分別將兩個(gè)鏈表插入到新hash表中對(duì)應(yīng)的位置。這里比較難理解的是一個(gè)單鏈表為何能拆成兩個(gè)不同的鏈表,且保證鏈表中的元素確實(shí)在同一槽位?這里做個(gè)論證,假設(shè)有個(gè)長(zhǎng)度為len的hash表,那么第i槽位對(duì)應(yīng)的key的hash值只可能為i、len+i、2*len+i、... 、n*len+i。由于HashMap擴(kuò)容是翻倍的,即擴(kuò)容到原來(lái)的2倍,因此擴(kuò)容后的長(zhǎng)度為2*len,那之前舊表中第i槽位的元素rehash后在新表中的槽位為:i%2len、(len+i)%2len、(2*len+i)%2len、... 、(n*len+i)%2n,很明顯(k*len+i)%2len(k為偶數(shù))的值為i,(k*len+i)%2len(k為奇數(shù))的值為i+len,所以rehash后舊表中第i槽位的key會(huì)落在新表第i或者第i+len槽位,這就解釋了為什么舊表中一個(gè)鏈表會(huì)拆分為兩個(gè)鏈表。還是以上面的HashMap為例1.8擴(kuò)容后的結(jié)果如下:
可以看出,擴(kuò)容后12和20依然在同一槽位,而且順序也沒(méi)變,由于不像1.6使用頭插入法, 因此在多線程場(chǎng)景下不會(huì)出現(xiàn)死循環(huán)。那是不是1.8的HashMap就線程安全了呢?答案是否定的,do while循環(huán)進(jìn)行鏈表拼接時(shí),由于不是原子操作,會(huì)導(dǎo)致拼接覆蓋的情況,導(dǎo)致數(shù)據(jù)丟失。
小結(jié)
????本文主要介紹了HashMap在多線程環(huán)境下導(dǎo)致的問(wèn)題及其原因,歡迎大家溝通交流。