JDK8-HashMap源碼分析


HashMap vs HashTable

         HashTable如果插入key/value為null的值時,會報錯,但是hashmap不會,在hashmap中,null是作為第0個元素的,相當於是做了特殊化處理。

         前者是非線程安全的,后者是線程安全的. 后者線程安全的原因就是因為后者的每一個方法上都有一個synchronized,這樣雖然保障了線程安全,但是每次都要鎖整個class對象,並且還會阻止其他synchronize方法的訪問,所以效率低下!  HashTable已經廢棄了,盡量別使用了.

         所以綜上所述,HashTable效率低的原因是因為所有訪問它的線程都必須競爭同一把鎖,如果容器里面不同的數據有多把鎖,那么執行的效率就高了,所以ConcurrentHashMap使用的就是鎖分段的技術.

 

JDK1.8之后,HashMap和之前1.7不同的是,不再單純的由數組+鏈表的方式實現的(所謂的鏈地址法).  1.7由於在Hash沖突的時候,在桶上形成的鏈表會越來越長,這樣在查詢的時候效率就會變低,1.8之后改成了由紅黑樹實現.即:

 

 

所以,JAVA8中的HashMap是由: 數組+鏈表/紅黑樹組成

 

         Java7使用Entry來表示每個HashMap中的數據節點,8中使用了Node,基本沒什么區別,都是key,value,hash和next這四個屬性來修飾鏈表,而紅黑樹的情況需要使用TreeNode

 

類屬性分析:

默認容量為: 16

 

 

         這里的默認容量為什么是2的n次冪?

         查看它的put方法可知,key在Node[]中的下標為: (n - 1) & hash。如果這個n是2的N次冪,那么hash相當於和111***111做與運算,數據分散的就比較均勻。如果n不是在的N次冪,即hash有可能和1110做與運算,那么最后一位怎么與都是0,那相當於結尾是1的那幾個下標永遠都不會放數據了,比如0001,0011…這肯定會增加碰撞的幾率.

         如果size > capacity*loadFactor的話,hashmap還會進行resize操作,會相當耗性能!所以如果事先可以確定你要放進hashMap中的數據大小。那么應該盡量設置成loadFactor * 2^n >  initCapacity ,這樣既考慮了&的問題,也避免了resize的問題.

         但是后面提到會有tableSizeFor()和在put的時候考慮loadFact來保證上面這兩個要求的.所以在初始化的時候,設置成自己知道的大小即可,沖突這些由hashmap自身來幫你減免!

         不對,還是得自己算下,如果你輸入的initcapacity為7,那么算出來的最近的2^n為8,如果選擇的是默認的0.75,即最多放入6個元素就要擴容,你放到第7個的時候,還是要resize()…

 

最大的capacity為 2^30

 

 

 

默認負載因子為0,75,即size > capacity * 0.75,hashmap就要進行擴容

 

 

特殊情況下:

1)內存空間很多,時間效率要求很高,可以降低loadFactor(盡量讓table擴寬)

2)如果內存緊張,時間效率要求不高,可以增加loadFactor

 

一個桶中,bin(箱子)的存儲方式由鏈表轉成紅黑樹的閾值為8

 

 

一個桶中,由紅黑樹轉成鏈表的閾值,resize的時候可能會用到這個值.

 

 

 

當桶中的bin被樹化時最小的hash表容量.如果樹化時bin的數量太多會進行resize擴容.注釋中說MIN_TREEIFY_CAPACITY至少是 4 * TREEIFY_THRESHOLD

 

 

 

上面說了這么多的bin,這里該介紹下bin到底是個什么結構了.

每個bin在HashMap代表存儲了一個K/V鍵值對,結構定義如下:

 

 

 

 

 

Hashmap中計算key的hash值

 

 

可以看到它並沒有直接使用Object中生成hashcode的方法,這個方法叫擾動函數,和之前的要將capacity設計成2^n一樣,也是為了減少碰撞用的.

