HashMap的底層實現以及死迴圈分析
一.HashMap的組成以及資料結構
在 JDK 1.7 中 HashMap 是以陣列加連結串列的形式組成的,JDK 1.8 之後新增了紅黑樹的組成結構,當連結串列大於 8 並且容量大於 64 時,連結串列結構會轉換成紅黑樹結構,它的組成結構如下圖所示:
陣列中的元素我們稱之為雜湊桶,它的定義如下:
staticclassNode<K,V>implementsMap.Entry<K,V>{ finalinthash; finalKkey; Vvalue; Node<K,V>next; Node(inthash,Kkey,Vvalue,Node<K,V>next){ this.hash=hash; this.key=key; this.value=value; this.next=next; } publicfinalKgetKey(){returnkey;} publicfinalVgetValue(){returnvalue;} publicfinalStringtoString(){returnkey+"="+value;} publicfinalinthashCode(){ returnObjects.hashCode(key)^Objects.hashCode(value); } publicfinalVsetValue(VnewValue){ VoldValue=value; value=newValue; returnoldValue; } publicfinalbooleanequals(Objecto){ if(o==this) returntrue; if(oinstanceofMap.Entry){ Map.Entry<?,?>e=(Map.Entry<?,?>)o; if(Objects.equals(key,e.getKey())&& Objects.equals(value,e.getValue())) returntrue; } returnfalse; } }
可以看出每個雜湊桶中包含了四個欄位:hash、key、value、next,其中 next 表示連結串列的下一個節點。
1.1 新增紅黑樹的原因
JDK 1.8 之所以新增紅黑樹是因為一旦連結串列過長,會嚴重影響 HashMap 的效能,而紅黑樹具有快速增刪改查的特點,這樣就可以有效的解決連結串列過長時操作比較慢的問題。
1.1.1 正常情況節點不會到8個
連結串列長度到達8個節點的概率只有億份之六
在HashMap的類的頭部有這樣的原始碼註釋
* 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 * * The root of a tree bin is normally its first node. However, * sometimes (currently only upon Iterator.remove), the root might * be elsewhere, but can be recovered following parent links * (method TreeNode.root()).
二. HashMap成員變數分析
HashMap 原始碼中包含了以下幾個屬性:
//HashMap初始化長度 staticfinalintDEFAULT_INITIAL_CAPACITY=1<<4;//aka16 //HashMap最大長度 staticfinalintMAXIMUM_CAPACITY=1<<30;//1073741824 //預設的載入因子(擴容因子) staticfinalfloatDEFAULT_LOAD_FACTOR=0.75f; //當連結串列長度大於此值且容量大於64時 staticfinalintTREEIFY_THRESHOLD=8; //轉換連結串列的臨界值,當元素小於此值時,會將紅黑樹結構轉換成連結串列結構 staticfinalintUNTREEIFY_THRESHOLD=6; //最小樹容量 staticfinalintMIN_TREEIFY_CAPACITY=
1.什麼是載入因子
載入因子也叫擴容因子或負載因子,用來判斷什麼時候進行擴容的,假如載入因子是 0.5,HashMap 的初始化容量是 16,那麼當 HashMap 中有 16*0.5=8 個元素時,HashMap 就會進行擴容。
那載入因子為什麼是 0.75 而不是 0.5 或者 1.0 呢?
這其實是出於容量和效能之間平衡的結果:
-
當載入因子設定比較大的時候,擴容的門檻就被提高了,擴容發生的頻率比較低,佔用的空間會比較小,但此時發生 Hash 衝突的機率就會提升,因此需要更復雜的資料結構來儲存元素,這樣對元素的操作時間就會增加,執行效率也會因此降低;
-
而當載入因子值比較小的時候,擴容的門檻會比較低,因此會佔用更多的空間,此時元素的儲存就比較稀疏,發生雜湊衝突的可能性就比較小,因此操作效能會比較高。
所以綜合了以上情況就取了一個 0.5 到 1.0 的平均數 0.75 作為載入因子。
HashMap 原始碼中三個重要方法:查詢、新增和資料擴容。
三. HashMap 三個重要方法
1.查詢
先來看查詢原始碼:
publicVget(Objectkey){
Node<K,V>e;
//對key進行雜湊操作
return(e=getNode(hash(key),key))==null?null:e.value;
}
finalNode<K,V>getNode(inthash,Objectkey){
Node<K,V>[]tab;Node<K,V>first,e;intn;Kk;
//非空判斷
if((tab=table)!=null&&(n=tab.length)>0&&
(first=tab[(n-1)&hash])!=null){
//判斷第一個元素是否是要查詢的元素
if(first.hash==hash&&//alwayscheckfirstnode
((k=first.key)==key||(key!=null&&key.equals(k))))
returnfirst;
//下一個節點非空判斷
if((e=first.next)!=null){
//如果第一節點是樹結構,則使用getTreeNode直接獲取相應的資料
if(firstinstanceofTreeNode)
return((TreeNode<K,V>)first).getTreeNode(hash,key);
do{//非樹結構,迴圈節點判斷
//hash相等並且key相同,則返回此節點
if(e.hash==hash&&
((k=e.key)==key||(key!=null&&key.equals(k))))
returne;
}while((e=e.next)!=null);
}
}
returnnull;
}
從以上原始碼可以看出,當雜湊衝突時我們需要通過判斷 key 值是否相等,才能確認此元素是不是我們想要的元素。
2.新增
publicVput(Kkey,Vvalue){
//對key進行雜湊操作
returnputVal(hash(key),key,value,false,true);
}
finalVputVal(inthash,Kkey,Vvalue,booleanonlyIfAbsent,
booleanevict){
Node<K,V>[]tab;Node<K,V>p;intn,i;
//雜湊表為空則建立表
if((tab=table)==null||(n=tab.length)==0)
n=(tab=resize()).length;
//根據key的雜湊值計算出要插入的陣列索引i
if((p=tab[i=(n-1)&hash])==null)
//如果table[i]等於null,則直接插入
tab[i]=newNode(hash,key,value,null);
else{
Node<K,V>e;Kk;
//如果key已經存在了,直接覆蓋value
if(p.hash==hash&&
((k=p.key)==key||(key!=null&&key.equals(k))))
e=p;
//如果key不存在,判斷是否為紅黑樹
elseif(pinstanceofTreeNode)
//紅黑樹直接插入鍵值對
e=((TreeNode<K,V>)p).putTreeVal(this,tab,hash,key,value);
else{
//為連結串列結構,迴圈準備插入
for(intbinCount=0;;++binCount){
//下一個元素為空時
if((e=p.next)==null){
p.next=newNode(hash,key,value,null);
//轉換為紅黑樹進行處理
if(binCount>=TREEIFY_THRESHOLD-1)//-1for1st
treeifyBin(tab,hash);
break;
}
//key已經存在直接覆蓋value
if(e.hash==hash&&
((k=e.key)==key||(key!=null&&key.equals(k))))
break;
p=e;
}
}
if(e!=null){//existingmappingforkey
VoldValue=e.value;
if(!onlyIfAbsent||oldValue==null)
e.value=value;
afterNodeAccess(e);
returnoldValue;
}
}
++modCount;
//超過最大容量,擴容
if(++size>threshold)
resize();
afterNodeInsertion(evict);
returnnull;
}
新增方法的執行流程,如下圖所示:
3.擴容方法
原始碼如下
finalNode<K,V>[]resize(){
//擴容前的陣列
Node<K,V>[]oldTab=table;
//擴容前的陣列的大小和閾值
intoldCap=(oldTab==null)?0:oldTab.length;
intoldThr=threshold;
//預定義新陣列的大小和閾值
intnewCap,newThr=0;
if(oldCap>0){
//超過最大值就不再擴容了
if(oldCap>=MAXIMUM_CAPACITY){
threshold=Integer.MAX_VALUE;
returnoldTab;
}
//擴大容量為當前容量的兩倍,但不能超過MAXIMUM_CAPACITY
elseif((newCap=oldCap<<1)<MAXIMUM_CAPACITY&&
oldCap>=DEFAULT_INITIAL_CAPACITY)
newThr=oldThr<<1;//doublethreshold
}
//當前陣列沒有資料,使用初始化的值
elseif(oldThr>0)//initialcapacitywasplacedinthreshold
newCap=oldThr;
else{//zeroinitialthresholdsignifiesusingdefaults
//如果初始化的值為0,則使用預設的初始化容量
newCap=DEFAULT_INITIAL_CAPACITY;
newThr=(int)(DEFAULT_LOAD_FACTOR*DEFAULT_INITIAL_CAPACITY);
}
//如果新的容量等於0
if(newThr==0){
floatft=(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>[])newNode[newCap];
//開始擴容,將新的容量賦值給table
table=newTab;
//原資料不為空,將原資料複製到新table中
if(oldTab!=null){
//根據容量迴圈陣列,複製非空元素到新table
for(intj=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;
elseif(einstanceofTreeNode)
//紅黑樹相關的操作
((TreeNode<K,V>)e).split(this,newTab,j,oldCap);
else{//preserveorder
//連結串列複製,JDK1.8擴容優化部分
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;
}
//原索引+oldCap
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;
}
//將原索引+oldCap放到雜湊桶中
if(hiTail!=null){
hiTail.next=null;
newTab[j+oldCap]=hiHead;
}
}
}
}
}
returnnewTab;
}
從以上原始碼可以看出,JDK 1.8 在擴容時並沒有像 JDK 1.7 那樣,重新計算每個元素的雜湊值,而是通過高位運算(e.hash & oldCap)來確定元素是否需要移動,比如 key1 的資訊如下:
key1.hash = 10 0000 1010
oldCap = 16 0001 0000
使用 e.hash & oldCap 得到的結果,高一位為 0,當結果為 0 時表示元素在擴容時位置不會發生任何變化,而 key 2 資訊如下:
key2.hash = 10 0001 0001
oldCap = 16 0001 0000
這時候得到的結果,高一位為 1,當結果為 1 時,表示元素在擴容時位置發生了變化,新的下標位置等於原下標位置 + 原陣列長度,如下圖所示:
其中紅色的虛線圖代表了擴容時元素移動的位置。
四. HashMap 死迴圈分析
以 JDK 1.7 為例,假設 HashMap 預設大小為 2,原本 HashMap 中有一個元素 key(5),我們再使用兩個執行緒:t1 新增元素 key(3),t2 新增元素 key(7),當元素 key(3) 和 key(7) 都新增到 HashMap 中之後,執行緒 t1 在執行到 Entry<K,V> next = e.next; 時,交出了 CPU 的使用權,原始碼如下:
voidtransfer(Entry[]newTable,booleanrehash){
intnewCapacity=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);
}
inti=indexFor(e.hash,newCapacity);
e.next=newTable[i];
newTable[i]=e;
e=next;
}
}
}
那麼此時執行緒 t1 中的 e 指向了 key(3),而 next 指向了 key(7) ;之後執行緒 t2 重新 rehash 之後連結串列的順序被反轉,連結串列的位置變成了 key(5) → key(7) → key(3),其中 “→” 用來表示下一個元素。
當 t1 重新獲得執行權之後,先執行 newTalbe[i] = e 把 key(3) 的 next 設定為 key(7),而下次迴圈時查詢到 key(7) 的 next 元素為 key(3),於是就形成了 key(3) 和 key(7) 的迴圈引用,因此就導致了死迴圈的發生,如下圖所示:
當然發生死迴圈的原因是 JDK 1.7 連結串列插入方式為首部倒序插入,這個問題在 JDK 1.8 得到了改善,變成了尾部正序插入。
有人曾經把這個問題反饋給了 Sun 公司,但 Sun 公司認為這不是一個問題,因為 HashMap 本身就是非執行緒安全的,如果要在多執行緒下,建議使用 ConcurrentHashMap 替代,但這個問題在面試中被問到的機率依然很大,所以在這裡需要特別說明一下。