全網把Map中的hash()分析的最透徹的文章,別無二家。

你知道HashMap中hash方法的具體實現嗎?你知道HashTable、ConcurrentHashMap中hash方法的實現以及緣由嗎?你知道爲何要這麼實現嗎?你知道爲何JDK 7和JDK 8中hash方法實現的不一樣以及區別嗎?若是你不能很好的回答這些問題,那麼你須要好好看看這篇文章。文中涉及到大量代碼和計算機底層原理知識。絕對的乾貨滿滿。整個互聯網,把hash()分析的如此透徹的,別無二家。git

哈希

Hash,通常翻譯作「散列」,也有直接音譯爲「哈希」的,就是把任意長度的輸入,經過散列算法,變換成固定長度的輸出,該輸出就是散列值。 這種轉換是一種壓縮映射,也就是,散列值的空間一般遠小於輸入的空間,不一樣的輸入可能會散列成相同的輸出,因此不可能從散列值來惟一的肯定輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數。程序員

全部散列函數都有以下一個基本特性:根據同一散列函數計算出的散列值若是不一樣,那麼輸入值確定也不一樣。可是,根據同一散列函數計算出的散列值若是相同,輸入值不必定相同。github

兩個不一樣的輸入值,根據同一散列函數計算出的散列值相同的現象叫作碰撞。算法

常見的Hash函數有如下幾個:數組

直接定址法:直接以關鍵字k或者k加上某個常數(k+c)做爲哈希地址。安全

數字分析法:提取關鍵字中取值比較均勻的數字做爲哈希地址。數據結構

除留餘數法:用關鍵字k除以某個不大於哈希表長度m的數p,將所得餘數做爲哈希表地址。函數

分段疊加法:按照哈希表地址位數將關鍵字分紅位數相等的幾部分,其中最後一部分能夠比較短。而後將這幾部分相加,捨棄最高進位後的結果就是該關鍵字的哈希地址。性能

平方取中法:若是關鍵字各個部分分佈都不均勻的話,能夠先求出它的平方值,而後按照需求取中間的幾位做爲哈希地址。優化

僞隨機數法:採用一個僞隨機數看成哈希函數。

上面介紹過碰撞。衡量一個哈希函數的好壞的重要指標就是發生碰撞的機率以及發生碰撞的解決方案。任何哈希函數基本都沒法完全避免碰撞,常見的解決碰撞的方法有如下幾種:

  • 開放定址法:
    • 開放定址法就是一旦發生了衝突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。
  • 鏈地址法
    • 將哈希表的每一個單元做爲鏈表的頭結點,全部哈希地址爲i的元素構成一個同義詞鏈表。即發生衝突時就把該關鍵字鏈在以該單元爲頭結點的鏈表的尾部。
  • 再哈希法
    • 當哈希地址發生衝突用其餘的函數計算另外一個哈希函數地址,直到衝突再也不產生爲止。
  • 創建公共溢出區
    • 將哈希表分爲基本表和溢出表兩部分,發生衝突的元素都放入溢出表中。

HashMap 的數據結構

在Java中,保存數據有兩種比較簡單的數據結構:數組和鏈表。**數組的特色是:尋址容易,插入和刪除困難;而鏈表的特色是:尋址困難,插入和刪除容易。**上面咱們提到過,經常使用的哈希函數的衝突解決辦法中有一種方法叫作鏈地址法,其實就是將數組和鏈表組合在一塊兒,發揮了二者的優點,咱們能夠將其理解爲鏈表的數組。

640

咱們能夠從上圖看到,左邊很明顯是個數組,數組的每一個成員是一個鏈表。該數據結構所容納的全部元素均包含一個指針,用於元素間的連接。咱們根據元素的自身特徵把元素分配到不一樣的鏈表中去,反過來咱們也正是經過這些特徵找到正確的鏈表,再從鏈表中找出正確的元素。其中,根據元素特徵計算元素數組下標的方法就是哈希算法,即本文的主角hash()函數(固然,還包括indexOf()函數)。

hash方法

咱們拿JDK 1.7的HashMap爲例,其中定義了一個final int hash(Object k) 方法,其主要被如下方法引用。

hash-use

上面的方法主要都是增長和刪除方法,這不難理解,當咱們要對一個鏈表數組中的某個元素進行增刪的時候,首先要知道他應該保存在這個鏈表數組中的哪一個位置,即他在這個數組中的下標。而hash()方法的功能就是根據Key來定位其在HashMap中的位置。HashTable、ConcurrentHashMap同理。

源碼解析

首先,在同一個版本的Jdk中,HashMap、HashTable以及ConcurrentHashMap裏面的hash方法的實現是不一樣的。再不一樣的版本的JDK中(Java7 和 Java8)中也是有區別的。我會盡可能所有介紹到。相信,看文這篇文章,你會完全理解hash方法。

在上代碼以前,咱們先來作個簡單分析。咱們知道,hash方法的功能是根據Key來定位這個K-V在鏈表數組中的位置的。也就是hash方法的輸入應該是個Object類型的Key,輸出應該是個int類型的數組下標。若是讓你設計這個方法,你會怎麼作?

其實簡單,咱們只要調用Object對象的hashCode()方法,該方法會返回一個整數,而後用這個數對HashMap或者HashTable的容量進行取模就好了。沒錯,其實基本原理就是這個,只不過,在具體實現上,由兩個方法int hash(Object k)int indexFor(int h, int length)來實現。可是考慮到效率等問題,HashMap的實現會稍微複雜一點。

hash :該方法主要是將Object轉換成一個整型。

indexFor :該方法主要是將hash生成的整型轉換成鏈表數組中的下標。

HashMap In Java 7

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方法其實主要是將hash生成的整型轉換成鏈表數組中的下標。那麼return h & (length-1);是什麼意思呢?其實,他就是取模。Java之全部使用位運算(&)來代替取模運算(%),最主要的考慮就是效率。位運算(&)效率要比代替取模運算(%)高不少,主要緣由是位運算直接對內存數據進行操做,不須要轉成十進制,所以處理速度很是快。

那麼,爲何可使用位運算(&)來實現取模運算(%)呢?這實現的原理以下:

X % 2^n = X & (2^n - 1)

2^n表示2的n次方,也就是說,一個數對2^n取模 == 一個數和(2^n - 1)作按位與運算 。

假設n爲3,則2^3 = 8,表示成2進制就是1000。2^3 = 7 ,即0111。

此時X & (2^3 - 1) 就至關於取X的2進制的最後三位數。

從2進制角度來看,X / 8至關於 X >> 3,即把X右移3位,此時獲得了X / 8的商,而被移掉的部分(後三位),則是X % 8,也就是餘數。

上面的解釋不知道你有沒有看懂,沒看懂的話其實也不要緊,你只須要記住這個技巧就能夠了。或者你能夠找幾個例子試一下。

6 % 8 = 6 ,6 & 7 = 6

10 & 8 = 2 ,10 & 7 = 2

640 (1)

因此,return h & (length-1);只要保證length的長度是2^n的話,就能夠實現取模運算了。而HashMap中的length也確實是2的倍數,初始值是16,以後每次擴充爲原來的2倍。

分析完indexFor方法後,咱們接下來準備分析hash方法的具體原理和實現。在深刻分析以前,至此,先作個總結。

HashMap的數據是存儲在鏈表數組裏面的。在對HashMap進行插入/刪除等操做時,都須要根據K-V對的鍵值定位到他應該保存在數組的哪一個下標中。而這個經過鍵值求取下標的操做就叫作哈希。HashMap的數組是有長度的,Java中規定這個長度只能是2的倍數,初始值爲16。簡單的作法是先求取出鍵值的hashcode,而後在將hashcode獲得的int值對數組長度進行取模。爲了考慮性能,Java總採用按位與操做實現取模操做。

以上,就是目前可以獲得的結論,可是,因爲HashMap使用位運算代替了取模運算,這就帶來了另一個問題,那就是有可能發生衝突。好比:CA11 00000001 0000在對0000 1111進行按位與運算後的值是相等的。 640 (2)

兩個不一樣的鍵值,在對數組長度進行按位與運算後獲得的結果相同,這不就發生了衝突嗎。那麼如何解決這種衝突呢,來看下Java是如何作的。

其中的主要代碼部分以下:

h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
複製代碼

這段代碼是爲了對key的hashCode進行擾動計算,防止不一樣hashCode的高位不一樣但低位相同致使的hash衝突。簡單點說,就是爲了把高位的特徵和低位的特徵組合起來,下降哈希衝突的機率,也就是說,儘可能作到任何一位的變化都能對最終獲得的結果產生影響。