根據前面可知,key在Node[]中的下標為 key.hashCode & (n-1),我們知道key.hashCode是一個很長的int類型的數字(范圍大概40億),而n-1顯然沒有這么長,如果直相與,那么只有key.hashCode的后面幾位參與運算了,顯然會使得碰撞很激烈!加了這個函數之后,讓高位也想辦法參與到運算中來,這樣就有可能進一步降低碰撞的可能性了!

 

用於存儲Node(K/V)對的hash表(數組),為什么是transient?

 

 

為了解答這個問題,我們需要明確下面事實:

Object.hashCode方法對於一個類的兩個實例返回的是不同的哈希值

可以試想下面的場景:

我們在機器A上算出對象A的哈希值與索引,然后把它插入到HashMap中,然后把該HashMap序列化后,在機器B上重新算對象的哈希值與索引,這與機器A上算出的是不一樣的,所以我們在機器B上get對象A時,會得到錯誤的結果。

所以說,當序列化一個HashMap對象時,保存Entry的table是不需要序列化進來的,因為它在另一台機器上是錯誤的,所以屬性這里為transient。

因為這個原因,HashMap重寫了writeObject與readObject 方法

 

保存K/V對的Set

 

 

目前hashMap中K/V對的數量

 

 

 

每次對這個hashmap做操作,這個modCount就會改變.(CAS?!)

 

 

 

Threadhold表示當容量達到該值時,會進行resize

loadFactor表示用戶設置的負載因子大小

 

 

 

 

構造函數:

         有三種,一種是無參的,這個沒啥好看的,一種是只配置了initialCapacity,最后一種是設置了initcapacity和loadFactor。看第三種就夠了,第二種不過是將loadFactor這個形參用默認0.75傳入而已.

 

 

方法內部是將loadFactor這個屬性設置為用戶輸入的大小,有意思的是tableSizeFor(initCap)這個函數,也就是說你輸入一個10,hash表的大小不一定就是10. 這個函數的功能就是用來保證容量應該大於cap,且為2的整數冪.

 

隨便寫個數帶進去算一下就可知道,該算法的作用讓最高位的1后面的位全變為1.然后再+1,得到的就是恰巧的2的n次冪.

 

 

         注意table的初始化是在第一次put的時候做的,那個時候還有考慮loadFactor再做一次tableSize的計算,那個時候得到的就是最合適的那個2的n次冪的那個數了!厲害啊!

 

 

所以,看下put流程吧!這個很重要:

方法流程如下:

 

 

方法定義如下:

 

 

False表示,會改變existing value,相當於是key相同的話會做替換.

True表示,table不在creation mode.(這是啥意思?!)

 

第一次或者擴容的時候會調用resize():

  1. 如果是第一次初始化且沒有輸入initcapacity

那么newCap=16,  threshold = 0.75 * 16

         Hash表大小為: table = Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]

  1. 如果是第一次初始化,但是設置了initcapacity和loadFactor

注意,此時的threadhold(=initcapacity接近的2^n那個值) 和 loadFactor已經有值了,但是table==null,因為沒有初始化嘛,所以此時oldCap=0

  • oldThreadHold = threadhold(=initcapacity接近的2^n那個值)

此時 newCap = threadhold

 threshold =newThreadHold = newCap * loadFactor (threadhold還變小了!…)

Hash表大小為: table = Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]

  1. 如果不是第一次初始化

此時 oldTab !=null  oldCap = oldTable.length  oldThreadHold = threadhold

那么 newCap = oldCap << 1 為原來的一倍 newThreadholder也為oldThr的一倍,即都擴大為原先的一倍!

         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

         還要將原先舊Table里的數據轉移到新的table中.

  1. 插入完成后,還要檢查下是否需要擴容

即 ++size > threshold  那么需要進行resize

 

         這里讓我想到有問題的可能就是這個了,首次初始化,cap為16,輸入的initcapacity為15的時候,會涉及到一次擴容(如果沒有沖突的話—所以這里的擴容一倍啥的操作應該是取了個折中!不然為了不沖突再擴容一倍很耗費空間啊!而且再擴容一倍也不能保證不沖突)

 

resize()流程分析:

