Java 基礎:解析 hashCode

OreChou發表於2019-02-27

Java 中所有的類都繼承自 Object 類,Object 類中有個返回 hashCode 的本地方法。

public native int hashCode();
複製程式碼

在文件的註釋中很清楚的說明了 hashCode 的作用,和它應該滿足的一些要求。

作用:給一個物件返回一個 hashCode 值,這個值在 hash table 的資料結構中有重要的作用。例如,確定放置在 hash table 哪個索引位置,hash 衝突的頻率。

要求

  1. 同一個 Java 物件,在程式執行的整個生命週期中。該物件返回的 hashCode 應該相同。
  2. 使用 equals 方法,判斷為兩個相等的物件,其返回的 hashCode 應該相同。
  3. 使用 equals 方法,判斷為兩個不相同的物件,其返回的 hashCode 應該不相同。

通常的 hashCode 生成方法是將物件的記憶體地址轉換成一個整型數,這樣就能為不同的物件返回一個不一樣的 hashCode。但是這種方法不能滿足上面的第二個條件,所以這種實現也不是 Java 語言所必須的實現方法。

在 String 中的實現

String 類也是繼承自 Object 類,它重寫了 hashCode() 方法。

/** Cache the hash code for the string */
private int hash; // Default to 0

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;
}
複製程式碼

在 String 中計算的 hashCode 會儲存在 hash 變數中,並且只會計算一次。因為 String 是 final 的,並且一個 String 物件被初始化後無法修改,所以它的 hashCode 不會變化。

for 迴圈計算 hashCode 的方法是根據以下式子:s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]。

使用 31 的原因

31 是一個質數(Prime number),質數也稱為素數。質數是大於 1 的自然數,且只能被 1 和其本身整除。

選擇 31 的原因大概有以下幾點:

  • 一個數乘質數後的結果,只能被 1 、質數、乘數還有結果本身整除。計算 hashCode 選擇一個優質質數可以降低 hash 的衝突率。

  • 31 (2 << 5 – 1),在計算的過程中可以被 JVM 優化。

相信第二點很多同學都能夠理解,現在解釋一下第一點。

我們列舉一下 100 以內左右的質數:2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97。

從上面的質數中選擇三個小中大質數:2,31,97。分析公式 s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1] 中的每一項,都是一個數乘質數的平方項。所以我們計算一下每個質數的 n 次方,我們選擇 n = 5。那麼結果如下:

質數 結果
2 2^5 = 32
31 31^5 = 28,629,151
97 97^5 = 8,587,340,257

可以看到通過質數 2 計算後的 hashCode 值是一個比較小的值,而通過質數 97 計算後的 hashCode 是一個比較大的值,而 31 比較適中。

我們可以認為 hashCode 的範圍若太小,可能會增加 hash 衝突的概率。而計算 hashCode 的計算乘子太大容易導致整型數的溢位(這裡並不是說選擇 31 不會導致溢位,是指一個導致溢位的速率),從而也會導致 hash 衝突的概率。31 可以有效的減輕這兩點。

更詳細的內容可以看一下 stackoverflow 上面的這個問題:Why does Java`s hashCode() in String use 31 as a multiplier?

設計 hashCode 演算法

根據《Effective Java》第二版中的第 9 條,對於我們自己編寫的類,覆蓋 equals 方法時需要覆蓋 hashCode 方法。原因在前面說過。

那麼如何設計一個 hashCode 演算法,書中設計了一個演算法:

  1. 把某個非 0 的常數值,比如 17,儲存在一個名為 result 的 int 型別的變數中。
  2. 對於物件中的每個域,做如下操作:
    • 為該域計算 int 型別的雜湊值 c :
      • 如果該域是 boolean 型別,則計算 (f?1:0)
      • 如果該域是 byte、char、short 或者 int 型別,則計算 (int)f
      • 如果該域是 long 型別,則計算 (int)(f^(f>>>32))
      • 如果該域是 float 型別,則計算 Float.floatToIntBits(f)
      • 如果該域是 double 型別,則計算 Double.doubleToLongBits(f),然後重複第三個步驟。
      • 如果該域是一個物件引用,並且該類的 equals 方法通過遞迴呼叫 equals 方法來比較這個域,同樣為這個域遞迴的呼叫 hashCode,如果這個域為 null,則返回0。
      • 如果該域是陣列,則要把每一個元素當作單獨的域來處理,遞迴的運用上述規則,如果陣列域中的每個元素都很重要,那麼可以使用 Arrays.hashCode 方法。
    • 按照公式 result = 31 * result + c,把上面步驟 2.1 中計算得到的雜湊碼 c 合併到 result 中。
  3. 返回 result

參考

科普:為什麼 String hashCode 方法選擇數字31作為乘子

Why does Java`s hashCode() in String use 31 as a multiplier?

《Effective Java》

相關文章