舉個例子來講,咱們如今想向一個HashMap中put一個K-V對,Key的值爲「hollischuang」,通過簡單的獲取hashcode後,獲得的值爲「1011000110101110011111010011011」,若是當前HashTable的大小爲16,即在不進行擾動計算的狀況下,他最終獲得的index結果值爲11。因爲16的二進制擴展到32位爲「00000000000000000000000000001111」,因此,一個數字在和他進行按位與操做的時候,前28位不管是什麼,計算結果都同樣(由於0和任何數作與,結果都爲0)。以下圖所示。

640 (3)

能夠看到,後面的兩個hashcode通過位運算以後獲得的值也是11 ,雖然咱們不知道哪一個key的hashcode是上面例子中的那兩個,可是確定存在這樣的key,這就產生了衝突。

那麼,接下來,我看看一下通過擾動的算法最終的計算結果會如何。

640 (4)

從上面圖中能夠看到,以前會產生衝突的兩個hashcode,通過擾動計算以後,最終獲得的index的值不同了,這就很好的避免了衝突。

其實,使用位運算代替取模運算,除了性能以外,還有一個好處就是能夠很好的解決負數的問題。由於咱們知道,hashcode的結果是int類型,而int的取值範圍是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];這裏面是包含負數的,咱們知道,對於一個負數取模仍是有些麻煩的。若是使用二進制的位運算的話就能夠很好的避免這個問題。首先,無論hashcode的值是正數仍是負數。length-1這個值必定是個正數。那麼,他的二進制的第一位必定是0(有符號數用最高位做爲符號位,「0」表明「+」,「1」表明「-」),這樣裏兩個數作按位與運算以後,第一位必定是個0,也就是,獲得的結果必定是個正數。

HashTable In Java 7

上面是Java 7中HashMap的hash方法以及indexOf方法的實現,那麼接下來咱們要看下,線程安全的HashTable是如何實現的,和HashMap有何不一樣,並試着分析下不一樣的緣由。如下是Java 7中HashTable的hash方法的實現。

private int hash(Object k) {
    // hashSeed will be zero if alternative hashing is disabled.
    return hashSeed ^ k.hashCode();
}
複製代碼

咱們能夠發現,很簡單,至關於只是對k作了個簡單的hash,取了一下其hashCode。而HashTable中也沒有indexOf方法,取而代之的是這段代碼:int index = (hash & 0x7FFFFFFF) % tab.length;。也就是說,HashMap和HashTable對於計算數組下標這件事,採用了兩種方法。HashMap採用的是位運算,而HashTable採用的是直接取模。

爲啥要把hash值和0x7FFFFFFF作一次按位與操做呢,主要是爲了保證獲得的index的第一位爲0,也就是爲了獲得一個正數。由於有符號數第一位0表明正數,1表明負數。

咱們前面說過,HashMap之因此不用取模的緣由是爲了提升效率。有人認爲,由於HashTable是個線程安全的類,原本就慢,因此Java並無考慮效率問題,就直接使用取模算法了呢?可是其實並不徹底是,Java這樣設計仍是有必定的考慮在的,雖然這樣效率確實是會比HashMap慢一些。

其實,HashTable採用簡單的取模是有必定的考慮在的。這就要涉及到HashTable的構造函數和擴容函數了。因爲篇幅有限,這裏就不貼代碼了,直接給出結論:

HashTable默認的初始大小爲11,以後每次擴充爲原來的2n+1。

也就是說,HashTable的鏈表數組的默認大小是一個素數、奇數。以後的每次擴充結果也都是奇數。

