爲什麼 HashMap 鏈表長度超過8才轉爲紅黑樹?

爲什麼 HashMap 鏈表長度超過8才轉爲紅黑樹?

項目環境

1.爲什麼要轉紅黑樹?

在上一篇文章《ConcurrentHashMap 在 Java7 和 8 有何不同?》討論過這個問題,紅黑樹是一個特殊的平衡二叉樹,查找的時間複雜度是 O(logn) ;而鏈表查找元素的時間複雜度爲 O(n),遠遠大於紅黑樹的 O(logn),尤其是在節點越來越多的情況下,O(logn) 體現出的優勢會更加明顯;簡而言之就是爲了提升查詢的效率。

2.爲什麼不一開始就用紅黑樹?

那爲什麼不一開始就用紅黑樹,反而要經歷一個轉換的過程呢?其實在 JDK 的源碼註釋中已經對這個問題作了解釋:

     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins. 

單個 TreeNode 需要佔用的空間大約是普通 Node 的兩倍,所以只有當包含足夠多的 Nodes 時纔會轉成 TreeNodes,而是否足夠多就是由 TREEIFY_THRESHOLD 的值(默認值8)決定的。而當桶中節點數由於移除或者 resize 變少後,又會變回普通的鏈表的形式,以便節省空間,這個閾值是 UNTREEIFY_THRESHOLD(默認值6)。

3.轉換閾值 8 是怎麼來的?

通過查看源碼可以發現,默認是鏈表長度達到 8 就轉成紅黑樹,而當長度降到 6 就轉換回去,這體現了時間和空間平衡的思想,最開始使用鏈表的時候,空間佔用是比較少的,而且由於鏈表短,所以查詢時間也沒有太大的問題。可是當鏈表越來越長,需要用紅黑樹的形式來保證查詢的效率。對於何時應該從鏈表轉化爲紅黑樹,需要確定一個閾值,這個閾值默認爲 8,並且在源碼中也對選擇 8 這個數字做了說明,原文如下:

     * 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/Poisson_distribution) 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(-0.5) * 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

上面這段話的意思是,如果 hashCode 分佈良好,也就是 hash 計算的結果離散好的話,那麼紅黑樹這種形式是很少會被用到的,因爲各個值都均勻分佈,很少出現鏈表很長的情況。在理想情況下,鏈表長度符合泊松分佈,各個長度的命中概率依次遞減,當長度爲 8 的時候,概率僅爲 0.00000006。這是一個小於千萬分之一的概率,通常我們的 Map 裏面是不會存儲這麼多的數據的,所以通常情況下,並不會發生從鏈表向紅黑樹的轉換。

但是 HashMap 決定某一個元素落到哪一個桶裏,是和這個對象的 hashCode 有關的,JDK 並不能阻止我們用戶實現自己的哈希算法,如果我們故意把哈希算法變得不均勻,例如:

public int hashCode() {
    return 1;
}

這裏 hashCode 計算出來的值始終爲 1,那麼就很容易導致 HashMap 裏的鏈表變得很長。讓我們來看下面這段代碼:

public class HashMapTreeifyDemo {
    public static void main(String[] args) {
        Map<Key, Integer> map = new HashMap<>();
        for (int i = 0; i < 1000; i++) {
            map.put(new Key(), i);
        }

        // 斷點打折這裏
        System.out.println(map);
    }


    static class Key {
        @Override
        public int hashCode() {
            return 1;
        }
    }

}

斷點調試:
在這裏插入圖片描述
可以看到 HashMap 中的元素爲 TreeNode 類型,表示鏈表已經轉爲紅黑樹。

事實上,鏈表長度超過 8 就轉爲紅黑樹的設計,更多的是爲了防止用戶自己實現了不好的哈希算法時導致鏈表過長,從而導致查詢效率低,而此時轉爲紅黑樹更多的是一種保底策略,用來保證極端情況下查詢的效率。

4.總結

通常如果 hash 算法正常的話,那麼鏈表的長度也不會很長,那麼紅黑樹也不會帶來明顯的查詢時間上的優勢,反而會增加空間負擔。所以通常情況下,並沒有必要轉爲紅黑樹,所以就選擇了概率非常小,小於千萬分之一概率,也就是長度爲 8 的概率,把長度 8 作爲轉化的默認閾值。

所以如果平時開發中發現 HashMap 或是 ConcurrentHashMap 內部出現了紅黑樹的結構,這個時候往往就說明我們的哈希算法出了問題,需要留意是不是我們實現了效果不好的 hashCode 方法,並對此進行改進,以便減少衝突。

5.參考

  • 《Java 併發編程 78 講》- 徐隆曦
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章