高併發編程:解析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的源碼註釋中也只是說是一種折中的選擇。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章