HashMap全方位剖析
常見HashMap面試問答
HashMap是不是有序的?
不是有序的。
有沒有有序的Map實現(xiàn)類?
TreeMap和linkedHashMap。
TreeMap和LinkedHashMap是如何保證它的順序的?
TreeMap是通過實現(xiàn)SortMap接口,能夠把它保存的鍵值對根據(jù)key排序,基于紅黑樹,從而保證TreeMap中所有鍵值對處于有序狀態(tài)。LinkedHashMap則是通過插入排序(就是PUT的時候的順序是什么,取出來的時候就是什么順序)和訪問順序(改變排序把訪問過的放到底部)讓鍵值有序。
TreeMap和LinkedHashMap的有序?qū)崿F(xiàn)那個更好呢?
- 為什么要用HashMap?
- HashMap是一個散列桶(數(shù)組和鏈表),它存儲的內(nèi)容是鍵值對key-value映射
- HashMap采用了數(shù)組和鏈表的數(shù)據(jù)結(jié)構(gòu),能在查詢和修改方便繼承了數(shù)組的線性查找和鏈表的尋址修改
- HashMap是非synchronized,所以HashMap很快
- HashMap可以接受null鍵和值,而HashTable則不能(原因就是equals() 方法需要對象,因為HashMap是活出的API經(jīng)過處理才可以)
- HashMap的工作原理是什么?
-
HashMap 是基于hashing的原理
當(dāng)我們使用put(key,value)存儲對象到hashMap中,使用get(key)從HashMap中獲取對象。當(dāng)我們給put()方法傳遞鍵和值時,我們先對鍵調(diào)用HashCode()方法,計算并返回的HashCode 是用于找到Map 數(shù)組的bucket位置來存儲Node對象。
這里關(guān)鍵點在于指出,HashMap是在bucket中儲存鍵對象和值對象,作為Map.Node。
bucket模型圖
HashMap的簡化的模擬數(shù)據(jù)結(jié)構(gòu):
Node[] table = new Node[16];//散列桶初始化,Table
class Node {
hash; //hash值
key; //鍵
value; //值
node next; //用于指向鏈表的下一層(產(chǎn)生沖突,使用拉鏈法)
}
以下是具體的put過程(1.8)
- 對key求Hash值,然后再計算下標(biāo)
- 如果沒有碰撞,直接放入桶中(碰撞的意思是計算得到的Hash值相同,需要放到一個bucket中)
- 如果碰撞發(fā)生了,以鏈表的方式鏈接到后面
- 如果鏈表長度超過閾值(TREEIFY THRESHOLD==8),就把鏈表轉(zhuǎn)成紅黑樹,鏈表長度低于6,就把紅黑樹轉(zhuǎn)回鏈表
- 如果節(jié)點已經(jīng)存在就替換舊值
- 如果桶滿了(容量16*加載因子0.75),就需要resize(擴(kuò)容2倍后重排)
以下是具體get過程
考慮特殊情況:如果兩個鍵的HashCode相同,如何獲取值對象?
當(dāng)我們調(diào)用get()方法,HashMap會使用鍵對象的HashCode找到bucket位置,找到bucket位置之后,會調(diào)用keys.equals()方法去找到鏈表中正確的節(jié)點,最終找到要找的對象。
- 有什么方法可以減少碰撞?
- 擾動函數(shù)可以減少碰撞
原理:兩個不相等的對象返回不同的HashCode,碰撞可能發(fā)生的機(jī)會小一些。同時也意味著存鏈表結(jié)構(gòu)減小,使用GET方法取值的話就不會頻繁調(diào)用equal()方法,從而提高HashMap的性能,擾動函數(shù)的作用就是Hash()方法內(nèi)部的算法實現(xiàn),目的是讓不同對象返回不同的HashCode。
使用不變的、聲明做final對象,并且采用合適的equals()和HashCode()方法,將會減少碰撞發(fā)生
不可變性使得能夠魂村不同鍵的HashCode,這將提高整個獲取對象的速度,使用String、Integer這樣的wrapper類作為鍵是非常好的選擇。為什么String、Integer這樣的wrapper類適合作為鍵?
因為String是常量類final類型的,在內(nèi)部實現(xiàn)過程中已經(jīng)重寫equals()和HashCode()方法,常量類型不可變性作為前提條件,要計算HashCode(),必須保證鍵值改變,如果鍵值在put和get時返回的HashCode不同,就會導(dǎo)致HashMap中找不到相應(yīng)的對象。
- HashMap 中hash函數(shù)是如何實現(xiàn)的?
HashMap中想要找到某個元素,需要根據(jù)key的hash值來找到對應(yīng)數(shù)據(jù)中具體的位置。
由于HashMap的數(shù)據(jù)結(jié)構(gòu)是數(shù)組和鏈表結(jié)合,一般情況我們希望HashMap中的元素位置盡可能均勻分布,最好的情況就是每個位置上只有一個元素。這樣使用hash算法計算這個位置的時候,立刻就能知道對應(yīng)位置的元素是我們需要的,同時也不用去遍歷鏈表。因此,首先需要把HashCode對數(shù)組長度取模運算,這樣的好處就是元素分布的相對均勻。
但由于取模運算消耗相對比較大,有沒有更快速、消耗更小的方式處理,通過讀JDK1.8源碼了解到如下:
Jdk源碼hash()方法
static final int hash(Object key){
if(key == null){
return 0;
}
int h;
h = key.hashCode();返回散列值也就是HashCode
return (n-1)&(h ^ (h >>> 16));
}
h = hashCode(): 1111 1111 1111 1111 1111 0000 1110 1010
調(diào)用hashCode()
h: 1111 1111 1111 1111 1111 0000 1110 1010
h >>> 16: 0000 0000 0000 0000 1111 1111 1111 1111
計算Hash
hash = h ^(h >>> 16): 1111 1111 1111 1111 0000 1111 0001 0101
(n-1) & hash: 0000 0000 0000 0000 0000 0000 0000 1111
1111 1111 1111 1111 1111 1111 0001 0101
計算下標(biāo)
0101 = 5
簡單來說就是:
- 高16bit不變,低16bit做了一個異或(得到的HashCode轉(zhuǎn)化為32位二進(jìn)制,前16位和后16位低16bit和高16bit做了一個異或)
- (n-1)&hash = 得到下標(biāo)
- 拉鏈法導(dǎo)致的鏈表過深,選擇紅黑樹的好處是什么,為什么不用二叉樹代替?但在鏈表長度比較短的時候又不選擇使用紅黑樹?
不選擇二叉樹的原因就是二叉查找樹在特殊情況下會變成一條線性結(jié)構(gòu)(就跟原有鏈表結(jié)構(gòu)是相同的,造成了很深的層次),遍歷查找會非常慢。而紅黑樹在插入新數(shù)據(jù)后可以通過使用左旋、右旋、變色這些操作來保持平衡。引入紅黑樹為了查找數(shù)據(jù)快,解決鏈表查詢深度的問題。我們知道紅黑樹屬于平衡二叉樹,為了保持“平衡”是需要付出代價的,但是該代價所消耗的資源要比遍歷線性鏈表要少,所以在長度比較短的情況下,根本不需要引入紅黑樹,移入反而更慢;但是在長度大于閾值8的情況下,選擇使用紅黑樹更快。
6. 對紅黑樹的認(rèn)知
- [ ] 每個節(jié)點非紅即黑
- [ ] 根節(jié)點的顏色總是黑色的
- [ ] 如果節(jié)點是紅色的,則它的每個子節(jié)點必須是黑色的(反之不一定)
- [ ] 每個葉子節(jié)點都是黑色的空節(jié)點(NIL節(jié)點)
- [ ] 從根節(jié)點到葉節(jié)點或空子節(jié)點的每條路徑,必須包含相同數(shù)目的黑色節(jié)點(即相同的黑色高度)
7. 解決hash碰撞的更多方法?
-
開放定址法
當(dāng)沖突發(fā)生時,使用某種探測技術(shù)在散列表中形成一個探查序列。沿此探測序列逐個單元的去查找,知道找到給定的地址。按照形成探查序列的方法不同,可將開放定址法區(qū)分為限行探測方法、二次探測法、雙重散列法等。
下面給一個限行探查法的例子:
問題:已知一組關(guān)鍵字為(26、36、41、38、44、15、68、12、06、51),用除余法因子是13的散列函數(shù)計算出的上述關(guān)鍵字的散列表。
為了減少沖突,通常令裝填因子α由除余法因子是13的散列函數(shù)計算出的上述關(guān)鍵字序列的散列地址為(0,10,2,12,5,2,3,12,6,12)
前5個關(guān)鍵字插入時,其相應(yīng)的地址均為開放地址,故將他們直接插入T[0]、T[10]、T[2]、T[12]和T[5]中。
當(dāng)插入第5個關(guān)鍵字15時,其散列地址2(即h(15)= 15%13=2)已被關(guān)鍵字41(15和41互為同義詞)占用。探查到h1(2+1)%13=3,此地址開放,所以講15放入T[3]中。
當(dāng)插入第7個關(guān)鍵字68時,散列地址3已被非同義詞15先占用,故將其插入到T[4]中。
當(dāng)插入第8個關(guān)鍵字12時,散列地址12已被同義詞38占用,故探查h1=(2+3)%13=0,而T[0]也被26占用
再探查h2=(12+2)%13=1,此地址開放,可將12插入其中。
類似的,第9個關(guān)鍵字06直接插入T[6]中;而最后一個關(guān)鍵字51插入時,因探測地址12,0,1,。。。6均為非空,故51插入T[7]中。
8.如果HashMap的大小超過了負(fù)載因子(load factor)定義的容量怎么辦?
HashMap默認(rèn)的負(fù)載因子大小為0.75.也就是說,當(dāng)一個Map填滿75%的bucket時候,和其他集合類一樣(比如ArrayList),將會創(chuàng)建原來HashMap大小的兩倍的bucket數(shù)組來重新調(diào)整Map大小,并將原來的對象放入新的bucket數(shù)組中。這個過程叫做rehashing。
因為它調(diào)用hash方法找到新的bucket位置。這個值只可能在兩個地方,一個是原下標(biāo)的位置,另一種是在下標(biāo)為<原下標(biāo)+原容量>的位置。
9.重新調(diào)整HashMap大小存在什么問題
重新調(diào)整HashMap的大小的時候,確實存在條件競爭。
因為如果兩個線程都發(fā)現(xiàn)HashMap需要重新調(diào)整大小了,他們會同時試著調(diào)整大小。在調(diào)整大小的過程中,存儲在鏈表中的元素的次序有可能會反過來。因為移動到新的bucket位置的時候,HashMap并不會將元素放在鏈表的尾部,而是放在頭部。是由于避免尾部遍歷(tail traversing)。如果條件競爭發(fā)生了,那么久死循環(huán)了。多線程的環(huán)境下不使用HashMap。
為什么多線程會導(dǎo)致死循環(huán),它是怎么發(fā)生的?
HashMap的容量是有限的。當(dāng)經(jīng)過多次元素插入,使得HashMap達(dá)到一定飽和度時,key映射位置發(fā)生沖突的幾率會逐漸提高。這時候,HashMap需要擴(kuò)展它的長度,也就是進(jìn)行Resize.
- 擴(kuò)容:創(chuàng)建一個新的Entry空數(shù)組,長度是原數(shù)組的2倍
- rehash:遍歷原來的Entry數(shù)組,把所有的Entry重新Hash到新數(shù)組
10.HashTable
- 數(shù)組 + 鏈表方式存儲
- 默認(rèn)容量:11(質(zhì)數(shù)為宜)
- put操作:首先進(jìn)行所以計算(key.hashCode()&0x7FFFFFFF)%table.length;若在鏈表中找到了,則替換舊值,若未找到則繼續(xù);當(dāng)總元素個數(shù)超過容量 * 加載因子時,擴(kuò)容為原來2倍并重新散列;將新元素加到鏈表頭部
- 對修改HashTable內(nèi)部貢獻(xiàn)數(shù)據(jù)的方法添加了synchronized,保證線程安全
11.HashMap與HashTable區(qū)別
- 默認(rèn)容量不一樣,擴(kuò)容不同
- 線程安全性:HashTable安全
- 效率不同:HashTable要慢,因為加鎖
12.可以使用CocurrentHashMap代替HashTable嗎?
- HashTable是synchronized的,但是ConcurrentHashMap同步性能更好,因為它不僅僅根據(jù)同步級別對map的一部分進(jìn)行上鎖
- ConcurrentHashMap當(dāng)然可以代替HashTable,但是HashTable提供更強的線程安全性
- ConcurrentHashMap和HashTable都可以用于多線程環(huán)境,但是當(dāng)HashTable的大小增加到一定的時候,性能會急劇下降,因為迭代時需要鎖定很長時間。由于ConcurrentHashMap引入了分割(segmentation),不論它變得多么大,僅僅需要鎖定Map的某個部分,其他的線程不需要等到迭代完成才能訪問Map。簡而言之,在迭代的過程中,ConcurrentHashMap僅僅鎖定Map的某個部分,而HashTable則會鎖定整個Map
13.ConcurrentHashMap(jdk 1.7)
- ConcurrentHashMap由Segment數(shù)組和hashEntry數(shù)組和鏈表組成
- Segment是基于重入鎖(ReentrantLock):一個數(shù)據(jù)段競爭鎖。每個HashEntry一個鏈表結(jié)構(gòu)的元素,利用Hash算法得到索引確定歸屬的數(shù)據(jù)段,也就是對應(yīng)到在修改時需要競爭獲取的鎖。ConcurrentHashMap支持CurrencyLevel(segment數(shù)組數(shù)量)的線程并發(fā)。每當(dāng)一個線程占用鎖訪問一個segment時,不會影響到其他segment
- 核心數(shù)據(jù)如Value,以及鏈表都是volatile修飾的,保證了獲取時的可見性
- 首先是通過key定位到segment,之后對應(yīng)的segment中進(jìn)行具體的put操作如下:
- 將當(dāng)前segment中的Table通過可以的HashCode定位到hashEntry
- 遍歷該hashEntry,如果不為空則判斷傳入的key 和當(dāng)前遍歷的key是否相等,相等則覆蓋舊的Value
- 不為空則需要新建一個HashEntry并加入到Segment中,同時會先判斷是否需要擴(kuò)容
- 最后會接觸在1中所獲取當(dāng)前的Segment的鎖
- 雖然hashEntry中的Value是用volatile關(guān)鍵詞修飾的,但是并不能保證并發(fā)的原子性,所以put操作時仍然需要加鎖處理
首先第一步的時候會嘗試獲取鎖,如果獲取失敗肯定就會有其他線程存在競爭,則利用scanAndLockForPut()自旋獲取鎖
- 嘗試自旋獲取鎖
- 如果重試的次數(shù)達(dá)到了MAX_SCAN_RETRIES則改為阻塞鎖獲取,保證能獲取成功。最后解除當(dāng)前Segment的鎖
13.ConcurrentHashMap(jdk1.8)
ConcurrentHashMap拋棄了Segment分段鎖,采用了CAS + synchronized來保證并發(fā)安全性。其中val next都用了volatile修飾,保證了可見性。
最大特點是引入了CAS
借助Unsafe來實現(xiàn)native code.CAS有3個操作數(shù),內(nèi)存值V、舊的預(yù)期值A(chǔ)、要修改的新值B。當(dāng)且僅當(dāng)預(yù)期值A(chǔ)和內(nèi)存值V相同時,將內(nèi)存值V修改為B,否則什么都不做,Unsafe借助CPU指令cmpxchg來實現(xiàn)。
CAS使用實例
對sizeCtl的控制都是用CAS來實現(xiàn)的:
- -1代表table正在初始化
- N表示有-N-1個線程正在進(jìn)行擴(kuò)容操作
- 如果table未初始化,表示table需要初始化的大小
- 如果table初始化完成,表示table的容量,默認(rèn)是table大小的0.75倍,用這個公式算0.75(n-(n>>>2))
CAS會出現(xiàn)的問題:ABA
解決方案:對變量增加一個版本號,每次修改,版本號加1,比較的時候比較版本號
PUT過程
- 根據(jù)key計算HashCode
- 判斷是否需要進(jìn)行初始化
- 通過key定位出的Node,如果為空表示當(dāng)前位置可以寫入數(shù)據(jù),利用CAS嘗試寫入,是被則自旋保證成功
- 如果當(dāng)前位置的HashCode == MOVED == -1,則需要進(jìn)行擴(kuò)容
- 如果不滿足,則利用synchronized鎖寫入數(shù)據(jù)
- 如果數(shù)量大于TREEIFY-THRESHOLD則要轉(zhuǎn)換為紅黑樹
GET過程
- 根據(jù)計算出來的HashCode尋址,如果就在桶上那么直接返回值
- 如果是紅黑樹那就按照樹的方式獲取值
- 就不滿足那就按照鏈表的方式遍歷獲取值
結(jié)尾
ConcurrentHashMap在Java8中存在一個bug會進(jìn)入死循環(huán),原因是遞歸創(chuàng)建ConcurrentHashMap對象,但是在JDK1.9已經(jīng)修復(fù)了,具體案例如下代碼:
public class ConcurrentHashMapDemo{
private Map<Integer,Integer> cache = new ConcurrentHashMap<>(15);
public static void main(String[] args){
ConcurrentHashMapDemo ch = new ConcurrentHashMapDemo();
System.out.println(ch.fibonaacci(80));
}
public int fibonaacci(Integer i){
if(i == 0 || i == 1){
return i;
}
return cache.computeIfAbsent(i,(key) ->{System.out.println("fibonaacci : "+ key);
return fibonaacci(key -1)+fibonaacci(key -2);
});
}
}