聊聊經典資料結構HashMap,逐行分析每一個關鍵點

雕爺發表於2020-09-27

本文基於JDK-8u261原始碼分析


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

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,又退化成連結串列…可以看到在這種場景下,會不斷地出現連結串列和紅黑樹之間的相互轉換,這個效能是很低的,因為大部分的執行時間都花費在了轉換資料結構上面,而我僅僅是做了幾次連續的增刪操作而已。所以為了避免這種情況的發生,將兩個閾值錯開一些,以此來儘量避免在閾值點附近可能存在的、頻繁地做轉換資料結構操作而導致效能變低的情況出現。

這裡之所以閾值會選擇為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}

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

相關文章