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

HollisChuang發表於2018-03-27

你知道HashMap中hash方法的具體實現嗎?你知道HashTable、ConcurrentHashMap中hash方法的實現以及原因嗎?你知道為什麼要這麼實現嗎?你知道為什麼JDK 7和JDK 8中hash方法實現的不同以及區別嗎?如果你不能很好的回答這些問題,那麼你需要好好看看這篇文章。文中涉及到大量程式碼和計算機底層原理知識。絕對的乾貨滿滿。整個網際網路,把hash()分析的如此透徹的,別無二家。

雜湊

Hash,一般翻譯做“雜湊”,也有直接音譯為“雜湊”的,就是把任意長度的輸入,通過雜湊演算法,變換成固定長度的輸出,該輸出就是雜湊值。 這種轉換是一種壓縮對映,也就是,雜湊值的空間通常遠小於輸入的空間,不同的輸入可能會雜湊成相同的輸出,所以不可能從雜湊值來唯一的確定輸入值。簡單的說就是一種將任意長度的訊息壓縮到某一固定長度的訊息摘要的函式。

所有雜湊函式都有如下一個基本特性:根據同一雜湊函式計算出的雜湊值如果不同,那麼輸入值肯定也不同。但是,根據同一雜湊函式計算出的雜湊值如果相同,輸入值不一定相同。

兩個不同的輸入值,根據同一雜湊函式計算出的雜湊值相同的現象叫做碰撞。

常見的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的原始碼,每一行都很有意思,都值得花時間去鑽研、推敲。

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

參考資料

雜湊表(HashTable)的構造方法和衝突解決

HashMap的資料結構

HashMap和HashTable到底哪不同?

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

相關文章