有關HashMap的幾個小彩蛋,你想知道的全在這裏了

今天閒來無事翻HashMap的源碼,結合幾篇之前看過的帖子,發現之前看源碼時一筆帶過,其實蠻有意思的小問題點,今天就整個梳理一下,算是個總結。

hashMap的capacity和size

大家都知道hashMap是一個數組加鏈表(或紅黑樹)的結構,在初始化時數組的長度就是capacity,而容器裏面放置的<k,v>鍵值對的個數就是size,這裏還是有一點區別的。

初始容量和擴容問題

看過源碼的都知道,hashMap的初始容量是16,很多人將這解釋爲一個經驗值,我也如此理解,你如果看的是JDK1.8以後的源碼,會發現這裏的源碼多了一行註釋

    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

爲什麼好端端的不寫個16,非要寫個1<<4呢?其實這裏就是作者再告訴我們,容量的默認定義就是2次冪,不管你外界傳進來的initcapacity是多少,最終在初始化時都會轉化爲2的整數次冪來定義。
爲什麼是2次冪呢?其實是和HashMap的底層涉及有關,我們知道HashMap是一個數組➕鏈表(之後是紅黑樹)的結構,根據當前key的Hash值來確定數組中的位置,如果這個位置選擇的不好,就會導致數組有一些地方始終放不進去值,有一些地方一直在鏈式加元素,造成資源的浪費。
這個HashMap中的位置是怎麼計算的,我們再看看代碼

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
            //重點就是這一句,p代表當前存放的數組位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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);
                        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;
                }
            }
            ...

核心就是這一句

p = tab[i = (n - 1) & hash]
這裏關注下(n - 1) & hash,n是目前的數組容量,hash是當前key的hash值,看到n-1你可能已經有感覺了,我們不妨把n的各種情況模擬一下,假設n=16,hash值從0遞增

hash值 (n - 1) & hash 結果
0 1111&0000 0
1 1111&0001 1
2 1111&0010 2
3 1111&0011 3
4 1111&0100 4
14 1111&1110 14
15 1111&1111 15
16 01111&10000 0(鏈式存儲在0後面)

能發現算出來的位置基本是散列開的,很均勻。

那麼我們把n換成15試試,n-1 = 14 (1110)

hash值 (n - 1) & hash 結果
0 1110&0000 0
1 1110&0001 0
2 1110&0010 2
3 1110&0011 2
4 1110&0100 4
14 1110&1110 14
15 1110&1111 14

會發現0,2,4等位置都鏈式存儲了兩個數據,而1,3等位置始終沒有數據存儲,這就造成了浪費。
這裏面還有一個小彩蛋,其實這種計算數組位置的問題,我的第一想法就是取餘運算,那麼爲什麼這裏不用取餘呢?取餘就沒這個問題了,其實p&(q-1)就等於p%q,不信的同學可以自己驗證下,這裏這麼寫主要還是因爲&的性能要優於取餘運算,實際上位運算(&)效率要比代替取模運算(%)高很多,主要原因是位運算直接對內存數據進行操作,不需要轉成十進制,因此處理速度非常快。所以這種最底層的計算邏輯,優先考慮位運算,這樣就不難理解爲什麼如此設計了。

hash問題

每一次看源碼的時候都是理解其過程,遇到hash函數就跳過了,這一次我們把hash函數單獨拎出來品一品,其實也蠻有意思的。

final int hash(Object k) {
   int h = hashSeed;
   if (0 != h && k instanceof String) {
       return sun.misc.Hashing.stringHash32((String) k);
   }

   h ^= k.hashCode();
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

static int indexFor(int h, int length) {
   return h & (length-1);
}

indexFor就是尋找數組下標,這裏 h & (length-1)的問題之前已經講過了,下面我們着重看一些哈希的過程,就是hash這個方法。
首先明確一些概念,HashMap的數據是存儲在鏈表數組裏面的。在對HashMap進行插入/刪除等操作時,都需要根據K-V對的鍵值定位到他應該保存在數組的哪個下標中。而這個通過鍵值求取下標的操作就叫做哈希。
HashMap的數組是有長度的,Java中規定這個長度只能是2的倍數,初始值爲16。
求哈希簡單的做法是先求取出鍵值的hashcode,然後在將hashcode得到的int值對數組長度進行取模。爲了考慮性能,Java總採用按位與操作實現取模操作。
因爲按位與的計算方式,也帶出來了一些問題,那就是如果hashMap的容量不大時,很容易造成hash值的“相似低位碰撞”。

什麼叫“相似低位碰撞”?

舉個例子,假設一個hashMap的初始容量是16,length-1 = 15,二進制碼就是1111,那麼兩個高位不同而低位相同的hash值,計算之後的數組加標是相同的,比如
hash1 :0110 0011 & 0000 1111 = 0000 0011
hash2 :0101 0011 & 0000 1111 = 0000 0011
爲了避免因爲低位相同而造成的hash碰撞,進而降低底層hashMap的存儲效率,需要對hash值進行處理,也就是這一段代碼

   h ^= k.hashCode();
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);

這段代碼是爲了對key的hashCode進行擾動計算,防止不同hashCode的高位不同但低位相同導致的hash衝突。簡單點說,就是爲了把高位的特徵和低位的特徵組合起來,降低哈希衝突的概率,也就是說,儘量做到任何一位的變化都能對最終得到的結果產生影響。
舉個例子,假設目前有一個對象A的hash值是1011000110101110011111010011011,對應的數組長度是16,根據上面的描述相信你已經知道,不管這個數字的前28位是什麼,最終產生的結果都是1011,相應的也就發生了碰撞。
假設目前還有一個對象B的hash值是0000000000000000011111010011011,我們通過上面的擾動計算說明下最後的數值
在這裏插入圖片描述
從上面圖中可以看到,之前會產生衝突的兩個hashcode,經過擾動計算之後,最終得到的index的值不一樣了,這就很好的避免了衝突。
其實,使用位運算代替取模運算,除了性能之外,還有一個好處就是可以很好的解決負數的問題。因爲我們知道,hashcode的結果是int類型,而int的取值範圍是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];這裏面是包含負數的,我們知道,對於一個負數取模還是有些麻煩的。如果使用二進制的位運算的話就可以很好的避免這個問題。首先,不管hashcode的值是正數還是負數。length-1這個值一定是個正數。那麼,他的二進制的第一位一定是0(有符號數用最高位作爲符號位,“0”代表“+”,“1”代表“-”),這樣裏兩個數做按位與運算之後,第一位一定是個0,也就是,得到的結果一定是個正數。

java8的一些改變

關於Java 8中的hash函數,原理和Java 7中基本類似。Java 8中這一步做了優化,只做一次16位右位移異或混合,而不是四次,但原理是不變的。

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

在JDK1.8的實現中,優化了高位運算的算法,通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的。以上方法得到的int的hash值,然後再通過h & (table.length -1)來得到該對象在數據中保存的位置。

負載因子的問題

在HashMap中,臨界值(threshold) = 負載因子(loadFactor) * 容量(capacity)

loadFactor是裝載因子,表示HashMap滿的程度,默認值爲0.75f,也就是說默認情況下,當HashMap中元素個數達到了容量的3/4的時候就會進行自動擴容。
那麼這個值爲什麼是0.75呢?我們看一下官方給出的解釋

As a general rule, the default load factor (.75) offers a good tradeoff 
between time and space costs. 
Higher values decrease the space overhead but increase the lookup cost
 (reflected in most of the operations of the HashMap class, including get and put).

大意是什麼呢?
就是說,默認的負載因子(0.75)在時間和空間成本之間提供了很好的權衡。更高的值減少了空間開銷,但增加了查找成本(反映在HashMap類的大多數操作中,包括get和put)。
試想一下,如果我們把負載因子設置成1,容量使用默認初始值16,那麼表示一個HashMap需要在"滿了"之後纔會進行擴容。
那麼在HashMap中,最好的情況是這16個元素通過hash算法之後分別落到了16個不同的桶中,否則就必然發生哈希碰撞。而且隨着元素越多,哈希碰撞的概率越大,查找速度也會越低。
私人以爲0.75還有一個考慮,就是threshold = loadFactor * capacity,而capacity通過前文我們已經分析出來一定是2的倍數,所以這裏設置0.75可以保證threshold一定是整數。

HashMap的初始容量

通過前文可以知道初始容量在不賦值的情況下是16,如果賦值,將會初始化爲大於賦值容量的第一個2次冪,那麼假如我們在代碼上下文中清晰的知道一個hashMap會放入多少個值,這個時候能不能按照我們的想法來初始化呢?
比如我們需要往hashMap中放入7個值,於是我們設置initcapacity = 7,這時初始化的hashMap容量是大於7的第一個2次冪,也就是8,可是因爲負載因子是0.75,導致我們放入6個元素後hashMap就需要擴容,這顯然不是我們所期望的。
有沒有什麼好一點的辦法?其實可以參照guava的設計,

return (int) ((float) expectedSize / 0.75F + 1.0F);

比如我們計劃向HashMap中放入7個元素的時候,我們通過expectedSize / 0.75F + 1.0F計算,7/0.75 + 1 = 10 ,10經過JDK處理之後,會被設置成16,這就大大的減少了擴容的機率。當HashMap內部維護的哈希表的容量達到75%時(默認情況下),會觸發rehash,而rehash的過程是比較耗費時間的。所以初始化容量要設置成expectedSize/0.75 + 1的話,可以有效的減少衝突也可以減小誤差。
所以,我們可以認爲,當我們明確知道HashMap中元素的個數的時候,把默認容量設置成expectedSize / 0.75F + 1.0F 是一個在性能上相對好的選擇,但是,同時也會犧牲些內存。
這個算法在guava中有實現,開發的時候,可以直接通過Maps類創建一個HashMap:

Map<String, String> map = Maps.newHashMapWithExpectedSize(7);

其代碼實現如下:

public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {

    return new HashMap(capacity(expectedSize));

}

static int capacity(int expectedSize) {

    if (expectedSize < 3) {

        CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");

        return expectedSize + 1;

    } else {

        return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : 2147483647;

    }

}

鏈表與紅黑樹的轉換

在JDK1.8以後,爲了提升哈希碰撞後元素的查找效率,系統會在單鏈表長度大於8的時候將鏈表轉化爲一顆紅黑樹,轉化的過程大家可以看看代碼,不在這裏贅述,我想要說明的一個問題是,爲什麼這個數字是8?
在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.  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

大意是說,理想情況下,在隨機哈希碼下,存儲箱中節點的頻率遵循泊松分佈,默認大小調整閾值爲0.75時,平均參數約爲0.5,但由於大小調整的粒度,變化較大。忽略方差,列表大小k的預期出現次數是(exp(-0.5)*pow(0.5,k)/*factorial(k))
而在一個理想的hash函數下,紅黑樹模型是極少會用到的,8以內可以覆蓋絕大多數的情況。
(英語有限,大致就是這個意思吧)

參考資料:Hollis(ID:hollischuang)微信公衆號的幾篇文章

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