1. 程式人生 > >高併發程式設計:解析HashMap

高併發程式設計:解析HashMap

底層實現原理

在JDK1.8以前版本中,HashMap的實現是陣列+連結串列,它的缺點是即使雜湊函式選擇的再好,也很難達到元素百分百均勻分佈,而且當HashMap中有大量元素都存到同一個桶中時,這個桶會有一個很長的連結串列,此時遍歷的時間複雜度就是O(n),當然這是最糟糕的情況。

在JDK1.8及以後的版本中引入了紅黑樹結構,HashMap的實現就變成了陣列+連結串列或陣列+紅黑樹。新增元素時,若桶中連結串列個數超過8,連結串列會轉換成紅黑樹;刪除元素、擴容時,若桶中結構為紅黑樹並且樹中元素個數較少時會進行修剪或直接還原成連結串列結構,以提高後續操作效能;遍歷、查詢時,由於使用紅黑樹結構,紅黑樹遍歷的時間複雜度為 O(logn),所以效能得到提升。

HashMap在JDK1.8及以後的版本中引入了紅黑樹結構,若桶中連結串列元素個數大於等於8時,連結串列轉換成樹結構;若桶中連結串列元素個數小於等於6時,樹結構還原成連結串列。因為紅黑樹的平均查詢長度是log(n),長度為8的時候,平均查詢長度為3,如果繼續使用連結串列,平均查詢長度為8/2=4,這才有轉換為樹的必要。連結串列長度如果是小於等於6,6/2=3,雖然速度也很快的,但是轉化為樹結構和生成樹的時間並不會太短。

選擇6和8,中間有個差值7可以有效防止連結串列和樹頻繁轉換。假設一下,如果設計成連結串列個數超過8則連結串列轉換成樹結構,連結串列個數小於8則樹結構轉換成連結串列,如果一個HashMap不停的插入、刪除元素,連結串列個數在8左右徘徊,就會頻繁的發生樹轉連結串列、連結串列轉樹,效率會很低。

死迴圈分析

在JDK1.8之前的版本中,HashMap的底層實現是陣列+連結串列。當呼叫HashMap的put方法新增元素時,如果新元素的hash值或key在原Map中不存在,會檢查容量size有沒有超過設定的threshold,如果超過則需要進行擴容,擴容的容量是原陣列的兩倍,具體程式碼如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
        //檢查容量是否超過threshold
        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);
    }

擴容就是新建Entry陣列,並將原Map中元素重新計算hash值,然後存到新陣列中,具體程式碼如下:

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];
        //原陣列元素轉存到新陣列中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        //指向新陣列
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    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;
            }
        }
    }

假設一個HashMap的初始容量是4,使用預設負載因子0.75,有三個元素通過Hash演算法計算出的陣列下標都是2,但是key值都不同,分別是a1、a2、a3,HashMap內部儲存如下圖:

假設插入的第四個元素a4,通過Hash演算法計算出的陣列下標也是2,當插入時則需要擴容,此時有兩個執行緒T1、T2同時插入a4,則T1、T2同時進行擴容操作,它們各自新建了一個Entry陣列newTable。

T2執行緒執行到transfer方法的Entry<K,V> next = e.next;時被掛起,T1執行緒執行transfer方法後Entry陣列如下圖:

在T1執行緒沒返回新建Entry陣列之前,T2執行緒恢復,因為在T2掛起時,變數e指向的是a1,變數next指向的是a2,所以在T2恢復執行完transfer之後,Entry陣列如下圖:在此我向大家推薦一個架構學習交流裙。交流學習裙號:821169538,裡面會分享一些資深架構師錄製的視訊錄影 

可以看到在T2執行完transfer方法後,a1元素和a2元素形成了迴圈引用,此時無論將T1的Entry陣列還是T2的Entry陣列返回作為擴容後的新陣列,都會存在這個環形連結串列,當呼叫get方法獲取該位置的元素時就會發生死迴圈,更嚴重會導致CPU佔用100%故障。

擴容解說

JDK8中HashMap擴容涉及到的載入因子和連結串列轉紅黑樹的知識點經常被作為面試問答題,下面對這兩個知識點進行小結。

連結串列轉紅黑樹為什麼選擇數字8

在JDK8及以後的版本中,HashMap引入了紅黑樹結構,其底層的資料結構變成了陣列+連結串列或陣列+紅黑樹。新增元素時,若桶中連結串列個數超過8,連結串列會轉換成紅黑樹。之前有寫過篇幅分析選擇數字8的原因,內容不夠嚴謹。最近重新翻了一下HashMap的原始碼,發現其原始碼中有這樣一段註釋:

Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFYTHRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poissondistribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-pow(0.5, k) / factorial(k)). The first values are: 
0: 0.60653066 
1: 0.30326533 
2: 0.07581633 
3: 0.01263606 
4: 0.00157952 
5: 0.00015795 
6: 0.00001316 
7: 0.00000094 
8: 0.00000006 
more: less than 1 in ten million

翻譯過來大概的意思是:理想情況下使用隨機的雜湊碼,容器中節點分佈在hash桶中的頻率遵循泊松分佈,具體可以檢視泊松分佈,按照泊松分佈的計算公式計算出了桶中元素個數和概率的對照表,可以看到連結串列中元素個數為8時的概率已經非常小,再多的就更少了,所以原作者在選擇連結串列元素個數時選擇了8,是根據概率統計而選擇的。

預設載入因子為什麼選擇0.75

HashMap有兩個引數影響其效能:初始容量和載入因子。容量是雜湊表中桶的數量,初始容量只是雜湊表在建立時的容量。載入因子是雜湊表在其容量自動擴容之前可以達到多滿的一種度量。當雜湊表中的條目數超出了載入因子與當前容量的乘積時,則要對該雜湊表進行擴容、rehash操作(即重建內部資料結構),擴容後的雜湊表將具有兩倍的原容量。

通常,載入因子需要在時間和空間成本上尋求一種折衷。載入因子過高,例如為1,雖然減少了空間開銷,提高了空間利用率,但同時也增加了查詢時間成本;載入因子過低,例如0.5,雖然可以減少查詢時間成本,但是空間利用率很低,同時提高了rehash操作的次數。在設定初始容量時應該考慮到對映中所需的條目數及其載入因子,以便最大限度地減少rehash操作次數,所以,一般在使用HashMap時建議根據預估值設定初始容量,減少擴容操作。

選擇0.75作為預設的載入因子,完全是時間和空間成本上尋求的一種折衷選擇,至於為什麼不選擇0.5或0.8,筆者沒有找到官方的直接說明,在HashMap的原始碼註釋中也只是說是一種折中的選擇。