深入理解 hash 函數、HashMap、LinkedHashMap、TreeMap

前言

Map 是非常常用的一種數據接口。在java中,提供了成熟的 Map 實現。


圖 1

最主要的實現類有 Hashtable、HashMap、LinkedHashMap和 TreeMap。在 HashTable 的子類中,還有 Properties的實現。Properties 是專門讀取配置文件的類,我們會在稍後介紹。這裏首先值得關注的是 HashMap 和 HashTable 兩套不同的實現,兩者都實現了 Map 接口。從表面上看,並沒有多大差別,但是在內部實現上卻有些微小的細節。

首先,HashTable 的大部分方法都做了同步,而 HashMap 沒有,因此, HashMap 不是線程安全的。
其次,HashTable 不允許 key 或者 value 使用 null 值,而 HashMap 可以。
第三,在內部實現算法上,它們對 key 的 hash 算法和 hash 值到內存索引的映射算法不同。

雖然有諸多不同,但是他們的性能確實相差無幾。由於 HashMap 使用廣泛性,現以 HashMap 爲例,闡述它的實現機理。

 

1、HashMap 的實現原理

HashMap 內部維護一個數組,並且將 key 做 hash 算法,然後將 hash 值映射到內存地址,即數組的下標索引,這樣就可以通過key直接取到所對應的數據。而對於發生碰撞的位置,則會維護一個鏈表,所有在同一位置發生碰撞的元素都會存放在同一位置的鏈表中。


圖 2

如圖 2,數組中的每一個元素都是一個 Entry 實例:

static class Entry<k,v> implements Map.Entry<k,v> {
    final K key;
    V value;
    Entry<k,v> next;
    int hash;
    //.....省略部分
}</k,v></k,v></k,v>

每一個實例都包含 元素key, 元素value , 元素hash值,以及指向下一個在當前位置發生衝突的 Entry實例。

 

2、Put 方法詳細解析

下面我們來看一下最基本的put 操作。

/*
 * 將(key, value)放入 map
 */
public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    // 計算 key 對應的下標 。關於 hash 和 indexFor 方法,我們會在後面講到。
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    // 如果發生了衝突,那麼就遍歷當前衝突位置的鏈表。如果在鏈表中發現該元素已經存在(即兩元素的 key 和 hash
    // 值一樣),則用新值替換原來的值,並返回原來的值。
    for (Entry<k, v=""> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            // 將該元素的訪問存入歷史記錄中(在LinkedHashMap才發揮作用)
            e.recordAccess(this);
            return oldValue;
        }
    }
    // 標誌容器被修改次數的計數器,在使用迭代器遍歷時起作用
    modCount++;
    // 爲新值創建一個新元素,並添加到數組中
    addEntry(hash, key, value, i);
    return null;
}
 
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果數組需要擴容,則進行擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    // 創建新元素並添加到數組中
    createEntry(hash, key, value, bucketIndex);
}
 
/*
 * 創建新元素,並將該新元素加到下標位置的最前端,該新元素的next引用指向該位置原來的元素(如果有)
 */
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<k, v=""> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}</k,></k,>

應經加上了詳細的註釋,相信大家都能讀得懂。同樣的,get 操作就比這個簡單多了,筆者就不再囉嗦了,下面直接將最關鍵的部分。

 

3、HashMap 的核心算法-hash 函數的實現

HashMap的高性能需要保證以下幾點:

1、hash 算法必須是高效的
2、hash 值到內存地址(數組索引)的算法是快速的
3、根據內存地址(數組索引)可以直接取得對應的值

首先來看第一點,hash 算法的高效性,在 HashMap 中,put() 方法和 hash 算法有關代碼如下:

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        //...........省略部分
    }

final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
 
        h ^= k.hashCode();
 
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

HashMap的功能是通過“鍵(key)”能夠快速的找到“值”。下面我們分析下HashMap計算下標索引的思路:

1、 當調用put(key,value)時,首先獲取key的hashcode,int hash = key.hashCode();
2、 再把hash通過一下運算得到一個int h。

hash ^= (hash >>> 20) ^ (hash >>> 12);
int h = hash ^ (hash >>> 7) ^ (hash >>> 4);

爲什麼要經過這樣的運算呢?這就是HashMap的高明之處。先看個例子,一個十進制數32768(二進制1000 0000 0000 0000),經過上述公式運算之後的結果是35080(二進制1000 1001 0000 1000)。看出來了嗎?或許這樣還看不出什麼,再舉個數字61440(二進制1111 0000 0000 0000),運算結果是65263(二進制1111 1110 1110 1111),現在應該很明顯了,它的目的是讓“1”變的均勻一點,散列的本意就是要儘量均勻分佈。假設key.hashCode()的值爲:0x7FFFFFFF, table.length爲默認值16。它的詳細運行過程如下圖 3 所示。

圖 3

3、 得到h之後,下一步便要解決,怎樣通過 h ,得到元素的數組下標。

前面說過hashmap的底層採用數組盛放數據,所以我們當然希望這個hashmap裏面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個,這樣當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。 所以我們首先想到最簡單的辦法就是把hashcode對數組長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。這也是 HashTable 採用的策略,HashTable中的算法只是把key的 hashcode與length相除取餘,即hash % length,這樣有可能會造成index分佈不均勻。而且,“模”運算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中是這樣做的:

/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

將取得的 h 跟數組的長度-1做一次“與”運算(&)。看上去很簡單,其實比較有玄機。比如數組的長度是2的4次方,那麼hashcode就會和2的4次方-1做“與”運算。很多人都有這個疑問,爲什麼hashmap的數組初始化大小都是2的次方大小時,hashmap的效率最高,我以2的4次方舉例,來解釋一下爲什麼數組大小爲2的冪時hashmap訪問的性能最高

看下圖 4,左邊兩組是數組長度爲16(2的4次方),右邊兩組是數組長度爲15。兩組的hashcode均爲8和9,但是很明顯,當它們和1110“與”的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到同一個鏈表上,那麼查詢的時候就需要遍歷這個鏈表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度爲15的時候,hashcode的值會與14(1110)進行“與”,那麼最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個末尾都爲1 的位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的機率,減慢了查詢的效率!


圖 4

所以說,當數組長度爲2的n次冪的時候,不同的key算得得index相同的機率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

說到這裏,我們再回頭看一下hashmap中默認的數組大小是多少,查看源代碼可以得知是16,爲什麼是16,而不是15,也不是20呢,看到上面annegu的解釋之後我們就清楚了吧,顯然是因爲16是2的整數次冪的原因,在小數據量的情況下16比15和20更能減少key之間的碰撞,而加快查詢的效率。

所以,在存儲大容量數據的時候,最好預先指定hashmap的size爲2的整數次冪次方。就算不指定的話,也會以大於且最接近指定值大小的2次冪來初始化的。

 

4、 HashMap 的 resize() 性能瓶頸

當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因爲數組的長度是固定的),所以爲了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑。在hashmap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。

那麼hashmap什麼時候進行擴容呢?當hashmap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展爲2*16=32,即擴大一倍,然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知hashmap中元素的個數,那麼預設元素的個數能夠有效的提高hashmap的性能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設置爲1024。 但是new HashMap(1024)還不是更合適的,因爲0.75*1000 < 1000, 也就是說爲了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。

 

總結:

本文主要描述了HashMap的結構,put 操作的詳細實現,hashmap中hash函數的實現,以及該實現的特性,同時描述了hashmap中resize帶來性能消耗的根本原因。尤其是hash函數的實現,可以說是整個HashMap的精髓所在,只有真正理解了這個hash函數,纔可以說對HashMap有了一定的理解。


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