【轉】【JAVA】關於Java的Hash算法的深入理解

我們眼中的哈希

在計算機領域中哈希涉及的範圍非常廣泛,而且是較長使用的一種算法和數據結構,對此我們在後端開發中不斷地使用由jdk提供的方法進行使用。由於長時間的使用,很少人會去對裏面的核心進行分析和學習。HashMap是通過一個Entry的數組實現的。而Entry的結構有三個屬性,key,value,next。如果在c中,我們遇到next想到的必然是指針,其實在java這就是個指針。每次通過hashcode的值,來散列存儲數據。今天就來看下那些對於一個專業的開發者而言必要了解的東西。

引用一句百度對於哈希算法的定義:哈希算法可以將任意長度的二進制值引用爲較短的且固定長度的二進制值,把這個小的二進制值稱爲哈希值。

HashMap

HashMap是一個用於存儲Key-Value鍵值對的集合,每一個鍵值對也叫做Entry。這些個鍵值對(Entry)分散存儲在一個數組當中,這個數組就是HashMap的主幹。HashMap數組每一個元素的初始值都是Null。

HashMap的默認初始長度?爲什麼?

HashMap的初始化長度是16,負載因子是0.75,並且每次自動擴展或是手動初始化時,長度必須是2的冪。之所以選擇16,是因爲有效提供給key映射到index的Hash算法。從Key映射到HashMap數組的對應位置,會用到一個Hash函數。利用Key的HashCode值來做某種運算來達到一種儘可能均勻分佈的Hash函數,這種算法採用的是位運算的方式實現,這也是初始化長度爲16的另一個原因,使用16的長度可以比其他相對範圍內的數值運算後出現的數更獨立,減少了同一個數值出現的次數,實現了更均勻的結果。

如何進行位運算呢?有如下的公式(Length是HashMap的長度):

index = HashCode(Key) & (Length - 1)

下面我們以值爲“book”的Key來演示整個過程:

1.計算book的hashcode,結果爲十進制的3029737,二進制的101110001110101110 1001。

2.假定HashMap長度是默認的16,計算Length-1的結果爲十進制的15,二進制的1111。

3.把以上兩個結果做與運算,101110001110101110 1001 & 1111 = 1001,十進制是9,所以 index=9。

可以說,Hash算法最終得到的index結果,完全取決於Key的Hashcode值的最後幾位。

假設HashMap的長度是10,重複剛纔的運算步驟:

HashCode:10 1110 0011 1010 1110 1001
Length-1:1001
Index:   1001

單獨看這個結果,表面上並沒有問題。我們再來嘗試一個新的HashCode 101110001110101110 1011 :

HashCode:10 1110 0011 1010 1110 1011
Length-1:1001
Index:   1001

讓我們再換一個HashCode 101110001110101110 1111 試試 :

HashCode:10 1110 0011 1010 1110 1111
Length-1:1001
Index:   1001

是的,雖然HashCode的倒數第二第三位從0變成了1,但是運算的結果都是1001。也就是說,當HashMap長度爲10的時候,有些index結果的出現機率會更大,而有些index結果永遠不會出現(比如0111)!

這樣,顯然不符合Hash算法均勻分佈的原則。

反觀長度16或者其他2的冪,Length-1的值是所有二進制位全爲1,這種情況下,index的結果等同於HashCode後幾位的值。只要輸入的HashCode本身分佈均勻,Hash算法的結果就是均勻的。


高併發下的HashMap

在高併發下使用HashMap,它的容量是有限的,所以HashMap會通過ReHash的方式進行擴容,使用ReHash能夠使容量擴大本身的兩倍。所以ReHash是在HashMap擴容時的一個步驟。當經過多次元素插入,使得HashMap達到一定的飽和度,key映射位置發生衝突的機率會逐漸增高。這個情況我們需要進行Resize,那麼影響Resize的因素有兩個:Capacity 初始長度 、LoadFactor 負載因子.

HashMap.Size >= Capacity * LoadFactor

HashMap的Resize經過兩個步驟:

擴容:創建一個新的Entry空數組,長度是原來的兩倍
ReHash:遍歷原來的Entry數組,把所有的Entry重新Hash到新數組。因爲長度擴大之後Hash的規則會發生變動,因此需要重新Hash。

回顧一下Hash公式: index = HashCode(Key) & (Length - 1)

當原數組長度爲8時,Hash運算是和111B做與運算;新數組長度爲16,Hash運算是和1111B做與運算。Hash結果顯然不同。

