一、簡介
HashMap源碼看過無數遍了,但是總是忘,好記性不如爛筆頭。
本文HashMap源碼基於JDK8。
文章將全面介紹HashMap的源碼及HashMap存在的諸多問題。
開局一張圖,先來看看hashmap的結構。
二、歷史版本
再次聲明一下本文HashMap源碼基於JDK8。不同版本HashMap的變化還是比較大的,在1.8之前,HashMap沒有引入紅黑樹,也就是說HashMap的桶(桶即hashmap數組的一個索引位置)單純的采取鏈表存儲。這種結構雖然簡單,但是當Hash沖突達到一定程度,鏈表長度過長,會導致時間復雜度無限向O(n)靠近。比如向HashMap中插入如下元素,你會神奇的發現,在HashMap的下表為1的桶中形成了一個鏈表。
1 map.put(1, 1); 2 map.put(17,17); 3 map.put(33,33); 4 map.put(49,49); 5 map.put(65,65); 6 map.put(81,81); 7 map.put(97,97);
...
16^n + 1
為了解決這種簡單的底層存儲結構帶來的性能問題,引入了紅黑樹。在一定程度上緩解了鏈表存儲帶來的性能問題。引入紅黑樹之后當桶中鏈表長度超過8將會樹化即轉為紅黑樹(put觸發)。當紅黑樹元素少於6會轉為鏈表(remove觸發)。
在這里還有一個很重要的知識點,樹化和鏈表化的閾值不一樣?想一個極端情況,假設閾值都是8,一個桶中鏈表長度為8時,此時繼續向該桶中put會進行樹化,然后remove又會鏈表化。如果反復put和remove。每次都會進行極其耗時的數據結構轉換。如果是兩個閾值,將會形成一個緩沖帶,減少這種極端情況發生的概率。
上面這種極端情況也被稱之為復雜度震盪。
類似的復雜度震盪問題ArrayList也存在。
三、基礎知識
3.1,常量和構造方法
1 // 16 默認初始容量(這個容量不是說map能裝多少個元素,而是桶的個數) 2 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 3 // 最大容量值 4 static final int MAXIMUM_CAPACITY = 1 << 30; 5 // 默認負載因子 6 static final float DEFAULT_LOAD_FACTOR = 0.75f; 7 //樹化閾值 一個桶鏈表長度超過 8 進行樹化 8 static final int TREEIFY_THRESHOLD = 8; 9 //鏈表化閾值 一個桶中紅黑樹元素少於 6 從紅黑樹變成鏈表 10 static final int UNTREEIFY_THRESHOLD = 6; 11 //最小樹化容量,當容量未達到64,即使鏈表長度>8,也不會樹化,而是進行擴容。 12 static final int MIN_TREEIFY_CAPACITY = 64; 13 //桶數組,bucket. 這個也就是hashmap的底層結構。 14 transient Node<K,V>[] table; 15 //數量,即hashmap中的元素數量 16 transient int size; 17 //hashmap進行擴容的閾值。 (這個表示的元素多少,可不是桶被用了多少哦,比如閾值是16,當有16個元素就進行擴容,而不是說當桶被用了16個) 18 int threshold; 19 //當前負載因子,默認是 DEFAULT_LOAD_FACTOR=0.75 20 final float loadFactor; 21 /************************************三個構造方法***************************************/ 22 public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,負載因子 23 if (initialCapacity < 0) 24 throw new IllegalArgumentException("Illegal initial capacity: " + 25 initialCapacity); 26 if (initialCapacity > MAXIMUM_CAPACITY)// > 不能大於最大容量 27 initialCapacity = MAXIMUM_CAPACITY; 28 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 29 throw new IllegalArgumentException("Illegal load factor: " + 30 loadFactor); 31 this.loadFactor = loadFactor; 32 this.threshold = tableSizeFor(initialCapacity);//總要保持 初始容量為 2的整數次冪 33 } 34 public HashMap(int initialCapacity) { 35 this(initialCapacity, DEFAULT_LOAD_FACTOR); 36 } 37 public HashMap() { 38 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 39 }
3.2、桶的兩種數據結構
前面說了,JDK8 HashMap采用的是鏈表+紅黑樹。
鏈表結構
1 static class Node<K,V> implements Map.Entry<K,V> { 2 final int hash; 3 final K key; 4 V value; 5 Node<K,V> next; 6 7 Node(int hash, K key, V value, Node<K,V> next) { 8 this.hash = hash; 9 this.key = key; 10 this.value = value; 11 this.next = next; 12 } 13 }
紅黑樹結構
1 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { 2 TreeNode<K,V> parent; // red-black tree links 3 TreeNode<K,V> left; 4 TreeNode<K,V> right; 5 TreeNode<K,V> prev; // needed to unlink next upon deletion 6 boolean red; 7 TreeNode(int hash, K key, V val, Node<K,V> next) { 8 super(hash, key, val, next); 9 } 10 }
3.3、hash算法實現
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
計算桶下標方法
(n - 1) & hash//n表示HashMap的容量。 相當於取模運算。等同於 hash % n。
n其實說白了就是HashMap底層數組的長度。(n-1) & hash這個與運算,等同於hash % n。
hash()方法,只是key的hashCode的再散列,使key更加散列。而元素究竟存在哪個桶中。還是 (n - 1) & hash 結果決定的。
綜合一下如下,在hashmap中計算桶索引的方法如下所示。
public static int index(Object key, Integer length) { int h; h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); return (length - 1) & h; }
假設當前hashmap桶個數即數組長度為16,現在插入一個元素key。
計算過程如上圖所示。得到了桶的索引位置。
在上面計算過程中,只有一步是比較難以理解的。也就是為什么不直接拿 key.hashcode() & (n - 1) ,為什么要用 key.hashcode() ^ (key.hashcode() >>> 16) 為什么要多一步呢?后面問題總結會詳細介紹。
四、HashMap put過程源碼
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 Node<K,V>[] tab; Node<K,V> p; int n, i; 4 //put1,懶加載,第一次put的時候初始化table(node數組) 5 if ((tab = table) == null || (n = tab.length) == 0) 6 //resize中會進行table的初始化即hashmap數組初始化。 7 n = (tab = resize()).length; 8 //put2,(n - 1) & hash:計算下標。// put3,判空,為空即沒hash碰撞。直接放入桶中 9 if ((p = tab[i = (n - 1) & hash]) == null) 10 //將數據放入桶中 11 tab[i] = newNode(hash, key, value, null); 12 else {//put4,有hash碰撞 13 Node<K,V> e; K k; 14 //如果key已經存在,覆蓋舊值 15 if (p.hash == hash && 16 ((k = p.key) == key || (key != null && key.equals(k)))) 17 e = p; 18 //put4-3:如果是紅黑樹直接插入 19 else if (p instanceof TreeNode) 20 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 21 else {//如果桶是鏈表,存在兩種情況,超過閾值轉換成紅黑樹,否則直接在鏈表后面追加 22 for (int binCount = 0; ; ++binCount) { 23 //put4-1:在鏈表尾部追加 24 if ((e = p.next) == null) { 25 p.next = newNode(hash, key, value, null); 26 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 27 //put4-2:鏈表長度超過8,樹化(轉化成紅黑樹) 28 treeifyBin(tab, hash); 29 break; 30 } 31 if (e.hash == hash && 32 //如果key已經存在,覆蓋舊值 33 ((k = e.key) == key || (key != null && key.equals(k)))) 34 break; 35 p = e; 36 } 37 } 38 //put5:當key已經存在,執行覆蓋舊值邏輯。 39 if (e != null) { // existing mapping for key 40 V oldValue = e.value; 41 if (!onlyIfAbsent || oldValue == null) 42 e.value = value; 43 afterNodeAccess(e); 44 return oldValue; 45 } 46 } 47 ++modCount; 48 if (++size > threshold)//put6,當size > threshold,進行擴容。 49 resize(); 50 afterNodeInsertion(evict); 51 return null; 52 }
其實上面put的邏輯還算是比較清晰的。(吐槽一下JDK源碼,可讀性真的不好,可讀性真的不如Spring。尤其是JDK中總是在if或者for中對變量進行賦值。可讀性真的差。但是邏輯是真的經典)
總結一下put的過程大致分為以下8步。
1,懶漢式,第一次put才初始化table桶數組。(節省內存,時間換空間) 2,計算hash及桶下標。 3,未發生hash碰撞,直接放入桶中。 4,發生碰撞 4.1、如果是鏈表,迭代插入到鏈表尾部。 4.2、如果鏈表長度超過8,樹化即轉換為紅黑樹。(當數組長度小於64時,進行擴容而不是樹化) 4.3、如果是紅黑樹,插入到紅黑樹中。 5,如果在以上過程中發現key已經存在,覆蓋舊值。 6,如果size > threshold。進行擴容。
以上過程中,當鏈表長度超過8進行樹化,只是執行樹化方法 treeifyBin(tab, hash); 。但是在該方法中還有一步判斷,也就是當桶數組長度<64。並不會進行樹化,而是進行擴容。你想想,假如容量為16,你就插入了9個元素,巧了,都在同一個桶里面,如果這時進行樹化,樹化本身就是一個耗時的過程。時間復雜度會增加,性能下降,不如直接進行擴容,空間換時間。
看看這個方法
1 final void treeifyBin(Node<K,V>[] tab, int hash) { 2 int n, index; Node<K,V> e; 3 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//如果容量 < 64則直接進行擴容;不轉紅黑樹。(你想想,假如容量為16,你就插入了9個元素,巧了,都在同一個桶里面,如果這時進行樹化,時間復雜度會增加,性能下降,不如直接進行擴容,空間換時間) 4 resize(); 5 else if ((e = tab[index = (n - 1) & hash]) != null) { 6 TreeNode<K,V> hd = null, tl = null; 7 do { 8 TreeNode<K,V> p = replacementTreeNode(e, null); 9 if (tl == null) 10 hd = p; 11 else { 12 p.prev = tl; 13 tl.next = p; 14 } 15 tl = p; 16 } while ((e = e.next) != null); 17 if ((tab[index] = hd) != null) 18 hd.treeify(tab); 19 } 20 }
在put邏輯中還有最重要的一個過程也就是擴容。
五、擴容
5.1、擴容
1 final Node<K,V>[] resize() { 2 Node<K,V>[] oldTab = table; 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold; 5 int newCap, newThr = 0; 6 if (oldCap > 0) { 7 // 大於最大容量,不進行擴容(桶數量固定) 8 if (oldCap >= MAXIMUM_CAPACITY) { 9 threshold = Integer.MAX_VALUE; 10 return oldTab; 11 } 12 //擴容為原來的兩倍,<< 位運算 13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 14 oldCap >= DEFAULT_INITIAL_CAPACITY) 15 newThr = oldThr << 1; //threshold不在重新計算,同樣直接擴容為原來的兩倍 16 } 17 else if (oldThr > 0) // initial capacity was placed in threshold 18 newCap = oldThr; 19 else { // zero initial threshold signifies using defaults 20 newCap = DEFAULT_INITIAL_CAPACITY; 21 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 22 } 23 if (newThr == 0) { 24 float ft = (float)newCap * loadFactor; 25 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 26 (int)ft : Integer.MAX_VALUE); 27 } 28 threshold = newThr; 29 @SuppressWarnings({"rawtypes","unchecked"}) 30 //創建新的桶(原來的兩倍) 31 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 32 table = newTab; 33 if (oldTab != null) { 34 for (int j = 0; j < oldCap; ++j) {//一共oldCap個桶 35 Node<K,V> e; 36 if ((e = oldTab[j]) != null) {//如果第j個桶沒元素就不管了 37 oldTab[j] = null; 38 //只有一個元素,直接移到新的桶中(為什么不先判斷是不是TreeNode? 39 //很簡單,因為TreeNode沒有next指針,在此一定為null,也能證明是一個元素。 40 //對於大多數沒有hash沖突的桶,減少了判斷,處處充滿着智慧) 41 if (e.next == null) 42 //計算桶下標,e.hash & (newCap - 1)是newCap哦 43 newTab[e.hash & (newCap - 1)] = e; 44 else if (e instanceof TreeNode) 45 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 46 else { // rehash 源碼很經典 47 Node<K,V> loHead = null, loTail = null;//下標保持不變的桶 48 Node<K,V> hiHead = null, hiTail = null;//下標擴容兩倍后的桶 49 Node<K,V> next; 50 do { 51 next = e.next; 52 if ((e.hash & oldCap) == 0) {//判斷成立,說明該元素不用移動 53 if (loTail == null)//尾空,頭插 54 loHead = e; 55 else//尾不空,尾插 56 loTail.next = e; 57 loTail = e; 58 } 59 else {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置 60 if (hiTail == null) 61 hiHead = e; 62 else 63 hiTail.next = e; 64 hiTail = e; 65 } 66 } while ((e = next) != null); 67 if (loTail != null) { 68 loTail.next = null; 69 newTab[j] = loHead;//j 即oldIndex 70 } 71 if (hiTail != null) { 72 hiTail.next = null; 73 newTab[j + oldCap] = hiHead; //j + oldCap即newIndex 74 } 75 } 76 } 77 } 78 } 79 return newTab; 80 }
從以上源碼總計一下擴容的過程:
1,創建一個兩倍於原來(oldTab)容量的數組(newTab)。 2,遍歷oldTab 2.1,如果當前桶沒有元素直接跳過。 2.2,如果當前桶只有一個元素,直接移動到newTab中的索引位。(e.hash & (newCap - 1)) 2.3,如果當前桶為紅黑樹,在split()方法中進行元素的移動。 2.4,如果當前桶為鏈表,執行鏈表的元素移動邏輯。
在以上過程中,我們着重介紹鏈表的元素移動。也就是上述代碼中的39-68行。
首先,我們看其中
1 Node<K,V> loHead = null, loTail = null;//下標保持不變的桶 2 Node<K,V> hiHead = null, hiTail = null;//下標擴容兩倍后的桶
loHead和loTail分別對應經過rehash后下標保持不變的元素形成的鏈表頭和尾。
hiHead和hiTail分別對應經過rehash后下標變為原來(n + oldIndex)后的鏈表頭和尾。
經過上面變量,我們不難發現,桶中的數據只有兩個去向。(oldIndex和 n + oldIndex)
接下來我們思考一個問題。為什么經過rehash,一個桶中的元素只有兩個去向?
以下過程很燒腦,但是看懂了保證會收獲很多。 更會體會到源碼之美。
大致畫一下圖,如下所示。
HashMap的容量總是2的n次方(n <= 32)。
假設擴容前桶個數為16。
看擴容前后的結果。觀察擴容前后可以發現,唯一影響索引位的是hash的低第5位。
所以分為兩種情況hash低第5位為0或者1。
1 當低第5位為0:newIndex = oldIndex 2 當低第5位為1:newIndex = oldIndex + oldCap
以上過程也就說明了為啥rehash后一個桶中的元素只有兩個去向。這個過程我看沒有博客介紹過。為什么在這里詳細介紹這個呢?因為這個很重要,不懂這個就看不懂以上rehash代碼,也很難體會到JDK源碼的經典之處。給ConcurrentHashMap rehash時的鎖打一個基礎。
回到源碼52行。
if ((e.hash & oldCap) == 0)
這個判斷成立,則說明該元素在rehash后下標不變,還在原來的索引位置的桶中。為什么?
我們先看一下 (e.hash & oldCap)
看結果,如果判斷 if ((e.hash & oldCap) == 0) 成立,也就是說hash的低第5位為0。
在上個問題我們推導桶中元素的兩個去向的時候,發現低第5位的兩種情況決定了該元素的去向。再觀察上面問題推導中的hash的第一種情況當*為0;
驚不驚喜,意不意外,神奇的發現,當hash低5位為0時,其新索引為依然為oldIndex。OK,你不得不佩服作者的腦子為何如此聰明。當然了這一切巧妙的設計都是建立在hashmap桶的數量總是2的n次方。
回到源碼,如下。很簡單了,將新的兩個鏈表分別放到newTab的oldIndex位置和newIndex位置。正如我們上面推導的那樣
1 if (loTail != null) { 2 loTail.next = null; 3 newTab[j] = loHead;//j 即oldIndex 4 } 5 if (hiTail != null) { 6 hiTail.next = null; 7 newTab[j + oldCap] = hiHead; //j + oldCap即newIndex 8 }
以上resize過程就說完了。
留一個問題,以上resize過程性能還能不能進一步優化呢?有興趣的可以對比ConcurrentHashMap的這個rehash源碼。你會神奇的發現JDK8的作者為了性能究竟有多拼。
當然resize過程在並發環境下還是存在一定問題的。接下來繼續往下看。
5.2、JDK7並發環境擴容問題——循環鏈表
先看源碼
1 //將當前所有的哈希表數據復制到新的哈希表 2 void transfer(Entry[] newTable, boolean rehash) { 3 int newCapacity = newTable.length; 4 //遍歷舊的哈希表 5 for (Entry<K,V> e : table) { 6 while(null != e) { 7 //保存舊的哈希表對應的鏈表頭的下一個結點 8 Entry<K,V> next = e.next; 9 if (rehash) { 10 e.hash = null == e.key ? 0 : hash(e.key); 11 } 12 //因為哈希表的長度變了,需要重新計算索引 13 int i = indexFor(e.hash, newCapacity); 14 //第一次循環的newTable[i]為空,賦值給當前結點的下一個元素, 15 e.next = newTable[i]; 16 //將結點賦值到新的哈希表 17 newTable[i] = e; 18 e = next; 19 } 20 } 21 }
JDK7 hashmap采用的是頭插法,也就是每put一個元素,總是插入到鏈表的頭部。相對於JDK8尾插法,插入操作時間復雜度更低。
看上面transfer方法。假設擴容前數組長度為2,擴容后即長度為4。過程如下。(以下幾張圖片來自慕課網課程)
第一步:處理節點5,resize后還在原來位置。
第二步:處理節點9,resize后還在原來位置。頭插,node(9).next = node(5);
第三步:處理節點11,resize后在索引位置3處。移動到新桶中。
並發環境下的問題
假設此時有兩個線程同時put並同時觸發resize操作。
線程1執行到,只改變了舊的鏈表的鏈表頭,使其指向下一個元素9。此時線程1因為分配的時間片已經用完了。
緊接着線程2完成了整個resize過程。
線程1再次獲得時間片,繼續執行。解釋下圖,因為節點本身是在堆區。兩個線程棧只是調整鏈表指針的指向問題。
當線程2執行結束后,table這個變量將不是我們關注的重點,因為table是兩個線程的共享變量,線程2已經將table中的變量搬運完了。但是由於線程1停止的時間如上,線程1的工作內存中依然有一個變量next是指向9節點的。明確了這一點繼續往下看。
當線程2執行結束。線程1繼續執行,newTable[1]位置是指向節點5的。如下圖。
如上圖線程1的第一次while循環結束后,注意 e = next 這行代碼。經過第一次循環后,e指向9。如下圖所示。
按理來說此時如果線程1也結束了也沒啥事了,但是經過線程2的resize,9節點時指向5節點的,如上圖。所以線程1按照代碼邏輯來說,依然沒有處理完。然后再將5節點插入到newTable中,5節點繼續指向9節點,這層循環因為5.next==null,所以循環結束(自己看代碼邏輯哦,e是在while之外的,所以這里不會死循環)。如下圖所示,循環鏈表形成。
然后在你下一次進行get的時候,會進入死循環。
最后想一下JDK7會出現死循環的根源在哪里?很重要哦這個問題,根源就在於JDK7用的是頭插法,而resize又是從頭開始rehash,也就是在老的table中本來是頭的,到新table中便成為了尾,改變了節點的指向。
5.3、JDK8的數據丟失問題
上面介紹了JDK7中循環鏈表的形成,然后想想JDK8中的resize代碼,JDK8中的策略是將oldTab中的鏈表拆分成兩個鏈表然后再將兩個鏈表分別放到newTab中即新的數組中。在JDK8會出現丟失數據的現象(很好理解,在這里就不畫圖了,感興
趣的自己畫一下),但是不會出現循環鏈表。丟數據總比形成死循環好吧。。。另外一點JDK8的這種策略也間接的保證了節點間的相對順序。
好吧,還是說說JDK8的丟數據問題吧。
1 do { 2 next = e.next; 3 if ((e.hash & oldCap) == 0) {//判斷成立,說明該元素不用移動 4 if (loTail == null)//尾空,頭插 5 loHead = e; 6 else//尾不空,尾插 7 loTail.next = e; 8 loTail = e; 9 } 10 else {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置 11 if (hiTail == null) 12 hiHead = e; 13 else 14 hiTail.next = e; 15 hiTail = e; 16 } 17 } while ((e = next) != null); 18 if (loTail != null) { 19 loTail.next = null; 20 newTab[j] = loHead;//j 即oldIndex 21 } 22 if (hiTail != null) { 23 hiTail.next = null; 24 newTab[j + oldCap] = hiHead; //j + oldCap即newIndex 25 }
假設兩個線程,根據代碼邏輯,線程1執行了4次循環讓出時間片,如下圖所示。
此時鏈表table索引1位置的桶如下所示
如果此時線程2也進行resize。此時線程2看到的oldTab是如上圖所示的。很明顯,接下來線程1執行完成,並順利將兩個鏈表放到了newTab中。
此時線程2又獲取時間片並繼續執行以下操作相當於之前線程1的resize結果被線程2覆蓋了。此時就發生了數據的丟失。
終於介紹完了擴容過程,不容易啊。
六、HashMap get過程源碼
1 public V get(Object key) { 2 Node<K,V> e; 3 return (e = getNode(hash(key), key)) == null ? null : e.value;//get1,計算hash 4 } 5 final Node<K,V> getNode(int hash, Object key) { 6 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; 7 if ((tab = table) != null && (n = tab.length) > 0 && 8 (first = tab[(n - 1) & hash]) != null) {// get2,(n - 1) & hash 計算下標 9 if (first.hash == hash && // always check first node //get3-1,首先檢查第一個元素(頭元素),如果是目標元素,直接返回 10 ((k = first.key) == key || (key != null && key.equals(k)))) 11 return first; 12 if ((e = first.next) != null) { 13 if (first instanceof TreeNode)//get3-2,紅黑樹 14 return ((TreeNode<K,V>)first).getTreeNode(hash, key); 15 do {//get3-3,鏈表 16 if (e.hash == hash && 17 ((k = e.key) == key || (key != null && key.equals(k)))) 18 return e; 19 } while ((e = e.next) != null); 20 } 21 } 22 return null; 23 }
看完了put的源碼,會發現get過程是何其簡單,大致過程如下
1,計算hash 2,計算下標 3,獲取桶的頭節點,如果頭結點key等於目標key直接返回。 3.1,如果是鏈表,執行鏈表迭代邏輯,找到目標節點返回。 3.2,如果是紅黑樹,執行紅黑樹迭代邏輯,找到目標節點返回。
關於remove方法,不介紹了,無非就是就是get過程+紅黑樹到鏈表的轉化過程。不介紹了。
七、問題總結
7.1、為什么hashmap的容量必須是2的n次方。
回顧一下計算下標的方法。即計算key在數組中的索引位。
hash&(n - 1)
其中n就是hashmap的容量也就是數組的長度。
假設n是奇數。則n-1就是偶數。偶數二進制中最后一位一定是0。所以如上圖所示, hash&(n - 1) 最終結果二進制中最后一位一定是0,也就意味着結果一定是偶數。這會導致數組中只有偶數位被用了,而奇數位就白白浪費了。無形中浪費了內存,同樣也增加了hash碰撞的概率。
其中n是2的n次方保證了(兩個n不一樣哦,別較真)hash更加散列,節省了內存。
難道不能是偶數嗎?為啥偏偏是2的n次方?
2的n次方能保證(n - 1)低位都是1,能使hash低位的特征得以更好的保留,也就是說當hash低位相同時兩個元素才能產生hash碰撞。換句話說就是使hash更散列。
呃。。。個人覺得2在程序中是個特殊的數字,通過上文resize中的關於二進制的一堆分析也是建立在容量是2的n次方的基礎上的。雖然這個解釋有點牽強。如果大家有更好的解釋可以在下方留言。
兩層含義:
1,從奇偶數來解釋。
2,從hash低位的1能使得hash本身的特性更容易得到保護方面來說。(很類似源碼中hash方法中 <<< 16的做法)
7.2、解決hash沖突的方法
hashmap中解決hash沖突采用的是鏈地址法,其實就是有沖突了,在數組中將沖突的元素放到鏈表中。
一般有以下四種解決方案。詳情度娘。
1 鏈地址法 2 開放地址法 3 再哈希法 4 建立公共溢出區
7.3、HashMap、HashTable、ConcurrentHashMap區別
HashMap是不具備線程安全性的。
HashTable是通過Synchronized關鍵字修飾每一個方法達到線程安全的。性能很低,不建議使用。
ConcurrentHashMap很經典,Java程序員必精通。下篇文章就介紹ConcurrentHashMap。該類位於J.U.C並發包中,為並發而生。
7.4、如何保證HashMap的同步
Map map = Collections.synchronizedMap(new HashMap());其實其就是給HashMap的每一個方法加Synchronized關鍵字。
性能遠不如ConcurrentHashMap。不建議使用。
7.5、為什么引入紅黑樹
這個問題很簡單,因為紅黑樹的時間復雜度表現更好為O(logN),而鏈表為O(N)。
為什么紅黑樹這么好還要用鏈表?
因為大多數情況下hash碰撞導致的單個桶中的元素不會太多,太多也擴容了。只是極端情況下,當鏈表太長會大大降低HashMap的性能。所以為了應付這種極端情況才引入的紅黑樹。當桶中元素很少比如小於8,維護一個紅黑樹是比較耗時的,因為紅黑樹需要左旋右旋等,也很耗時。在元素很少的情況下的表現不如鏈表。
一般的HashMap的時間復雜度用平均時間復雜度來分析。除了極端情況鏈表對HashMap整體時間復雜度的表現影響比較小。
7.6、為什么樹轉鏈表和鏈表轉樹閾值不同
其實上文中已經介紹了,因為復雜度震盪。詳情請參考上文。
7.7、Capacity的計算
變相問一下這個問題就是當初始化hashMap時initialCapacity參數傳的是18,HashMap的容量是什么?是32。
1 static final int tableSizeFor(int cap) { 2 int n = cap - 1; 3 n |= n >>> 1; 4 n |= n >>> 2; 5 n |= n >>> 4; 6 n |= n >>> 8; 7 n |= n >>> 16; 8 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 9 }
該方法大意:如果cap不是2的n次方則取大於cap的最小的2的n次方的值。當然這個值不能超過MAXIMUM_CAPACITY 。
(這里對這個方法沒怎么看懂,明白的大神們回應在留言區指教。)
7.8、為什么默認的負載因子loadFactor = 0.75
1 * Because TreeNodes are about twice the size of regular nodes, we 2 * use them only when bins contain enough nodes to warrant use 3 * (see TREEIFY_THRESHOLD). And when they become too small (due to 4 * removal or resizing) they are converted back to plain bins. In 5 * usages with well-distributed user hashCodes, tree bins are 6 * rarely used. Ideally, under random hashCodes, the frequency of 7 * nodes in bins follows a Poisson distribution 8 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a 9 * parameter of about 0.5 on average for the default resizing 10 * threshold of 0.75, although with a large variance because of 11 * resizing granularity. Ignoring variance, the expected 12 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / 13 * factorial(k)). The first values are: 14 * 15 * 0: 0.60653066 16 * 1: 0.30326533 17 * 2: 0.07581633 18 * 3: 0.01263606 19 * 4: 0.00157952 20 * 5: 0.00015795 21 * 6: 0.00001316 22 * 7: 0.00000094 23 * 8: 0.00000006 24 * more: less than 1 in ten million
源碼中有這么一段注釋,重點就是 Poisson distribution 泊松分布。
以上是桶中元素個數和出現的概率對照表。
意思就是說當負載因子為0.75的時候,桶中元素個數為8的概率幾乎為零。
通過泊松分布來看,0.75是"空間利用率"和"時間復雜度"之間的折衷。關於這個請參考《為什么默認的負載因子是0.75》。
7.9、HashMap中為什么用位運算而不是取模運算
主要是位運算在底層計算速度更快。
簡單證明一下
1 long s1 = System.nanoTime(); 2 System.out.println(2147483640 % 16);//8 3 long e1 = System.nanoTime(); 4 long s2 = System.nanoTime(); 5 System.out.println(2147483640 & 15);//8 6 long e2 = System.nanoTime(); 7 System.out.println("取模時間:" + (e1 - s1));//取模時間:134200 8 System.out.println("與運算時間:" + (e2 - s2));//與運算時間:15800
題外話:還有一個刷leetcode題,二分法計算中心點。總結的經驗,用除法會導致部分算法題超時。
1 long s1 = System.nanoTime(); 2 System.out.println(1 + (2147483640 - 1) / 2);//1073741820 3 long e1 = System.nanoTime(); 4 long s2 = System.nanoTime(); 5 System.out.println(1 + (2147483640 - 1) >> 1);//1073741820 6 long e2 = System.nanoTime(); 7 System.out.println("除法時間:" + (e1 - s1));//除法時間:20100 8 System.out.println("位運算時間:" + (e2 - s2));//位運算時間:15700
注意:一般二分法用left + (right - left)/2;因為如果用(right+left)/2;right + left容易>Integer.MAX_VALUE;
如有錯誤的地方還請留言指正。
原創不易,轉載請注明原文地址: https://www.cnblogs.com/hello-shf/p/12168181.html
參考文獻: