用最簡單的大白話聊一聊面試必問的HashMap原理和部分源碼解析

HashMap在面試中經常會被問到,一定會問到它的存儲結構和實現原理,甚至可能還會問到一些源碼

今天就來看一下HashMap

首先得看一下HashMap的存儲結構和底層實現原理

如上圖所示,HashMap底層是用數組+鏈表+紅黑樹實現的,其中紅黑樹是JDK1.8對HashMap優化之後加入的,當鏈表的長度大於8的時候會由鏈表結構轉爲紅黑樹,這些等下在看源碼分析的時候都可以看到具體的實現。

那爲什麼用這幾種數據結構來實現?

這種結構在數據結構上稱爲散列鏈表,其中的數組就相當於一個一個的桶(Bucket),當有數據準備存進去的時候,它會通過一定的散列算法去計算,儘可能的讓數據平均的命中到各個桶上面去,儘可能的避免哈希碰撞。如果發生哈希碰撞,就是不同的數據最後落到了同一個桶上的時候,就採用鏈表的方式來存儲,但是鏈表長度比較長了的時候,去存儲數據,讀取數據都需要不停的去遍歷循環,所以此時再採用鏈表結構的話效率會明顯下降,所以JDK1.8之後做了優化,當鏈表的長度大於8的時候就由鏈表轉爲紅黑樹來存儲。紅黑樹是平衡二叉樹的其中一種實現,它比普通的二叉樹表現更優異,因爲普通的查詢二叉樹在一定條件下也可能會變成鏈表結構,而紅黑樹它是平衡二叉樹的一種,它是通過左旋右旋變色等保持樹的平衡。

簡單的瞭解了HashMap的存儲結構後,下面來講下HashMap其中三個方法的源碼

一、hash()方法

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

這個方法裏看似簡單,卻暗藏玄機。

它是拿到了key本身的hashCode後,又做了一次運算,先將原來的hashCode無符號右位移16位,然後再將原來的hashCode異或(^)上這個位移後的值,最後得到一個值。

補充知識:

>> 表示右移,如果該數爲正,則高位補0,若爲負數,則高位補1。

>>>表示無符號右移,也叫邏輯右移,即若該數爲正,則高位補0,而若該數爲負數,則右移後高位同樣補0。

^ 表示異或運算,每個位相同爲0,不同爲1

比如:

0 ^ 1 得 1
1 ^ 1 得 0
0 ^ 0 得 0
1 ^ 0 得 1

那爲什麼要無符號右移16位後做異或運算?key本身的hashCode直接拿來用不好嗎?

我們做一個簡單演練

將h無符號右移16爲相當於將高區16位移動到了低區的16位,再與原hashcode做異或運算,可以將高低位二進制特徵混合起來

從上文可知高區的16位與原hashcode相比沒有發生變化,低區的16位發生了變化

我們可知通過上面(h = key.hashCode()) ^ (h >>> 16)進行運算可以把高區與低區的二進制特徵混合到低區,那麼爲什麼要這麼做呢?

我們都知道重新計算出的新哈希值在後面將會參與hashmap中數組槽位的計算,計算公式:(n - 1) & hash,假如這時數組槽位有16個,則槽位計算如下:

仔細觀察上文不難發現,高區的16位很有可能會被數組槽位數的二進制碼鎖屏蔽,如果我們不做剛纔移位異或運算,那麼在計算槽位時將丟失高區特徵

也許你可能會說,即使丟失了高區特徵不同hashcode也可以計算出不同的槽位來,但是細想當兩個哈希碼很接近時,那麼這高區的一點點差異就可能導致一次哈希碰撞,所以這也是將性能做到極致的一種體現

使用異或運算的原因

 異或運算能更好的保留各部分的特徵,如果採用&運算計算出來的值會向1靠攏,採用|運算計算出來的值會向0靠攏

爲什麼槽位數必須使用2^n

1、爲了讓哈希後的結果更加均勻

這個原因我們繼續用上面的例子來說明

假如槽位數不是16,而是17,則槽位計算公式變成:(17 - 1) & hash

從上文可以看出,計算結果將會大大趨同,hashcode參加&運算後被更多位的0屏蔽,計算結果只剩下兩種0和16,這對於hashmap來說是一種災難

2、可以通過位運算e.hash & (newCap - 1)來計算,a % (2^n) 等價於 a & (2^n - 1)  ,位運算的運算效率高於算術運算,原因是算術運算還是會被轉化爲位運算

說了這麼多點,上面提到的所有問題,最終目的還是爲了讓哈希後的結果更均勻的分部,減少哈希碰撞,提升hashmap的運行效率

二、put()方法

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

這個沒什麼好講的,調用了下邊的putVal()方法

三、putVal()方法

這個方法很重要,是往hashMap裏put值的核心邏輯,下邊源碼裏的每一行我都進行了註釋

 /**
     * Implements Map.put and related methods
     *
     * @param hash hash for keyput
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        /**
         * 判斷tab是不是爲空,如果爲空,則將容量進行初始化,也就是說,初始換操作不是在new HashMap()的時候進行的,而是在第一次put的時候進行的
         */
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
 
        /**
         * 初始化操作以後,根據當前key的哈希值算出最終命中到哪個桶上去,並且這個桶上如果沒有元素的話,則直接new一個新節點放進去
         */
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
 
        /**
         * 如果對應的桶上已經有元素
         */
        else {
            Node<K,V> e; K k;
            /** 先判斷一下這個桶裏的第一個Node元素的key是不是和即將要存的key值相同,如果相同,則            
             *把當前桶裏第一個Node元素賦值給e,這個else的最下邊進行了判斷,如果e!=null就執行把
             * 新value進行替換的操作 
             */
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果和桶裏第一個Node的key不相同,則判斷當前節點是不是TreeNode(紅黑樹),如果是,則進 
            //行紅黑樹的插入
            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);
                        //判斷元素個數是不是大於等於8
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //轉換成紅黑樹
                            treeifyBin(tab, hash);
                        break;
                    }
 
                    /**
                     * 如果在遍歷的時候,發現key值相同(就是key已經存在了)就什麼都不做跳出循環。因爲在上邊e = p.next的時候,已經記錄e的Node值了,而下邊進行了判斷,如果e!=null就執行把新value進行替換的操作
                     */
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    
                    //把當前下標賦值給p並進行下一次循環
                    p = e;
                }
            }
 
            /**
              只要e不爲空,說明要插入的key已經存在了,覆蓋舊的value值,然後返回原來oldValue
              因爲只是替換了舊的value值,並沒有插入新的元素,所以不需要下邊的擴容判斷,直接 
               return掉
             */
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        /**
         * 判斷容量是否已經到了需要擴充的閾值了,如果到了,則進行擴充
         * 如果上一步已經判斷key是存在的,只是替換了value值,並沒有插入新的元素,所以不需要判斷 
         * 擴容,不會走這一步的
         */
        if (++size > threshold)
            resize();
 
        afterNodeInsertion(evict);
        return null;
    }

hashMap中還有其他的一些方法在此就不挨個來說了

可以在下方進行評論,一起學習進步

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