(和Java7不同,8中resize后元素順序是不變的)

         概括起來就是:

  1. 原先有值的情況下

如果已經是MAXIMUM_CAPACITY,那么返回原先數組,否則將容量擴大為原來的一倍,即newCap = oldCap << 1, newThr=oldThr << 1

  1. 原先沒有值,但是構造函數指定了initCapacity

newCap = oldThr, newThr隨后會被指定為newCap*loadFactor

  1. 原先沒有值,且沒有指定initCapacity,即無參構造函數

按照默認的值初始化,即initCapacity=16, newThr=0.75*16

  1. 然后使用newCap構建一個newTab,如果舊表不為空就要遷移數據

遷移數據的流程如下:

 

 

  1. 如果只有一個元素時,那就重新計算位置,插入新的table
  2. 如果節點是樹類型,那使用樹的插入方式(這個暫時還不太了解)
  3. 如果節點是鏈表類型,因為元素放的位置取決於tab[i = (capacity - 1) & hash]。當長度擴為原來的2倍時,因為oldCap和newCap是2的次冪,並且newCap是oldCap的兩倍,就相當於oldCap的唯一一個二進制的1向高位移動了一位,結論為元素要么在原先位置,要么在原位置上再移動2次冪。

舉例:

比如原來容量是16,那么就相當於index=e.hash & 0x1111

現在容量擴大了一倍,就是32,那么index=e.hash & 0x11111

現在(e.hash & oldCap) == 0就表明:

已知: e.hash & 0x1111 = index

並且: e.hash & 0x10000 = 0

那么: e.hash & 0x11111 不也就是原先index的值!

 

get()流程分析:

         如果獲取到的Node為null則返回null,否則返回Node中存儲的值。

 

 

         點到getNode()中繼續查看

 

 

         分四步進行:

                  首先,如果table未空,直接返回null。否則就查找節點

                  再者,計算hash得到的位置剛符合,那直接返回。(只有一個bin的情況)

                  如果是紅黑樹,按紅黑樹的方式查找

                  如果是鏈表,逐個查找。 找不到返回null。

 

 

entrySet分析:

         遍歷的話,使用此種方式。比每次從Map中重新獲取一個key要快多了!

不是每次都是new EntrySet(),但是暫時沒找到這個東西在哪里填充的。

 

 

         網上的說法是遍歷的原理就是hashmap實現的原理。entrySet()該方法返回的是map包含的映射集合視圖,視圖的概念相當於數據庫中視圖。提供一個窗口,沒有具體到相關數據,而真正獲取數據還是從table[]中來。(ps:可以借鑒下hashmap的foreach方法,即先遍歷完數組中的第一個鏈表,再遍歷數組中的下一個鏈表…)

 

 

 

 

HashMap為什么線程不安全:

         Java8以前線程不安全是在於在resize()的時候會在get的時候產生死循環,而之所以產生死循環是因為resize之后,元素的先后順序會相反。

         即轉移的時候是這樣的:每次取出舊數組的頭結點的next,之后重新計算頭結點在新的Hash中的位置,然后將頭節點的next指向新的table[i],然后把table[i]設置成當前的頭結點,那么就完成了頭結點的轉移。

   

 

         這時候,線程一種3.next是7,線程二中7.next是3,e.next = newTable[i]就會形成了環形鏈表,所以在get的時候就會一直循環在這里。

並且在迭代的過程中,如果有線程修改了map,會拋出ConcurrentModificationException錯誤,就是所謂的fail-fast策略。

 

         Java8之后,因為順序是相同的,所以上面的那個環形鏈表問題就沒有了。但是后面那個問題還是有的,所以還是線程不安全的。另外還有++size的操作也不是線程安全的!

 

 

 

 

 

 

 

參考:

http://www.iteye.com/topic/539465 (initailCapacity為什么要設置成2的n次冪?)

https://blog.csdn.net/dog250/article/details/46665743#comments (紅黑樹的一種解釋)

https://coolshell.cn/articles/9606.html(Java7中為什么會形成環形鏈表)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM