如何正確實現 Java 中的 HashCode

FrankYou發表於2018-10-25

相等 和 Hash Code

從一般角度來看,Equality 是不錯的,但是 hash code 更則具技巧性。如果我們在 hash code上多下點功夫,我們就能瞭解到 hash code 就是用在細微處去提升效能的。

大部分的資料結構使用equals去檢查是否他們包含一個元素。例如:

1
2
List<String> list = Arrays.asList("a", "b", "c");
boolean contains = list.contains("b");

這個變數 contains 是true。因為他們是相等的,雖然b的例項化(instance)雖然不完全一樣(再說一次,忽略String interning)。

將傳遞給 contains 的例項與每個元素進行比較很浪費時間。還好,整個這類資料結構使用了一種更高效的方法。它不會將請求的例項與每個元素比較,而是使用捷徑,找到可能與之相等的例項,然後只比較這幾項。

這個捷徑就是雜湊碼——從物件計算出來的一個能代表該物件的整數值。與雜湊碼相同的例項不必相等,但相等的例項一定有相同的雜湊碼。(或者說應該有,我們稍後會對這個問題進行簡單討論)。這類的資料結構常常使用這種技術命名,在名稱中加入 Hash 以便識別,其中最具代表性的就是 HashMap。

一般情況下它們會這樣進行:

  • 新增一個元素的時候,使用它的雜湊碼來計算存放在內部陣列(稱為桶)中的位置(序號)。
  • 另一個不等同的元素如果具有相同的雜湊碼,它會被放在同一個桶中,與原來那個放在一起,比如把它們放在一個列表中。
  • 如果傳遞一個例項給 contains 方法,會先計算它的雜湊碼來找到桶,只有同一個桶中的元素需要與這個例項進行比較。

使用這種方法實現 contains 的情況很少,在理想的狀態下根本不需要 equals 比較。

將 equals、hashCode 定義在 Object 中。

關於雜湊的一些思考

如果把 hashCode 作為一種快捷方式取決於其是否相等,那麼只有一件事情我們需要關心:相等的物件應該有一致的雜湊碼。

這也是為什麼,如果我們覆寫 equals 方法,就必須建立一個匹配的 hashCode 實現!此外,實現 equal 應該是依據我們的實現而實現的,這可能會導致沒有相同的雜湊碼,因為他們使用的是 Object 的實現。

hashCode 約定

從原文件引用:

對於 hashCode 的一般約定:

  • 在 Java 應用程式中,任何時候對同一物件多次呼叫 hashCode 方法,都必須一直返回同樣的整數,對它提供的資訊也用於物件的相等比較,且不會被修改。這個整數在兩次對同一個應用程式的執行不中不需要保持一致。
  • 如果兩個物件通過 equals(Object) 方法來比較相等,那麼這兩個物件的 hashCode 方法必須產生同樣的整型結果。
  • 如果兩個物件通過 equals(Object) 方法比較結果不等,這兩個物件的 hashCode 不必產生同不整型結果。然而,開發者應該瞭解對不等的物件產生不同的整型結果有助於提高雜湊表的效能。

第一條反映了 equals 的一致性。第二條是我們在上面提到的要求。第三條陳述了我們下面要討論的一個重要細節。

實現 hashCode

Person.hashCode 有個很簡單的實現:

1
2
3
4
@Override
public int hashCode() {
return Objects.hash(firstName, lastName);
}

通過計算相關欄位的雜湊碼,再把這些雜湊碼組合起來得到 person 的雜湊碼。它們用 Object 的工具函式 hash 來參與計算。

選擇欄位

然而什麼欄位才是相關的?這些要求有助於回答這個問題:如果相等的物件必須有相同的雜湊碼,那麼在計算雜湊碼的時候就不應該使用那些不用於相等性檢查的欄位。(否則,如果兩個物件只有那些欄位不同的話,它們會相等但雜湊碼不同。)

所以用於計算雜湊碼的那些欄位應該是用於相等性比較的那些欄位的子集。預設情況下,它們會使用相同的欄位,但有幾個細節需要考慮。

一致性

第一是一致性要求。它應該經過非常嚴格的計算。如果有欄位產生了變化,雜湊碼也應該允許變化(對於可變類來說,這往往是不可避免的),依賴雜湊的資料結構並未準備應付這種情況。

正如我們在上面看到的那樣,雜湊碼用於確定一個元素的桶,但是如果雜湊相關的欄位發生變化,並不會立即重新計算雜湊碼,而且內部的陣列也不會更新。

這就意味著,再對一個相等的物件甚至同一個物件的查詢會失敗!這個資料結構會計算當前的雜湊碼,這個雜湊碼與例項存入時的雜湊碼並不相同,這直接導致找錯了桶。

小結:最好不要用可變的欄位來計算雜湊碼!

效能

雜湊碼可能最終會在每次呼叫 equals 的時候計算,這可能正好發生在程式碼中效能極為關鍵的部分,所以考慮效能是很有意義的。相比之下 equals 的優化空間就非常小。

除非是使用了複雜的演算法,或者使用的欄位非常非常多,組合他們雜湊碼的計算成本可以忽略不計,因為這不可避免。但是應該考慮是否所有欄位都需要包含在計算中!尤其應該以審視的眼光來看待集合,例如計算列表和集合中所有元素的雜湊碼。需要根據不同的情況來考慮是否需要它們參與計算。

如果效能是關鍵,使用 Object.hash 就可能不是最好的選擇,因為它會為可變引數建立陣列。

一般的優化原則是:謹慎處理!使用一個公共雜湊演算法的,可能需要放棄集合,並在分析可能的改進之後進行優化。

碰撞

如果只關注效能,下面這個例項怎麼樣?

1
2
3
4
@Override
public int hashCode() {
return 0;
}

毫無疑問,它很快。而且相等的物件會有相同的雜湊碼,這也讓我們覺得不錯。還有個亮點,它不涉及可變的欄位!

但是,想想我們提到的桶是什麼?這種情況下所有例項會被裝進同一個桶中!通常這會導致使用一個連結串列來容納所有元素,這樣的效能太糟糕了——比如,每次執行 contains 都會對列表進行線性掃描。

因此,我們得讓每個桶裡的內容儘可能的少!一個即使對非常相似的物件計算的雜湊碼也大不相同的演算法,會是一個不錯的開始。

如何取得,一定程度上取決於選擇的欄位。我們用於計算的細節,更多時候是為了生成不同的雜湊碼。注意,這與我們對效能的想法完全相反。結果很有趣,用太多或者太少欄位都會導致效能不佳。

防止碰撞的演算法是雜湊演算法的另一部分。

計算雜湊

計算欄位的雜湊碼最簡單的辦法就是直接呼叫這個欄位的 `hashCode`。可以手工來進行合併。一個公共演算法是從任意的某個數開始,讓它與另一個數(通常是一個小素數)相乘,再加上一個欄位的雜湊碼,然後重複:

1
2
3
4
5
int prime = 31;
int result = 1;
result = prime * result + ((firstName == null) ? 0 : firstName.hashCode());
result = prime * result + ((lastName == null) ? 0 : lastName.hashCode());
return result;

這有可能造成溢位,但這不是什麼大問題,因為在 Java 中不會引發異常。

注意,如果輸入資料有著特定的模式,最好的雜湊演算法都可能出現異常頻繁的碰撞。舉個簡單的例子,假設我們用一個點的 x 座標和 y 座標來計算雜湊。一開始不太糟,直到我們發現這樣一條直線上的點:f(x) = -x,這些點的 x + y = 0。就會發生大量的碰撞!

相關文章