因爲HashTable會盡可能使用素數、奇數做爲容量的大小。當哈希表的大小爲素數時,簡單的取模哈希的結果會更加均勻。(這個是能夠證實出來的,因爲不是本文重點,暫不詳細介紹,可參考:http://zhaox.github.io/algorithm/2015/06/29/hash)

至此,咱們看完了Java 7中HashMap和HashTable中對於hash的實現,咱們來作個簡單的總結。

  • HashMap默認的初始化大小爲16,以後每次擴充爲原來的2倍。
  • HashTable默認的初始大小爲11,以後每次擴充爲原來的2n+1。
  • 當哈希表的大小爲素數時,簡單的取模哈希的結果會更加均勻,因此單從這一點上看,HashTable的哈希表大小選擇,彷佛更高明些。由於hash結果越分散效果越好。
  • 在取模計算時,若是模數是2的冪,那麼咱們能夠直接使用位運算來獲得結果,效率要大大高於作除法。因此從hash計算的效率上,又是HashMap更勝一籌。
  • 可是,HashMap爲了提升效率使用位運算代替哈希,這又引入了哈希分佈不均勻的問題,因此HashMap爲解決這問題,又對hash算法作了一些改進,進行了擾動計算。

ConcurrentHashMap In Java 7

private int hash(Object k) {
    int h = hashSeed;

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

    h ^= k.hashCode();

    // Spread bits to regularize both segment and index locations,
    // using variant of single-word Wang/Jenkins hash.
    h += (h <<  15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h <<   3);
    h ^= (h >>>  6);
    h += (h <<   2) + (h << 14);
    return h ^ (h >>> 16);
}

int j = (hash >>> segmentShift) & segmentMask;
複製代碼

上面這段關於ConcurrentHashMap的hash實現其實和HashMap一模一樣。都是經過位運算代替取模,而後再對hashcode進行擾動。區別在於,ConcurrentHashMap 使用了一種變種的Wang/Jenkins 哈希算法,其主要母的也是爲了把高位和低位組合在一塊兒,避免發生衝突。至於爲啥不和HashMap採用一樣的算法進行擾動,我猜這只是程序員自由意志的選擇吧。至少我目前沒有辦法證實哪一個更優。

HashMap In Java 8

在Java 8 以前,HashMap和其餘基於map的類都是經過鏈地址法解決衝突,它們使用單向鏈表來存儲相同索引值的元素。在最壞的狀況下,這種方式會將HashMap的get方法的性能從O(1)下降到O(n)。爲了解決在頻繁衝突時hashmap性能下降的問題,Java 8中使用平衡樹來替代鏈表存儲衝突的元素。這意味着咱們能夠將最壞狀況下的性能從O(n)提升到O(logn)。關於HashMap在Java 8中的優化,我後面會有文章繼續深刻介紹。

若是惡意程序知道咱們用的是Hash算法,則在純鏈表狀況下,它可以發送大量請求致使哈希碰撞,而後不停訪問這些key致使HashMap忙於進行線性查找,最終陷入癱瘓,即造成了拒絕服務攻擊(DoS)。

關於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)來獲得該對象在數據中保存的位置。

HashTable In Java 8

在Java 8的HashTable中,已經不在有hash方法了。可是哈希的操做仍是在的,好比在put方法中就有以下實現:

int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
複製代碼

這其實和Java 7中的實現幾乎無差異,就不作過多的介紹了。

ConcurrentHashMap In Java 8

Java 8 裏面的求hash的方法從hash改成了spread。實現方式以下:

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}
複製代碼

Java 8的ConcurrentHashMap一樣是經過Key的哈希值與數組長度取模肯定該Key在數組中的索引。一樣爲了不不太好的Key的hashCode設計,它經過以下方法計算獲得Key的最終哈希值。不一樣的是,Java 8的ConcurrentHashMap做者認爲引入紅黑樹後,即便哈希衝突比較嚴重,尋址效率也足夠高,因此做者並未在哈希值的計算上作過多設計,只是將Key的hashCode值與其高16位做異或並保證最高位爲0(從而保證最終結果爲正整數)。

總結

至此,咱們已經分析完了HashMap、HashTable以及ConcurrentHashMap分別在Jdk 1.7 和 Jdk 1.8中的實現。咱們能夠發現,爲了保證哈希的結果能夠分散、爲了提升哈希的效率,JDK在一個小小的hash方法上就有不少考慮,作了不少事情。固然,我但願咱們不只能夠深刻了解背後的原理,還要學會這種對代碼精益求精的態度。

Jdk的源代碼,每一行都頗有意思,都值得花時間去鑽研、推敲。

參考資料

哈希表(HashTable)的構造方法和衝突解決

HashMap的數據結構

HashMap和HashTable到底哪不一樣?

知乎問題中 @二大王 和 @Anra的答案

相關文章
相關標籤/搜索