1. 程式人生 > 其它 >HashMap的底層實現以及死迴圈分析

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 替代,但這個問題在面試中被問到的機率依然很大,所以在這裡需要特別說明一下。