在 Java 集合框架中,HashSet、HashMap、HashTable、ConcurrentHashMap 等基於哈希表的容器,核心依賴 hashCode() 定位桶位、equals() 解決衝突。若這兩個方法實現不符合契約,將導致:重複元素無法去重、鍵值查找失效、內存洩漏、業務邏輯錯誤。本文從底層原理、契約規範、違規後果、實戰模板四個維度,構建完整認知體系。
// HashMap.put(key, value) 簡化邏輯
int hash = key.hashCode(); // ① 獲取哈希碼
int index = (n - 1) & hash; // ② 計算桶索引 (n 為數組長度,2 的冪)
Node node = table[index]; // ③ 獲取鏈表/紅黑樹頭節點
if (node == null) {
table[index] = newNode(hash, key, value, null); // 空桶直接掛載
} else {
// ④ 遍歷鏈表/樹,比較 hashCode 後調用 equals
for (Node e = node; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
// 找到相同鍵,覆蓋值
V old = e.value;
e.value = value;
return old;
}
}
// ⑤ 無相同鍵,插入鏈表頭/樹中
addNode(hash, key, value);
}
e.hash == hash 是快速篩選,key.equals(e.key) 是精確比對。equals 負責最終裁決。
if (a.equals(b)) {
assert a.hashCode() == b.hashCode(); // 必須成立
}
違反後果: 兩個邏輯相等的對象落入不同桶,HashSet 存入重複元素,HashMap 同一鍵對應多個值,containsKey 返回 false。
if (!a.equals(b)) {
// a.hashCode() == b.hashCode() 允許發生
}
這正是哈希表解決衝突的基礎:鏈表/紅黑樹存儲同桶不同鍵。
對象在未修改 equals 依賴字段前提下,多次調用 hashCode() 必須返回相同整數。跨 JVM 運行無需一致。
class BadKey {
String id;
BadKey(String id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof BadKey && id.equals(((BadKey)o).id);
}
// 無 hashCode() → 繼承 Object.identityHashCode() (地址相關)
}
Set set = new HashSet<>();
set.add(new BadKey("1"));
set.add(new BadKey("1")); // 邏輯相等,但 hashCode 不同
System.out.println(set.size()); // 2 ❌ 應為 1
class MutableKey {
String name;
MutableKey(String name) { this.name = name; }
@Override public int hashCode() { return name.hashCode(); }
@Override public boolean equals(Object o) { ... }
}
MutableKey key = new MutableKey("Alice");
Map map = new HashMap<>();
map.put(key, "value");
key.name = "Bob"; // 修改 hashCode 依賴字段!
System.out.println(map.get(key)); // null ❌ 找不到,因為 hashCode 變了導致去錯桶
System.out.println(map.get(new MutableKey("Alice"))); // null ❌ 原對象已「丟失」
鐵律: hashCode() 與 equals() 必須依賴不可變字段(或不可變對象的不可變部分)。
class ConstantHash {
@Override public int hashCode() { return 42; } // 所有對象同桶
@Override public boolean equals(Object o) { ... }
}
// 退化為鏈表 O(n),大數據量下極慢
public class Person {
private final String id; // 不可變,參與 equals/hashCode
private final String name; // 不可變
private transient int age; // 可變/不參與,transient 可選
public Person(String id, String name, int age) {
this.id = id; this.name = name; this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(id, person.id) &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name); // JDK 7+ 便利方法
// 等價於: 31 * (31 * 1 + (id==null?0:id.hashCode())) + (name==null?0:name.hashCode())
}
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + id; // int/short/byte/char 直接參與
result = 31 * result + (flag ? 1 : 0); // boolean
result = 31 * result + Double.hashCode(score); // double/float
result = 31 * result + Long.hashCode(ts); // long
return result;
}
@Override
public int hashCode() {
return Objects.hash(id, name, Arrays.hashCode(tags), list);
}
// Objects.hash 內部對數組調用 Arrays.deepHashCode,對集合調用集合 hashCode
| 誤區 | 真相 | 正確做法 |
|---|---|---|
| equals 用 instanceof 即可,無需 getClass | 違反對稱性:子類 equals 父類 true,父類 equals 子類 false | 用 getClass() != o.getClass() 保證嚴格類型匹配,或文檔說明支援子類 |
| hashCode 返回 id.hashCode() 即可 | 若 id 為 null 拋 NPE;單字段分佈差 | Objects.hash(id) 自動處理 null,多字段混合更好 |
| 可變對象能放 HashSet/HashMap Key | 修改字段導致 hashCode 變化,對象「丟失」 | Key 必須不可變;或修改前 remove、修改後再 put |
| 覆寫 equals 參數用具體類型 | 方法簽名必須 equals(Object),否則是重載非覆寫 | 參數 Object,內部 instanceof/getClass 判斷 |
| List/Set 作為 Key 直接用 | 集合 hashCode 依賴元素順序/內容,元素變化導致 Key 失效 | 不可變集合 List.copyOf()、Set.copyOf() 或自定義不可變包裝類 |
| toString() 格式化用於 hashCode | 字符串解析開銷大、分佈差 | 直接組合字段 hashCode |
public final class ImmutablePoint {
private final int x, y;
private volatile int cachedHash; // 0 表示未計算
public ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
@Override
public int hashCode() {
int h = cachedHash;
if (h == 0) {
h = 31 * x + y;
cachedHash = h; // 雙重檢查鎖定模式無需同步,volatile 保證可見性
}
return h;
}
// equals 同理比較 x, y
}
適用於高頻哈希、創建成本大、不可變的值對象(如 String、枚舉、Guava Immutable 集合)。
@Test
void testEqualsHashCodeContract() {
Person a = new Person("1", "Alice");
Person b = new Person("1", "Alice");
Person c = new Person("2", "Bob");
// 1. 自反性
assertEquals(a, a);
assertEquals(a.hashCode(), a.hashCode());
// 2. 對稱性
assertEquals(a, b);
assertEquals(b, a);
assertEquals(a.hashCode(), b.hashCode()); // 契約核心
// 3. 傳遞性
Person b2 = new Person("1", "Alice");
assertEquals(a, b2);
assertEquals(b, b2);
// 4. 一致性
for (int i = 0; i < 100; i++) assertEquals(a, b);
// 5. 非空/異類
assertNotEquals(a, null);
assertNotEquals(a, "string");
// 6. HashMap/HashSet 行為
Set set = new HashSet<>();
set.add(a);
set.add(b); // 不應增加
assertEquals(1, set.size());
Map map = new HashMap<>();
map.put(a, "v1");
map.put(b, "v2"); // 覆蓋
assertEquals(1, map.size());
assertEquals("v2", map.get(a));
}
@EqualsAndHashCode(of = {"id", "name"}) // 指定字段
@Getter @AllArgsConstructor
public class Person { ... }
public record Person(String id, String name) {
// 編譯器自動生成:
// - final 字段
// - 構造器
// - equals/hashCode 基於所有字段
// - toString
// - 訪問器 id(), name()
}
Record 保證不可變、語義正確、零樣板,是現代 Java 定義數據載體的首選。
hashCode() 與 equals() 是 Java 對象身份識別的雙子星。**契約核心:邏輯相等 ⇒ 哈希相等;哈希依賴不可變字段。** 違反契約不編譯報錯,但會在運行時引發極難排查的集合異常。掌握標準實現模板、避開可變字段陷阱、善用 Objects.hash/Record/Lombok、編寫契約測試,是每位 Java 開發者的必修功課。記住:**正確的 equals/hashCode 是集合框架正常工作的前提,也是領域模型封裝不變量的體現。**