HashMap源碼賞析(1.8)


一、簡介

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

 

   參考文獻:

  https://www.jianshu.com/p/003256ce41ce

  https://www.cnblogs.com/morethink/p/7762168.html


免責聲明!

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



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