1. 程式人生 > >HashMap的擴容機制:JDK1.7,JDK1.8

HashMap的擴容機制:JDK1.7,JDK1.8

參考文章:

以前看HashMap的時候,resize擴容部分沒有仔細看,今天擠出時間,看之。

1. 確定hash桶陣列的索引位置

我們當然希望這個HashMap裡面的元素位置儘量分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用hash演算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,不用遍歷連結串列,大大優化了查詢的效率。HashMap定位陣列索引位置,直接決定了hash方法的離散效能。先看看原始碼的實現(方法一+方法二):

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 為第一步 取hashCode值
     // h ^ (h >>> 16)  為第二步 高位參與運算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) {  //jdk1.7的原始碼,jdk1.8沒有這個方法,但是實現原理一樣的
     return h & (length-1);  //第三步 取模運算
}

這裡的Hash演算法本質上就是三步:取key的hashCode值、高位運算、取模運算

第一步:顯而易見,直接取就是了。

第二步:在JDK1.8的實現中,優化了高位運算的演算法,通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在陣列table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。

第三步:不是 直接的取模運算,由於length為2的冪次,所以可以使用“與”運算 h & (length-1)代替取模運算。這樣速度更快。

2. resize()方法

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];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);  //transfer函式的呼叫
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

其實就是新建一個 newCapacity大小的陣列,然後呼叫transfer()方法將元素複製進去。

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);  //重新獲取hashcode
                e.next = newTable[i];  
                newTable[i] = e;
                e = next;
            }
        }
    }

來看看JDK1.7的trantransfer方法吧,可以看到其中是一些指標的操作,首先將 當前節點e的next節點儲存下來,然後找到在新陣列中的 下標 index, 使用頭插法插入節點, 然後 接著處理 next 節點。

這裡有兩個重要的地方:

1.  JDK1.8 當中 是如何找到新的陣列中的下標的。

圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的雜湊與高位運算結果。

可以看到, 此key 要麼在原先的index上, 要麼在 index+old_length 上,old_length為擴容前陣列的長度。

這樣做的好處:增加隨機性了,hashmap效能更好了。 效率更高了,不需要重新計算了。

2. 在JDK1.7中這樣使用頭插法在多執行緒的場景下是會出問題的,會形成一個環。

具體就是 執行緒a 一個節點插入完成後,還沒有執行 e = next ,執行緒 b 執行 next = e.next , 執行緒a此時執行 e = next,此時就變成剛剛插入節點的下一個節點了,然後呼叫 e.next = newTable[i] , 這樣第二個執行緒就會變成一個死迴圈了。

3.JDK1.8中的改變

JDK1.8中不採用頭插法了,也就不存在出現環的情況了,具體可以見下圖。