聊聊經典數據結構HashMap,逐行分析每個關鍵點

本文基於JDK-8u261源碼分析node


本文原創首發於 奇客時間(qiketime) web

1 簡介

HashMap是一個使用很是頻繁的鍵值對形式的工具類,其使用起來十分方便。可是須要注意的是,HashMap不是線程安全的,線程安全的是ConcurrentHashMap(Hashtable這種過期的工具類就不要再提了),在Spring框架中也會用到HashMap和ConcurrentHashMap來作各類緩存。從Java 8開始,HashMap的源碼作了必定的修改,以此來提高其性能。首先來看一下HashMap的數據結構:數組

總體上能夠看做是數組+鏈表的形式。數組是爲了進行快速檢索,而若是hash函數衝突了的話,就會在同一個位置處後面進行掛鏈表的操做。也就是說,同一個鏈表上的節點,它們的hash值計算出來都是同樣的。可是若是hash衝突比較多的時候,生成的鏈表也會拉的比較長,這個時候檢索起來就會退化成遍歷操做,性能就比較低了。在Java 8中爲了改善這種狀況,引入了紅黑樹。緩存

紅黑樹是一種高級的平衡二叉樹結構,其能保證查找、插入、刪除的時間複雜度最壞爲O(logn)。在大數據量的場景下,相比於AVL樹,紅黑樹的插入刪除性能要更高。當鏈表中的節點數量大於等於8的時候,同時當前數組中的長度大於等於MIN_TREEIFY_CAPACITY時(注意這裏是考點!因此之後不要再說什麼當鏈表長度大於8的時候就會轉成紅黑樹,這麼說只會讓別人以爲你沒有認真看源碼),鏈表中的全部節點會被轉化成紅黑樹,而若是當前鏈表節點的數量小於等於6的時候,紅黑樹又會被退化成鏈表。其中MIN_TREEIFY_CAPACITY的值爲64,也就是說當前數組中的長度(也就是桶bin的個數)必須大於等於64的時候,同時當前這個鏈表的長度大於等於8的時候,才能轉化。若是當前數組中的長度小於64,即便當前鏈表的長度已經大於8了,也不會轉化。這點須要特別注意。如下的treeifyBin方法是用來將鏈表轉化成紅黑樹操做的:安全

1/** 2 * Replaces all linked nodes in bin at index for given hash unless 3 * table is too small, in which case resizes instead. 4 */
 5final void treeifyBin(Node<K,V>[] tab, int hash) {
 6    int n, index; Node<K,V> e;
 7    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
 8        resize();
 9    else if ((e = tab[index = (n - 1) & hash]) != null) {
10        TreeNode<K,V> hd = null, tl = null;
11        do {
12            TreeNode<K,V> p = replacementTreeNode(e, null);
13            if (tl == null)
14                hd = p;
15            else {
16                p.prev = tl;
17                tl.next = p;
18            }
19            tl = p;
20        } while ((e = e.next) != null);
21        if ((tab[index] = hd) != null)
22            hd.treeify(tab);
23    }
24}

從上面的第7行和第8行代碼處能夠看出,若是當前數組的長度也就是桶的數量小於MIN_TREEIFY_CAPACITY的時候,會選擇resize擴容操做,此時就不會走轉成紅黑樹的邏輯了。這裏的意思就是說若是當前的hash衝突達到8的時候,根本的緣由就是由於桶分配的太少才產生那麼多衝突的。那麼此時我選擇擴容操做,以此來下降hash衝突的產生。等到數組的長度大於等於MIN_TREEIFY_CAPACITY的時候,若是當前鏈表的長度仍是8的話,纔會去轉化成紅黑樹。數據結構

由此能夠看出加入MIN_TREEIFY_CAPACITY這個參數的意義就是在於要保證hash衝突多的緣由不是由於數組容量少才致使的;還有一個意義在於,假如說當前數組的全部數據都放在了一個桶裏面(或者相似於這種狀況,絕大部分的節點都掛在了一個桶裏(hash函數散列效果很差,通常不太可能出現)),此時若是沒有MIN_TREEIFY_CAPACITY這個參數進行限制的話,那我就會去開開心心去生成紅黑樹去了(紅黑樹的生成過程以及後續的維護仍是比較複雜的,因此原則上是能不生成就不生成,後面會有說明)。而有了MIN_TREEIFY_CAPACITY這個參數進行限制的話,在上面的第8行代碼處就會觸發擴容操做。這裏的擴容更多的意義在於把這個hash衝突儘可能削減。好比把鏈表長度爲8的八個節點再平分到擴容後新的兩倍數組的兩處新的桶裏面,每一個桶由原來的八個節點到如今的四個節點(也多是一個桶5個另外一個桶3個,極端狀況下也可能一個桶8個另外一個桶0個。但無論怎樣,從統計學上考量的話,原來桶中的節點數大機率會被削減),這樣就至關於減小了鏈表的個數,也就是說減小了在同一個位置上的hash衝突的發生。還有一點須要提一下,源碼註釋中說明MIN_TREEIFY_CAPACITY的大小要至少爲4倍的轉成紅黑樹閾值的數量,這麼作的緣由也是更多的但願能減小hash衝突的發生。併發

**那麼爲何不直接用紅黑樹來代替鏈表,而是採用鏈表和紅黑樹來搭配在一塊兒使用呢?**緣由就在於紅黑樹雖然性能更好,可是這也僅是在大數據量下才能看到差別。若是當前數據量很小,就幾個節點的話,那麼此時顯然用鏈表的方式會更划算。由於要知道紅黑樹的插入和刪除操做會涉及到大量的自旋,以此來保證樹結構的平衡。若是數據量小的話,插入刪除的性能高效根本抵消不了自旋操做所帶來的成本。框架

**還有一點須要留意的是鏈表轉爲紅黑樹的閾值是8,而紅黑樹退化成鏈表的閾值是6。**爲何這兩個值會不同呢?能夠試想一下,若是這兩個值都爲8的話,而當前鏈表的節點數量爲7,此時一個新的節點進來了,計算出hash值和這七個節點的hash值相同,即發生了hash衝突。因而就會把這個節點掛在第七個節點的後面,可是此時已經達到了變成紅黑樹的閾值了(MIN_TREEIFY_CAPACITY條件假定也知足),因而就轉成紅黑樹。可是此時調用了一次remove操做須要刪掉這個新加的節點,刪掉以後當前紅黑樹的節點數量就又變成了7,因而就退化成了鏈表。而後此時又新加了一個節點,正好又要掛在第七個節點的後面,因而就又變成紅黑樹,而後又要remove,又退化成鏈表…能夠看到在這種場景下,會不斷地出現鏈表和紅黑樹之間的相互轉換,這個性能是很低的,由於大部分的執行時間都花費在了轉換數據結構上面,而我僅僅是作了幾回連續的增刪操做而已。因此爲了不這種狀況的發生,將兩個閾值錯開一些,以此來儘可能避免在閾值點附近可能存在的、頻繁地作轉換數據結構操做而致使性能變低的狀況出現。less

這裏之因此閾值會選擇爲8是經過數學統計上的結論得出的,在源碼中也有相關注釋:編輯器

其中中間的數字表示當前這個位置預計發生指定次數哈希衝突的機率是多少。能夠看到當衝突機率爲8的時候,機率已經下降到了0.000006%,幾乎是不可能發生的機率。從這裏也能夠看出,HashMap做者選擇這個數做爲閾值是不但願生成紅黑樹的(紅黑樹的維護成本高昂)。而一樣負載因子默認選擇爲0.75也是基於統計分析出來的,如下是源碼中對負載因子的解釋:

負載因子衡量的是數組在擴容前的填充程度,也就是說一個數組真正能存進去的實際容量 = 數組的長度 * 負載因子(好比當前數組的長度爲16(桶的個數),負載因子爲0.75,那麼當數組存進了16 * 0.75 = 12個桶的時候,就會進行擴容操做,而不是等到數組空間滿了的時候)。若是爲0.5表示的就是數組填充一半後就進行擴容;爲1就表示的是數組所有填滿後再進行擴容。之因此默認值選擇爲0.75是在時間和空間成本上作的一個折中方案,通常不建議本身更改。這個值越高,就意味着數組中能存更多的值,減小空間開銷,可是會增長hash衝突的機率,增長查找的成本;這個值越低,就會減小hash衝突的機率,可是會比較費空間。

而數組的默認容量爲16也是統計上的結果。值得一說的是,若是事先知道了HashMap所要存儲的數量的時候,就能夠將數組容量傳進構造器中,以此來避免頻繁地擴容操做。好比我如今要往HashMap中大約放進200個數據,若是不設置初始值的話,默認容量就是16,當存進16 * 0.75 = 12個數據的時候就會擴容一次,擴容到兩倍容量32,而後等再存進32 * 0.75 = 24個數據的時候再繼續擴容…直到擴容到能存進200個數據爲止。因此說,若是能提早先設置好初始容量的話,就不須要再擴容這麼屢次了。


2 構造器

1/** 2 * HashMap: 3 * 無參構造器 4 */
 5public HashMap() {
 6    //負載因子設置爲默認值0.75,其餘的屬性值也都是走默認的
 7    this.loadFactor = DEFAULT_LOAD_FACTOR;
 8}
 9
10/** 11 * 有參構造器 12 */
13public HashMap(int initialCapacity) {
14    //初始容量是本身指定的,而負載因子是默認的0.75
15    this(initialCapacity, DEFAULT_LOAD_FACTOR);
16}
17
18public HashMap(int initialCapacity, float loadFactor) {
19    //initialCapacity非負校驗
20    if (initialCapacity < 0)
21        throw new IllegalArgumentException("Illegal initial capacity: " +
22                initialCapacity);
23    //initialCapacity若是超過了設定的最大值(2的30次方),就重置爲2的30次方
24    if (initialCapacity > MAXIMUM_CAPACITY)
25        initialCapacity = MAXIMUM_CAPACITY;
26    //負載因子非負校驗和非法數字校驗(當被除數是0或0.0,而除數是0.0的時候,得出來的結果就是NaN)
27    if (loadFactor <= 0 || Float.isNaN(loadFactor))
28        throw new IllegalArgumentException("Illegal load factor: " +
29                loadFactor);
30    this.loadFactor = loadFactor;
31    /* 32 將threshold設置爲大於等於當前設置的數組容量的最小2次冪 33 threshold會在resize擴容方法中被從新更新爲新數組容量 * 負載因子,也就是下一次的擴容點 34 */
35    this.threshold = tableSizeFor(initialCapacity);
36}
37
38/** 39 * 這個方法是用來計算出大於等於cap的最小2次冪的,可是實現的方式很精巧,充分利用了二進制的特性 40 */
41static final int tableSizeFor(int cap) {
42    /* 43 這裏的-1操做是爲了防止cap如今就已是2的冪的狀況,後面會進行說明。爲了便於理解,這裏舉個例子: 44 假設此時cap爲34(100010),n就是33(100001)。咱們其實只要關注第一個最高位是1的這個位置,即從左 45 到右第一個爲1的位置。通用的解釋是01xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(x表明不肯定,不用關心這個位置上是0仍是1) 46 */
47    int n = cap - 1;
48    /* 49 通過一次右移操做並按位或以後,n變成了110001(100001 | 010000) 50 通用解釋:此時變成了011xxxxxxxxxxxxxxxxxxxxxxxxxxxxx 51 */
52    n |= n >>> 1;
53    /* 54 此時通過兩次右移操做並按位或以後,n變成了111101(110001 | 001100) 55 通用解釋:此時變成了01111xxxxxxxxxxxxxxxxxxxxxxxxxxx 56 */
57    n |= n >>> 2;
58    /* 59 此時通過四次右移操做並按位或以後,n變成了111111(111101 | 000011) 60 通用解釋:此時變成了011111111xxxxxxxxxxxxxxxxxxxxxxx 61 */
62    n |= n >>> 4;
63    /* 64 此時通過八次右移操做並按位或,對於上面的示例數據來講,此時已經變成了全部位都是1的狀況, 65 那麼下面的兩次右移操做作了和沒作已經沒區別了(由於右移後的結果確定是0,和原來的數按位或以後是沒有變的) 66 其實通過這麼屢次的右移並按位或,就是爲了最後能得出一個全是1的數 67 通用解釋:此時變成了01111111111111111xxxxxxxxxxxxxxx 68 */
69    n |= n >>> 8;
70    /* 71 此時通過十六次右移操做並按位或,通用解釋:此時變成了01111111111111111111111111111111 72 須要說明一下的是int的位數是32位,因此只須要右移16位就能夠中止了(固然也能夠繼續右移32位,64位... 73 只不過那樣的話就沒有什麼意義了,由於右移後的結果都是0,按位或的結果是不會發生變更的) 74 而int的最大值MAX_VALUE是2的31次方-1,換算成二進制就是有31個1 75 在以前的第25行代碼處已經將該值改成了2的30次方,1後面有30個0,即010000...000 76 因此傳進來該方法的最大值cap只能是這個數,通過-1再幾回右移操做後就變成了00111...111,即30個1 77 最後在第90行代碼處+1後又會從新修正爲2的30次方,即MAXIMUM_CAPACITY 78 */
79    n |= n >>> 16;
80    /* 81 n若是小於0對應的是傳進來的cap自己就是0的狀況。通過右移後,n變成了-1(其實右不右移根本不會改變結果, 82 由於-1的二進制數就是32個1,和任何數按位或都不會發生變更),這個時候就返回結果1(2的0次方)就好了 83 84 由此能夠看到,最後的效果就是得出了一個原始數據從第一個最高位爲1的這個位置開始,後面的全部位無論是0仍是1都改爲1 85 最後在第90行代碼處再加一後就變成了最高位是1而剩下位都是0的一個數,可是位數比原數據多一位,也就是原數據的最小2次冪了 86 87 如今能夠考慮一下以前說過的若是傳進來的cap自己就是2的冪的狀況。假如說沒有第47行代碼操做的話,那麼通過不斷右移操做後, 88 得出來的是一個全是1的二進制數,也就是這個數*2-1的結果,最後再加1後就變成了原數據的2倍,這顯然是不對的 89 */
90    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
91}

3 put方法

1/** 2 * HashMap: 3 */
 4public V put(K key, V value) {
 5    return putVal(hash(key), key, value, false, true);
 6}
 7
 8/** 9 * 計算key的hash值 10 * 注意這裏是直接調用key的hashCode方法,而且會將其高16位和低16位進行異或來做爲最終的hash(Java中的int值是32位) 11 * 那麼爲何會這樣作呢?由於在後續的判斷插入桶bin的位置使用的方法是(table.length - 1) & hash 12 * 這裏數組的長度必須爲2的冪(若是不是則會轉換成大於該值的最小2次冪,詳見tableSizeFor方法),那麼數組的長度減去1後的值 13 * 用二進制來表示就是11111...低位全都是1的一個數。這樣再去和本方法計算出來的hash值進行按位與的話,結果就是一個 14 * 保留了hash值全部這些低位上的數,說白了就是和hash % table.length這種是同樣的結果,就是對數組長度取餘而已 15 * 可是直接用%作取餘的話效率不高,並且這種按位與的方式只能適用於數組長度是2的冪的狀況,不是這種狀況的話是不能作等價交換的 16 * 17 * 從上面能夠看到,按位與的方式只會用到hash值低位的信息,高位的信息無論是什麼都無所謂,反正不會記錄到最後的hash計算中 18 * 這樣的話就以爲有些惋惜、有些浪費。若是將高位信息也進行記錄的話那就更好了。因此在下面第26行代碼處, 19 * 將其高16位和低16位進行異或,就是爲了將高16位的特徵信息也融合進hash值中,儘可能使哈希變得散列,減小hash衝突的發生 20 * 同時使用一個異或操做的話也很簡單高效,不會像有些hash函數同樣會進行不少的計算後纔會生成一個hash值(好比說這塊 21 * 在Java 7中的實現就是會有不少次的右移操做) 22 * <<<在底層源碼中,在能完成需求的前提下,能實現得越簡單越高效,就是王道>>> 23 */
 24static final int hash(Object key) {
 25    int h;
 26    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 27}
 28
 29/** 30 * 第5行代碼處: 31 */
 32final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 33 boolean evict) {
 34    Node<K, V>[] tab;
 35    Node<K, V> p;
 36    int n, i;
 37    if ((tab = table) == null || (n = tab.length) == 0)
 38        //若是當前數組尚未初始化的話,就進行初始化的工做。由此能夠看到,HashMap的初始化工做被延遲到了put方法中
 39        n = (tab = resize()).length;
 40    if ((p = tab[i = (n - 1) & hash]) == null)
 41        /* 42 經過(n - 1) & hash的方式來找到這個數據插入的桶位置(至於爲何用這種方式詳見hash方法的註釋) 43 若是這個桶上尚未數據存在的話,就直接建立一個新的Node節點插入進這個桶就能夠了,也就是快速判斷 44 */
 45        tab[i] = newNode(hash, key, value, null);
 46    else {
 47        /* 48 不然若是這個桶上有數據的話,就執行下面的邏輯 49 50 e是用來判斷新插入的這個節點是否能插入進去,若是不爲null就意味着找到了這個新節點要插入的位置 51 */
 52        Node<K, V> e;
 53        K k;
 54        if (p.hash == hash &&
 55                ((k = p.key) == key || (key != null && key.equals(k))))
 56            /* 57 若是桶上第一個節點的hash值和要插入的hash值相同,而且key也是相同的話, 58 就記錄一下這個位置e,後續會作值的覆蓋(快速判斷模式) 59 */
 60            e = p;
 61        //走到這裏說明要插入的節點和當前桶中的第一個節點不是同一個節點,可是他們計算出來的hash值是同樣的
 62        else if (p instanceof TreeNode)
 63            //若是節點是紅黑樹的話,就執行紅黑樹的插入節點邏輯(紅黑樹的分析本文不作展開)
 64            e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
 65        else {
 66            //走到這裏說明鏈表上有多個節點,遍歷鏈表上的節點(第一個節點不須要判斷了,由於在第54行代碼處已經判斷過了)
 67            for (int binCount = 0; ; ++binCount) {
 68                if ((e = p.next) == null) {
 69                    /* 70 若是當前鏈表的下一個位置爲null,意味着已經循環到最後一個節點尚未找到同樣的, 71 此時將要插入的新節點插到最後 72 */
 73                    p.next = newNode(hash, key, value, null);
 74                    //若是循環到此時,鏈表的數量已經達到了轉成紅黑樹的閾值的時候,就進行轉換
 75                    if (binCount >= TREEIFY_THRESHOLD - 1)
 76                        /* 77 以前分析過,是否真正會轉成紅黑樹,須要看當前數組的桶的個數 78 是否大於等於MIN_TREEIFY_CAPACITY,小於就只是擴容 79 */
 80                        treeifyBin(tab, hash);
 81                    break;
 82                }
 83                if (e.hash == hash &&
 84                        ((k = e.key) == key || (key != null && key.equals(k))))
 85                    //若是這個節點以前就在鏈表中存在,就能夠退出循環了(e在第68行代碼處已經被賦值了)
 86                    break;
 87                p = e;
 88            }
 89        }
 90        if (e != null) {
 91            /* 92 若是找到了要插入的位置的話,就作值的覆蓋 93 94 記錄舊值,並最終返回出去 95 */
 96            V oldValue = e.value;
 97            //若是onlyIfAbsent爲false,或者自己舊值就爲null,就新值覆蓋舊值
 98            if (!onlyIfAbsent || oldValue == null)
 99                e.value = value;
100            //鉤子方法,空實現
101            afterNodeAccess(e);
102            return oldValue;
103        }
104    }
105    /* 106 走到這裏說明以前是走到了第45行代碼處,添加了一個新的節點。 107 108 修改次數+1,modCount是用來作快速失敗的。若是迭代器中作修改,modCount != expectedModCount, 109 代表此時HashMap被其餘線程修改了,會拋出ConcurrentModificationException異常(我在分析ArrayList 110 的源碼文章中詳細解釋了這一過程,在HashMap中也是相似的) 111 */
112    ++modCount;
113    /* 114 既然是添加了一個新的節點,那麼就須要判斷一下此時是否須要擴容 115 若是當前數組容量已經超過了設定好的threshold閾值的時候,就執行擴容操做 116 */
117    if (++size > threshold)
118        resize();
119    //鉤子方法,空實現
120    afterNodeInsertion(evict);
121    return null;
122}

4 resize方法

在上面putVal方法中的第39行和118行代碼處,都是調用了resize方法來進行初始化或擴容的。而resize方法也是HashMap源碼中比較精髓、比較有亮點的一個方法。其具體實現大體能夠分爲兩部分:設置擴容標誌位和具體的數據遷移過程。下面就首先來看一下resize方法的前半部分源碼:

1/** 2 * HashMap: 3 * 擴容操做(當前數組爲空的話就變成了對數組初始化的操做) 4 */
 5final Node<K, V>[] resize() {
 6    Node<K, V>[] oldTab = table;
 7    //記錄舊數組(當前數組)的容量,若是爲null就是0
 8    int oldCap = (oldTab == null) ? 0 : oldTab.length;
 9    /* 10 1.在調用有參構造器的時候threshold存放的是大於等於當前數組容量的最小2次冪,將其賦值給oldThr 11 2.調用無參構造器的時候threshold=0 12 3.以前數組已經不爲空,如今在作擴容的時候,threshold存放的是舊數組容量 * 負載因子 13 */
 14    int oldThr = threshold;
 15    //newCap指的是數組擴容後的數量,newThr指的是newCap * 負載因子後的結果(若是計算出來有小數就取整數部分)
 16    int newCap, newThr = 0;
 17    //下面就是對各類狀況進行分析,而後將newCap和newThr標記位進行賦值的過程
 18    if (oldCap > 0) {
 19        if (oldCap >= MAXIMUM_CAPACITY) {
 20            /* 21 若是當前數組容量已經超過了設定的最大值,就將threshold設置爲int的最大值,而後返回當前數組容量 22 也就意味着在這種狀況下不進行擴容操做 23 */
 24            threshold = Integer.MAX_VALUE;
 25            return oldTab;
 26        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
 27                oldCap >= DEFAULT_INITIAL_CAPACITY)
 28            /* 29 若是當前數組容量*2後沒有超過設定的最大值,而且當前數組容量是大於等於初始容量16的話, 30 就將newCap設置爲oldCap * 2,newThr設置爲oldThr * 2 31 oldCap >= DEFAULT_INITIAL_CAPACITY這個條件出現的意義在後面會說明 32 */
 33            newThr = oldThr << 1;
 34    } else if (oldThr > 0)
 35        /* 36 走到這裏說明oldCap=0,也就是當前是初始化數組的時候。咱們剛纔看到若是是默認的無參構造器的話, 37 threshold是不會被賦值的,也就是爲0。可是若是調用的是有參的構造器,threshold就會在構造器初始階段被賦值了, 38 而這個if條件就是對應於這種狀況。這裏設置爲oldThr是由於在上面的第14行代碼處能夠看到oldThr指向的是threshold, 39 也就是說oldThr的值是大於等於「當前數組容量」的最小2次冪(注意,「當前數組容量」我在這裏是加上引號的, 40 也就是說並非如今真正物理存在的數組容量(當前的物理容量是0),而是經過構造器傳進來設定的容量), 41 確定是個大於0的數。既然這個oldThr如今就表明着我想要設定的新容量,那麼直接就將newCap也賦值成這個數就能夠了 42 */
 43        newCap = oldThr;
 44    else {
 45        /* 46 如上所說,這裏對應的是調用無參構造器,threshold=0的時候 47 將newCap賦值爲16,newThr賦值爲16 * 0.75 = 12,都是取默認的值 48 */
 49        newCap = DEFAULT_INITIAL_CAPACITY;
 50        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
 51    }
 52    if (newThr == 0) {
 53        /* 54 有兩種狀況程序能走到這裏: 55 第一種:在第43行代碼處的if條件中沒有對newThr進行賦值,此時計算出ft = 新數組容量 * 負載因子, 56 若是數組容量和ft都沒有超過設定的最大值的話,就將newThr賦值爲ft,不然賦值給int的最大值 57 58 第二種:注意到上面第27行代碼處的條件,若是oldCap比16要小的話,newThr也是沒有賦值的。 59 出現這種狀況的根源不在於這一次resize方法的調用,而是在於上一次初始化時候的調用。舉個例子就明白了: 60 一開始我是調用new HashMap<>(2)這個構造器,通過計算後threshold=2。接着調用put方法觸發初始化會跳進該方法裏 61 此時oldCap=0,oldThr=2。接着會走進第34行代碼處的if條件中,newCap=2,而後會走進第52行代碼處, 62 也就是本if條件中:newThr=1,而後修改threshold=1。以後resize方法就會走具體的擴容操做了 63 可是以前這些設置的標誌位都不會被更改,擴容後就退出該方法了。這是第一次調用過程。 64 而後我此時成功插入了這個節點後,又再次調用了put方法。此時仍是會把該節點插入成功進去, 65 可是在上面putVal方法中的第117行代碼處判斷髮現,我當前的size=2已經大於了threshold=1。因而又會調用resize該方法來進行擴容 66 而此時oldCap=2,oldThr=1。會走到第26行代碼處的if條件中,newCap=4,而此時的oldCap=2要小於16, 67 因而就跳出了該if條件,而後會走進第52行代碼處,也就是本if條件中:newThr=3,而後修改threshold=3 68 這是正確的狀況,由於newThr自己就是newCap * 負載因子後的結果,即 4 * 0.75 = 3 69 那麼假如說源碼裏沒有第27行代碼處的判斷條件的話,就會跳進到第33行代碼處,此時的oldThr=1,因此newThr=2 70 能夠看到此時newCap=4而newThr=2,發生了錯誤,4 * 0.75不等於2。因此說在第27行代碼處的 71 oldCap >= DEFAULT_INITIAL_CAPACITY這個條件的出現,將本來在第33行代碼處進行更新newThr的操做 72 改成了在第97行代碼處,以解決newThr更新不許確的問題 73 74 固然這裏只是演示了可能出錯的一種狀況,並無說到本質。其實我經過對比其餘的一些數據來演示這個結果後發現: 75 若是桶的個數超過了16也會存在這種差別點。其實上述的出錯能夠通常化:一個是原容量 * 負載因子後取整,而後*2, 76 另外一個是原容量 * 2 * 負載因子後再取整。差別點就在於取整的時機。而出現差異也就是 77 原容量 * 負載因子後是一個帶小數的數(若是爲整數是不會有差異的,並且也並非全部帶小數的數都會有差別), 78 這個帶小數的數取整後再*2,差別點被放大了,從而致使最終的不一樣。還有一處線索是第27行代碼處的 79 oldCap >= DEFAULT_INITIAL_CAPACITY,注意這裏是大於等於,而不是小於等於,也就是說 80 只有大於等於16的話纔會走進這個if條件(快速計算threshold結果),小於16的話會走進本if條件 81 (精確計算threshold結果)。因此說若是桶的個數大於16,閾值多一個少一個的話可能就沒那麼重要了, 82 好比說1024個桶,我是在819個桶滿了的時候去擴容仍是在818個,彷佛差異也不太大,在這種狀況下 83 就由於我把閾值threshold多減去了1個,從而會致使哈希衝突變高?仍是空間更浪費了?其實不見得 84 畢竟數據量在這裏擺着,並且負載因子通常都是小於1的數,因此這個差異最多也就是1個。這個差別點也會隨着 85 數據的愈來愈大而顯得愈來愈不重要。可是若是像前面舉的例子,4個桶我是在2個桶滿了仍是3個桶滿的時候去擴容, 86 這個差異就很大了,這兩種狀況下hash衝突發生機率的對比確定是比較大的。可能一個是20%,另外一個是40%, 87 而桶的個數比較大的時候這個差別對比可能就是1.2%和1.3%(這個數是我隨便舉的)。這樣的話在數據量大 88 並且擴容方法頻繁調用的時候(好比我要存進一個特別大的容量可是沒有指定初始容量),我犧牲了計算閾值的準確性 89 (若是負載因子設置合理,這個差別就只有1個的區別),但換來了執行速度的高效(注意在第33行代碼處是左移操做, 90 而在第96行代碼處是乘法,乘法後又接着一個三目運算符,而後又取整);可是數據量小的時候,明顯是計算準確更重要, 91 並且數據量小的狀況下也談不上什麼性能差別,畢竟這裏設定的閾值是16。因此在上面第14行代碼處的註釋中, 92 threshold有第四種取值:舊數組容量 * 負載因子 - 1(具體減去幾要看負載因子設置的值以及數組容量), 93 可是這種徹底能夠算做是第三種的特殊狀況。因此總結來講:第27行代碼處添加的意義就是爲了在桶數量比較大、 94 擴容方法頻繁調用的時候,稍微犧牲一些準確性,可是能讓threshold閾值計算得更快一些,是一種優化手段 95 */
 96        float ft = (float) newCap * loadFactor;
 97        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
 98                (int) ft : Integer.MAX_VALUE);
 99    }
100    //作完上述操做後,將threshold的值也更新一下
101    threshold = newThr;
102
103    //...
104}

上面在把newCap、newThr和threshold標記位都設置好了後,下面就是具體的數據遷移的過程,也就是resize方法後半部分的實現:

1/** 2 * HashMap: 3 */
 4final Node<K, V>[] resize() {
 5    //...
 6
 7    //此時newCap和newThr標記位都已經設置好了,將根據newCap新的容量來建立一個新的Node數組,以此來替代舊數組
 8    @SuppressWarnings({"rawtypes", "unchecked"})
 9    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
 10    table = newTab;
 11    //若是舊數組爲null,也就是說如今是對數組作初始化的操做。那麼直接就返回建立的新數組newTab就好了
 12    if (oldTab != null) {
 13        //遍歷舊數組上的每個桶
 14        for (int j = 0; j < oldCap; ++j) {
 15            Node<K, V> e;
 16            //若是舊數組的這個桶上沒有數據的話,就跳過它,不進行擴容
 17            if ((e = oldTab[j]) != null) {
 18                //舊數組上的這個節點賦值爲null,便於快速GC
 19                oldTab[j] = null;
 20                if (e.next == null)
 21                    /* 22 第一個節點後面沒有後續節點,也就意味着這個桶上只有一個節點, 23 那麼只須要經過計算找出新位置放進去就好了,這裏也就是在作快速遷移 24 */
 25                    newTab[e.hash & (newCap - 1)] = e;
 26                else if (e instanceof TreeNode)
 27                    //若是是紅黑樹,就執行紅黑樹的遷移邏輯(紅黑樹的分析本文不作展開)
 28                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
 29                else {
 30                    /* 31 走到這裏說明當前這個桶裏面有不止一個的節點,此時就會作鏈表上多個節點的遷移工做 32 首先來講一下大前提:如今舊數組上桶的位置是j,而準備放進新數組的桶位置有兩個:一個是j, 33 也就是說新數組上也會放在j這個位置上;另外一個是j+舊數組的容量。比方說當前桶的位置15, 34 而舊數組的容量是16,那麼新數組上第二個將要插入的桶的位置就是15 + 16 = 31 35 36 說完了大前提,再來看下面的代碼。如下定義了四個指針位置, 37 分別就對應了上面說的兩個新插入桶位置的頭尾指針 38 */
 39                    Node<K, V> loHead = null, loTail = null;
 40                    Node<K, V> hiHead = null, hiTail = null;
 41                    Node<K, V> next;
 42                    do {
 43                        //next指向當前節點的下一個節點
 44                        next = e.next;
 45                        /* 46 那麼如今的問題就是經過什麼辦法來判斷究竟是插入到j位置仍是j+舊數組容量的位置呢? 47 其實也很簡單,就是經過節點的哈希值和舊數組的容量按位與的方式來判斷的。舊數組容量 48 通過上面的分析後能夠知道,確定是一個2的冪,也就是1000...000,最高位爲1,而剩餘位都是0的形式 49 這樣按位與的結果就是取出了節點hash上的那個與舊數組所對應的1的那個位置上的數。 50 好比說節點的hash值是1010110,舊數組容量是16(1000),那麼按位與的結果就是 51 取出了hash值中從右往左第四位的值,即0。也就是說,存在新數組哪一個位置上,取決於hash值 52 所對應舊數組容量爲1的那個位置上究竟是0仍是1。從這裏也能夠看出,除了 53 (table.length - 1) & hash這種方式用來判斷插入桶的位置,是必需要求數組容量是2的冪以外, 54 在擴容作遷移的時候,也必需要求了這點 55 56 按位與的結果只有兩種,不是0就是1,因此若是爲0的話最後就會插入到新數組的j位置, 57 爲1就插入到j+舊數組容量的位置(後面會解釋若是換一下的話,到底行不行) 58 */
 59                        if ((e.hash & oldCap) == 0) {
 60                            if (loTail == null)
 61                                //若是當前是第一個插進來的節點,就將loHead頭指針指向它
 62                                loHead = e;
 63                            else
 64                                /* 65 不是第一個的話,就將loTail尾指針的下一個next指針指向它,也就是把鏈拉上 66 loTail在以前已經指向了最後一個節點處 67 */
 68                                loTail.next = e;
 69                            //更新一下loTail尾指針,從新指向此時的最後一個節點處
 70                            loTail = e;
 71                        } else {
 72                            //(e.hash & oldCap) == 1
 73                            if (hiTail == null)
 74                                //若是當前是第一個插進來的節點,就將hiHead頭指針指向它
 75                                hiHead = e;
 76                            else
 77                                /* 78 不是第一個的話,就將hiTail尾指針的下一個next指針指向它,也就是把鏈拉上 79 hiTail在以前已經指向了最後一個節點處 80 */
 81                                hiTail.next = e;
 82                            //更新一下hiTail尾指針,從新指向此時的最後一個節點處
 83                            hiTail = e;
 84                        }
 85                    //若是當前遍歷鏈表上的節點尚未到達最後一個節點處,就繼續循環去判斷
 86                    } while ((e = next) != null);
 87                    /* 88 走到這裏說明已經將原來的舊數組上的鏈表拆分完畢了,如今分紅了兩個鏈表,low和high 89 接下來須要作的工做就很清楚了:將這兩個鏈表分別插入到j位置和j+舊數組容量的位置就能夠了 90 */
 91                    if (loTail != null) {
 92                        /* 93 若是low鏈表有節點的話(沒節點說明以前的按位與的計算結果都是1),就從新更新一下low鏈表上 94 最後一個節點的next指針指向null。這步操做很重要,由於若是以前這個節點不是 95 舊數組桶上的最後一個節點的話,next是有值的。不改爲null的話指針指向就亂了 96 */
 97                        loTail.next = null;
 98                        //將鏈表插入到j位置
 99                        newTab[j] = loHead;
100                    }
101                    if (hiTail != null) {
102                        /* 103 若是high鏈表有節點的話(沒節點說明以前的按位與的計算結果都是0),就從新更新一下high鏈表上 104 最後一個節點的next指針指向null。這步操做很重要,由於若是以前這個節點不是 105 舊數組桶上的最後一個節點的話,next是有值的。不改爲null的話指針指向就亂了 106 */
107                        hiTail.next = null;
108                        //將鏈表插入到j+舊數組容量的位置
109                        newTab[j + oldCap] = hiHead;
110                    }
111                }
112            }
113        }
114    }
115    return newTab;
116}

