1. 程式人生 > >深入理解 hash 函式、HashMap、LinkedHashMap、TreeMap

深入理解 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有了一定的理解。