天天看點

Java1.8 HashMap源碼分析

一、HashMap的特點

    HashMap是基于hash算法+數組+連結清單+紅黑樹實作的,重要性逐漸提高

    1、hash算法就是将任意長度的值通過算法轉換成固定長度的值

    2、數組最大的優點就是随機通路的時間複雜度為O(1),得到hash算法轉換後的值(下标),那麼就能實作時間複雜度為O(1)的查詢功能。

    3、O(1)的時間複雜度是建立在最理想的hash算法之上的,再好的hash算法也會存在哈希沖突,為了解決hash沖突同時滿足新增、修改、删除、擴容等高性能需求,那麼連結清單就閃亮登場了(至于為什麼不直接使用紅黑樹,可檢視細節中3)

    4、連結清單最大的缺點就是查詢的時間複雜度為O(n),如果hash算法太差,或者其他原因(例如Hash Collision Dos攻擊)導緻hash沖突嚴重,查詢性能急速下降,在Java1.7版本會導緻CPU飙升,是以Java1.8使用了紅黑樹來解決長連結清單的查詢性能問題,此時時間複雜度從O(n)下降至O(logN)

    總結:從上述可看出,hash算法決定了HashMap的性能高低,是以官方每次對HashMap的優化無非就是一方面提高hash算法的分散性(越分散,碰撞率越低,空間使用率就越高),一方面盡可能地提升hash算法極差時的穩定性(使用了連結清單、紅黑樹和擴容來保證),簡單一句話就是提高HashMap的下限和上限,那麼整體的性能就能提升了。

    提示:HashMap和ArrayList作為容器中兩大殺器,使用率基本是極高極高的,每一點點的性能提升都是值得追求的,那麼下面我們看看HashMap的細節之處,究竟是如何提高HashMap的性能的。

二、HashMap細節

    1、擾動函數hash(key)

        Java1.8對Java1.7做了優化,将多次位運算減到一次位運算 和一次異或運算,也就 是高低位異或,提高分散性,降低hash碰撞率

        可參考知乎:JDK 源碼中 HashMap 的 hash 方法原理是什麼?

    2、treeifyBin方法中如果容量小于64直接擴容

/**
 * 擴容,因為hash沖突嚴重(大于1/3)
 * 1、tab.length為32時,threshold=24(負載因子=0.75)
 * 2、單個bucket的連結清單長度已經達到9了(大于8才轉樹形結構)
 * 碰撞率>=8/24,碰撞率已經很嚴重了,直接擴容
 */
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
   resize();
           

    3、連結清單長度大于8(包括添加節點)時轉紅黑樹

        受幾個因素影響:

        ①樹節點TreeNode比Node大很多(TreeNode繼承LinkedHashMap.Entry繼承 Node,一個Entry有兩個Entry引用8個位元組,一個TreeNode有4個TreeNode引用16個位元組,1個布爾值1個位元組,而Node為8+4+4+4=20個位元組,兩者比例為45:20)

        ②連結清單查詢的平均時間複雜度為O(n),而紅黑樹的為O(logN)

        ③對于增删改,紅黑樹需要考慮保持平衡的性能消耗(旋轉)

        ④根據Poisson distribution(泊松分布),達到8的連結清單長度機率幾乎為0

        ⑤如果門檻值太小,退化為連結清單時就會存在頻繁的樹化和退化的情況

    4、擴容後紅黑樹節點小于等于6時退化為連結清單

        确定樹化門檻值為8後,退化的門檻值就相對好确定了:

        ①比樹化門檻值小是為了緩沖上面⑤的情況

    5、擴容函數

        ①最大容量為2^30

if (oldCap >= MAXIMUM_CAPACITY) {
    /**
     * threshold變成MAX_VALUE(2^31-1),随它們碰撞,但是oldCap不改變。
     * 為什麼不擴容了呢?--> 為什麼capacity擴容都是擴大一倍并且是2的幂次方呢?
     * 這裡涉及到擴容中的邏輯e.hash & (oldCap - 1) 跟空間使用率
     * 假如當容量擴容到最大值MAX_VALUE(2^31-1)時,轉換成二進制
     *            hash
     *      &
     *            0111 1111 1111 1111 1111 1111 1111 1110
     * ---------------------------------------------------
     *            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxx0
     * 從上面可看出無論hash最低位是0或者1,結果的最低位都為0,也就是下标隻有2^30個位置被使用,有
     * 2^30-1個下标沒有被使用
     * 是以當容量為MAX_VALUE(2^31-1)時會造成一半的空間浪費,效率等同于未擴容前的
     * MAXIMUM_CAPACITY(2^30)
     * 總結:如果capacity擴容後的容量不是2的幂次方,空間使用率最高不超過50%
     */
    threshold = Integer.MAX_VALUE;
    return oldTab;
}
           

        ②容量大于等于16的擴容不重新計算threshold

else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
         oldCap >= DEFAULT_INITIAL_CAPACITY)
    /**
     * 為什麼需要判斷oldCap >= DEFAULT_INITIAL_CAPACITY呢?
     * (這問題類似于數組下标為什麼從0開始?cpu計算數組位址公式baseAddress+n*typeSize和
     * baseAddress+(n-1)*typeSize為了省去cpu計算n-1的指令)
     * 當容量capacity較小時 threshold=capacity * loadFactor 造成的誤差比較大,
     * 例如初始化容量為capacity=2 * loadFactor=0.75 threshold=1,如果每次擴容threshold都翻倍,
     * 第二次:threshold=2
     * 第三次:threshold=4
     * ....
     * 那麼誤差就越來越大了,是以為了降低誤差,容量較小時還是需要重新計算threshold
     * 為什麼隻小于16呢?
     * 個人猜測是在每次擴容都計算newThr和用位運算翻倍之間做權衡
     * 因為loadFactor預設值為0.75,當capacity>=4的時候,當capacity*loadFactor是沒有小數的,
     * 是以threshold是沒有誤差的,是以如果需要設定loadFactor,
     * 最好是使得capacity(1,2,4,8)*loadFactor=整數
     * 如果 loadFactor=0.6 初始化容量capacity=8  則threshold=4
     * 下次擴容:threshold=8 capacity=16 由于capacity>=16的時候不會再次計算threshold
     * 是以,這個時候loadFactor相當于從0.6降低到了0.5
     */
    newThr = oldThr << 1; // double threshold
           

        ③擴容後位置,1.8根據hash值對應的最高位決定

if (e.next == null)
    /**
     * e.hash & (newCap - 1) 重點,要考!
     * newCap - 1 :最高位1到最低位全是1,因為容量是2的幂次方
     * newCap = oldCap*2
     * 節點在oldTab中的下标為:e.hash & (oldCap - 1)
     * 節點在newCap中的下标為:e.hash & (oldCap*2 - 1)
     * 那麼(oldCap - 1) 與(oldCap*2 - 1) 的差别就是在最高位的差别,後者比前者多一位最高位1
     * 例如32 -1 與64-1 換成二進制就明顯了
     * 那麼最終的下标就在于e.hash 對應 (oldCap*2 - 1)或者(newCap - 1) 最高位的bit是0或1了
     * 轉化為:e.hash & oldCap
     * 0下标不變,1下标=原下标+oldCap
     * 這麼做有什麼好處?
     * 1、1.7版本rehash性能低,比位運算差多了
     * 2、把原來碰撞的節點又散列了一次(非常重要,減低沖突是hash算法的畢生使命)
     */
    newTab[e.hash & (newCap - 1)] = e;
           

          ④疑問,put方法,第一個if和else為什麼不合并?

           個人覺得這解析還是比較牽強,如果有其他思路請給我留言,謝謝!

/**
 * 為什麼不跟下面的else合并?先判斷第一個連結清單元素,下面else再循環周遊連結清單?
 * Poisson distribution(泊松分布)http://en.wikipedia.org/wiki/Poisson_distribution
 * 也可參考ConcurrentHashMap中的Overview,下面就是桶中元素個數的機率:
 *  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
 * 進入此分支代碼時,說明p要麼是連結清單,要麼是樹形結構,按照上面的機率來說,桶中隻有一個元素的機率為0.3/(1-0.61)=77.69%
 * 根據此結論,多幾行代碼或許能提升一點性能,還是可以接受的
 */
if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;
else if (p instanceof TreeNode)
    // 樹結構
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
    // 周遊節點
    for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
            p.next = newNode(hash, key, value, null);
            // binCount從0開始,是以-1,大于8個才轉樹結構(上面的if也需要算)
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
            break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        p = e;
    }
}
           

           總結:雖然平時開發不用太刻意去追求一點點性能(建議不要犧牲可讀性去追求一點點性能),但是這些思路還是很值得學習的