Resize前的HashMap:

Resize後的HashMap:


ReHash的Java代碼如下:

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

HashMap線程問題(參考JDK1.7)

上面的情況使用在單線程下是沒有問題的,但是一旦使用在多線程中,那麼就會出現破壞內部數據結構的鏈表數組。其中一些鏈接可能會丟失,或者形成了迴路,從而導致數據結構不可用。在ConcurrentHashMap中是不會發生的,高併發的情況下使用這個集合類兼顧了線程安全和性能。爲保證線程安全可以使用HashTable、collections、synchronizedMap。

內容包括:

Hashmap在插入元素過多的時候需要進行Resize,Resize的條件是
HashMap.Size >= Capacity * LoadFactor。

Hashmap的Resize包含擴容和ReHash兩個步驟,ReHash在併發的情況下可能會形成鏈表環。

原理:

假設一個HashMap已經到了Resize的臨界點。此時有兩個線程A和B,在同一時刻對HashMap進行Put操作:


此時達到Resize條件,兩個線程各自進行Rezie的第一步,也就是擴容:

這時候,兩個線程都走到了ReHash的步驟。讓我們回顧一下ReHash的代碼:

假如此時線程B遍歷到Entry3對象,剛執行完紅框裏的這行代碼,線程就被掛起。對於線程B來說:

e = Entry3 
next = Entry2

這時候線程A暢通無阻地進行着Rehash,當ReHash完成後,結果如下(圖中的e和next,代表線程B的兩個引用):

直到這一步,看起來沒什麼毛病。接下來線程B恢復,繼續執行屬於它自己的ReHash。線程B剛纔的狀態是:

e = Entry3 
next = Entry2

當執行到上面這一行時,顯然 i = 3,因爲剛纔線程A對於Entry3的hash結果也是3。


我們繼續執行到這兩行,Entry3放入了線程B的數組下標爲3的位置,並且e指向了Entry2。此時e和next的指向如下:

e = Entry2 
next = Entry2

整體情況如圖所示:

接着是新一輪循環,又執行到紅框內的代碼行:

e = Entry2 
next = Entry3

整體情況如圖所示:

接下來執行下面的三行,用頭插法把Entry2插入到了線程B的數組的頭結點:

整體情況如圖所示:

第三次循環開始,又執行到紅框的代碼:

e = Entry3 
next = Entry3.next = null

最後一步,當我們執行下面這一行的時候,見證奇蹟的時刻來臨了:

newTable[i] = Entry2 
e = Entry3 
Entry2.next = Entry3 
Entry3.next = Entry2

鏈表出現了環形!

整體情況如圖所示:

此時,問題還沒有直接產生。當調用Get查找一個不存在的Key,而這個Key的Hash結果恰好等於3的時候,由於位置3帶有環形鏈表,所以程序將會進入死循環!


ConcurrentHashMap的出現確保了線程的安全且高效率

  1. 這裏介紹的ConcurrentHashMap原理和代碼,都是基於Java1.7的。在Java8中會有些許差別。
  2. ConcurrentHashMap在對Key求Hash值的時候,爲了實現Segment均勻分佈,進行了兩次Hash。有興趣的朋友可以研究一下源代碼。

String中的HashCode()

String類有個私有的實例字段hash表示這串哈希值,第一次調用的時候,字符串的哈希值會被計算並且賦值給Hash字段,之後再調用HashCode的方法直接取hash字段返回。算法中的方式是,以31爲乘法算式中的因數,再和每個字符進行ASCII碼對應值作運算。

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

字符串哈希可以做很多事情,通常是類似於字符串判等,判迴文之類的。

但是僅僅依賴於哈希值來判斷其實是不嚴謹的,除非能夠保證不會有哈希衝突,通常這一點很難做到。

就拿jdk中String類的哈希方法來舉例,字符串”gdejicbegh”與字符串”hgebcijedg”具有相同的hashCode()返回值-801038016,並且它們具有reverse的關係。這個例子說明了用jdk中默認的hashCode方法判斷字符串相等或者字符串迴文,都存在反例。


部分的原理來自數據算法與數據結構和程序員小灰的文章觀點,針對某些原理本人進行一些個人的理解,大部分自己進行了深入的理解和探索。對某些問題進行了許多補充。學習是我們開始研究並且進行創造的基本需要,我們引用學習並且使用這些基礎進行延伸的創造和提供價值,這是我們人類歷史上進化的必需過程。

轉載自:https://blog.csdn.net/sinat_31011315/article/details/78699655

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