jdk-HashMap-1.7-補充文章

劉二郎發表於2018-07-09

此篇是關於初期的一篇HashMap文章的補充文章:主要涉及兩個東西,一、擴容;二、擴容時的執行緒安全分析。

HashMap1.7

在上述篇幅裡分析了hash過程,put過程和get過程。應該來說還是比較詳細的。

一、擴容

擴容應該是HashMap內一個非常常見的問題。此篇還是基於1.7去補充下,1.8的稍微複雜了一些是由於引入了紅黑樹進去。

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

當put的時候addEntry方法記憶體在一個擴容的判斷:

1.當size>=threshold時(通俗的講就是當前個數是否大於閾值);

2.當前存在hash衝突了;

這裡需要重點分析的是第一種情況的一些特例,比如threshold,這個值的初始值來源於下面:

threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

capacity初始預設值是16,loadFactory預設是0.75,也就是threshold預設是16*0.75=12。當個數大於12時,理論上就需要擴容了。

場景1:map中的陣列初始大小是16,那麼放進去的12個資料都放在了不同的陣列內(假設是0-11的位置上),這樣,當第13個放進來的時候(如果hash之後的位置是0-11(hash衝突了)),就需要擴容了。

場景2:map中的陣列初始大小是16,那麼放進去的12個資料都放在了不同的陣列內(假設是0-11的位置上),這樣,當第13個放進來的時候(如果hash之後的位置是12(hash沒有衝突)),那麼此時是不需要擴容的。這種情況下的極端例子就是16個資料在放置的時候都依次放在了16位的陣列中(0-15),這樣當17個資料來的時候才會擴容。

那麼在最初最多能存放多少資料而不發生擴容呢?

場景3:場景3更加極端一些,初始大小是16,閾值是12,那麼假設前11個值都落到了位置0上,也就是儲存到了陣列的同一個位置上,後續存入的15個資料都依次存放在1-15中(此時資料雖然大於閾值,但是沒有發生哈市衝突,所以不擴容),當第27個資料進來時,已經沒有位置了,必定發生衝突導致擴容。,所以最大的資料是11+15=26個資料

擴容後續程式碼

resize:

1.擴容有最大值限定,2^30方。

2.transfer就是將原陣列的值放入新陣列中。

3.最後重新設定threshold(新的閾值)。

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
transfer:
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

transfer內沒什麼特殊的東西,就是重新計算hash的值在新陣列中的哪一個位置上。

這裡就引出了一個新的問題,關於transfer的執行緒安全問題,也可以說是HashMap的執行緒安全問題,大家都知道HashMap是執行緒不安全的,那麼提現在哪兒呢?一個就是put的時候,另一個就是擴容裡的transfer的時候。

put就不說了,比較容易理解。今天主要分析一下transfer的時候的執行緒安全問題;

基礎前提:

1.陣列初始大小為2

2.hash演算法取簡單的key%length 的大小。

單執行緒場景:


多執行緒場景:

多執行緒存在問題主要會是在哪裡呢?看單執行緒場景中,我們可以看見對於原陣列+連結串列的操作,存在兩個指標,一個e,一個e.next。這就是問題所在(對於連結串列的操作指標e,如果一個執行緒完整操作之後,後續執行緒再次操作時,連結串列的結構已經發生改變,那麼執行緒不安全也就無法避免)。

我們來看一看核心操作:

while(null != e) {
                Entry<K,V> next = e.next; //1
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }

問題就出現在步驟1處,假設現在存在兩個執行緒A,執行緒B同時執行put操作。執行緒A執行到步驟1時,掛了。執行緒B正常執行。

因此執行緒A和執行緒B會出現下面的場景:


執行緒A再次被喚醒繼續執行擴容:

第一次迴圈,此時e指向key=3的節點,e.next指向key=7的節點。因此最終的結果就是執行緒A的位置3指向了key=3的處於執行緒B中的節點。


第二次迴圈,注意此時e和e.next的位置變化。這個時候e指向的是key=7,對於執行緒A來說當前存在指向key=3的資料,因此,key=7的next指向了key=3的節點,而key=7就變成了執行緒A的頭節點。


第三次迴圈,注意此時e又指向了key=3的節點,而e.next指向了null節點。如果針對key=3的節點再次操作的話,如下關鍵語句:

e.next = newTable[i];
key=3的next指向了第二次迴圈時的連結串列開頭資料key=7。所以就形成了一個環形結構,table[3]->key[3]->key[7]->[3]。這就是在多執行緒下可能出現的場景。