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():
- 如果是第一次初始化且沒有輸入initcapacity
那么newCap=16, threshold = 0.75 * 16
Hash表大小為: table = Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]
- 如果是第一次初始化,但是設置了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]
- 如果不是第一次初始化
此時 oldTab !=null oldCap = oldTable.length oldThreadHold = threadhold
那么 newCap = oldCap << 1 為原來的一倍 newThreadholder也為oldThr的一倍,即都擴大為原先的一倍!
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
還要將原先舊Table里的數據轉移到新的table中.
- 插入完成后,還要檢查下是否需要擴容
即 ++size > threshold 那么需要進行resize
這里讓我想到有問題的可能就是這個了,首次初始化,cap為16,輸入的initcapacity為15的時候,會涉及到一次擴容(如果沒有沖突的話—所以這里的擴容一倍啥的操作應該是取了個折中!不然為了不沖突再擴容一倍很耗費空間啊!而且再擴容一倍也不能保證不沖突)
resize()流程分析:
(和Java7不同,8中resize后元素順序是不變的)
概括起來就是:
- 原先有值的情況下
如果已經是MAXIMUM_CAPACITY,那么返回原先數組,否則將容量擴大為原來的一倍,即newCap = oldCap << 1, newThr=oldThr << 1
- 原先沒有值,但是構造函數指定了initCapacity
newCap = oldThr, newThr隨后會被指定為newCap*loadFactor
- 原先沒有值,且沒有指定initCapacity,即無參構造函數
按照默認的值初始化,即initCapacity=16, newThr=0.75*16
- 然后使用newCap構建一個newTab,如果舊表不為空就要遷移數據
遷移數據的流程如下:

- 如果只有一個元素時,那就重新計算位置,插入新的table
- 如果節點是樹類型,那使用樹的插入方式(這個暫時還不太了解)
- 如果節點是鏈表類型,因為元素放的位置取決於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中為什么會形成環形鏈表)