在Java 7的HashMap源碼中,transfer方法是用來作擴容時的遷移數據操做的。其實現就是經過遍歷鏈表中的每個節點,從新rehash實現的。在這其中會涉及到指針的修改,在高併發的場景下,可能會使鏈表上的一個節點的下一個指針指向了其前一個節點,也就是造成了死循環,死鏈(具體造成過程再也不展開):

這樣再去遍歷鏈表的時候就永遠不會停下來,出現了bug。而Java 8中經過造成兩個鏈表,節點hash值在數組容量二進制數爲1的那個位置處去按位與判斷是0仍是1,以此來選擇插入的方式很好地解決了這個問題,並且不用每個節點rehash,提升了執行速度。

既然說到了不用rehash,那麼這裏想要探究一下在數組擴容時,選擇新插入數組的位置是原位置和原位置+舊數組容量,爲何這裏加上的是舊數組容量呢?加別的值不能夠嗎?其實這裏加舊數組容量是有緣由的。咱們都知道,數組容量必須是2的冪,即100…000,而新數組的容量是原數組的2倍,也就是把原來值中的「1」往左移了一位,而咱們在判斷插入桶位置時用的方式是(數組容量 - 1)& hash值。把這些信息都整合一下,咱們就知道在新數組中計算桶位置和在舊數組中計算桶位置的差別,其實就在於舊數組二進制爲1上的這位上。若是該位是0,那就是和原來舊數組是同一個位置,若是是1,就是舊數組位置處+舊數組的容量。下面舉個例子:

兩個節點此時計算出來桶的位置都是1010,即第10個桶。它們都會被放在第10個桶中的鏈表中。

而如今數組擴容了,數組容量變爲了32,咱們再來看看結果會怎樣:

這時候發現(n - 1) & hash的結果不同了,節點1是01010,節點2是11010。也就是說,咱們在get方法執行的時候(get方法也是經過(n - 1) & hash的方式來找到桶的位置),找到節點1是在第10個桶,節點2是在第26個桶,這兩個節點之間相差16個桶,這不就是舊數組的容量嗎?如今是否是恍然大悟了,咱們當初在擴容時,將節點的hash值和舊數組容量進行按位與,其實也就是在找上圖紅框框中的那個位置。若是爲0,就將節點1放在新數組中第10個桶中(1010),也就是原位置處;若是爲1,就將節點2放在新數組中第26個桶中(1010+10000),也就是原位置+舊數組容量處。這樣作的話,我在get方法執行的時候也能保證正確執行,能正確找到節點在新數組中桶的位置。同時也說明了,這個放入的策略是不能換的。也就是說,不能是若是爲1的話最後就會插入到新數組的原位置,爲0就插入到原位置+舊數組容量的位置。若是這麼作的話,最後get方法在查找該節點的時候,就找不到了(而實際上還存在)。因此經過Java 8中的這種擴容方式,既能計算出正確的新桶位置,又能避免每個節點的rehash,節省計算時間,實在是妙哉。


5 get方法

1/** 2 * HashMap: 3 */
 4public V get(Object key) {
 5    Node<K, V> e;
 6    //若是獲取不到值,或者自己插入的value就是null的話,就返回null,不然返回具體的value
 7    return (e = getNode(hash(key), key)) == null ? null : e.value;
 8}
 9
10final Node<K, V> getNode(int hash, Object key) {
11    Node<K, V>[] tab;
12    Node<K, V> first, e;
13    int n;
14    K k;
15    //若是數組沒有初始化,或者計算出來的桶的位置爲null(說明找不到這個key),就直接返回null
16    if ((tab = table) != null && (n = tab.length) > 0 &&
17            (first = tab[(n - 1) & hash]) != null) {
18        if (first.hash == hash &&
19                ((k = first.key) == key || (key != null && key.equals(k))))
20            /* 21 若是桶上第一個節點的hash值和要查找的hash值相同,而且key也是相同的話, 22 就直接返回(快速判斷模式) 23 */
24            return first;
25        /* 26 若是下一個節點爲null,也就是當前桶上只有一個節點的時候, 27 而且以前那個節點不是的話,那就直接返回null,也就是找不到 28 */
29        if ((e = first.next) != null) {
30            if (first instanceof TreeNode)
31                //若是節點是紅黑樹的話,就執行紅黑樹的查找節點邏輯(紅黑樹的分析本文不作展開)
32                return ((TreeNode<K, V>) first).getTreeNode(hash, key);
33            /* 34 走到這裏說明鏈表上有多個節點,遍歷鏈表上的每個節點進行查找(第一個節點不須要判斷了, 35 由於在第18行代碼處已經判斷過了) 36 */
37            do {
38                if (e.hash == hash &&
39                        ((k = e.key) == key || (key != null && key.equals(k))))
40                    return e;
41            } while ((e = e.next) != null);
42        }
43    }
44    return null;
45}

6 remove方法

1/** 2 * HashMap: 3 */
 4public V remove(Object key) {
 5    Node<K, V> e;
 6    //若是找不到要刪除的節點,就返回null,不然返回刪除的節點
 7    return (e = removeNode(hash(key), key, null, false, true)) == null ?
 8            null : e.value;
 9}
10
11final Node<K, V> removeNode(int hash, Object key, Object value, 12 boolean matchValue, boolean movable) {
13    Node<K, V>[] tab;
14    Node<K, V> p;
15    int n, index;
16    //若是數組沒有初始化,或者計算出來的桶的位置爲null(說明找不到這個key),就直接返回null
17    if ((tab = table) != null && (n = tab.length) > 0 &&
18            (p = tab[index = (n - 1) & hash]) != null) {
19        Node<K, V> node = null, e;
20        K k;
21        V v;
22        if (p.hash == hash &&
23                ((k = p.key) == key || (key != null && key.equals(k))))
24            //若是桶上第一個節點的hash值和要查找的hash值相同,而且key也是相同的話,就記錄一下該位置
25            node = p;
26        else if ((e = p.next) != null) {
27            //e是桶上第一個節點的下一個節點,若是沒有的話,也說明找不到要刪除的節點,就返回null
28            if (p instanceof TreeNode)
29                //若是節點是紅黑樹的話,就執行紅黑樹的查找節點邏輯(紅黑樹的分析本文不作展開)
30                node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
31            else {
32                /* 33 走到這裏說明鏈表上有多個節點,遍歷鏈表上的每個節點進行查找,找到了就記錄一下該位置 34 (第一個節點不須要判斷了,由於在第22行代碼處已經判斷過了) 35 */
36                do {
37                    if (e.hash == hash &&
38                            ((k = e.key) == key ||
39                                    (key != null && key.equals(k)))) {
40                        node = e;
41                        break;
42                    }
43                    //此時p記錄的是當前節點的上一個節點
44                    p = e;
45                } while ((e = e.next) != null);
46            }
47        }
48        /* 49 若是找到了要刪除的節點,而且若是matchValue爲true(matchValue爲true表明僅在value相等的狀況下才能刪除) 50 而且value相等的時候(若是matchValue爲false,就只須要判斷第一個條件node是否不爲null) 51 固然,若是不相等的話,就直接返回null,也就是不會刪除 52 */
53        if (node != null && (!matchValue || (v = node.value) == value ||
54                (value != null && value.equals(v)))) {
55            if (node instanceof TreeNode)
56                //若是節點是紅黑樹的話,就執行紅黑樹的刪除節點邏輯(紅黑樹的分析本文不作展開)
57                ((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
58            else if (node == p)
59                /* 60 若是要刪除的節點是桶上的第一個節點,就直接將當前桶的第一個位置處賦值爲下一個節點 61 (若是next爲null就是賦值爲null) 62 */
63                tab[index] = node.next;
64            else
65                //不是桶上第一個節點就將前一個節點的next指向下一個節點,也就是將node節點從鏈表中剔除掉,等待GC
66                p.next = node.next;
67            //修改次數+1(快速失敗機制)
68            ++modCount;
69            //由於是要刪除節點,因此若是找到了的話,size就-1
70            --size;
71            //鉤子方法,空實現
72            afterNodeRemoval(node);
73            return node;
74        }
75    }
76    return null;
77}

7 clear方法

1/** 2 * HashMap: 3 */
 4public void clear() {
 5    Node<K, V>[] tab;
 6    //修改次數+1(快速失敗機制)
 7    modCount++;
 8    if ((tab = table) != null && size > 0) {
 9        //size記錄的是當前有數據的桶的個數,由於這裏要清空數據,因此將size重置爲0
10        size = 0;
11        //同時將table中的每一個桶都置爲null就好了
12        for (int i = 0; i < tab.length; ++i)
13            tab[i] = null;
14    }
15}

   原創文章,未經容許,請勿轉載,更多幹貨請傳送至奇客時間

相關文章
相關標籤/搜索