摘要:你重写了 equals,却没重写 hashCode,然后发现 HashMap.get() 怎么也取不到值。这不是玄学,这是约定。


一、问题现象

class User {
    private Long id;
    private String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) &&
               Objects.equals(name, user.name);
    }
    // ❌ 没有重写 hashCode!
}

public class HashMapTest {
    public static void main(String[] args) {
        User u1 = new User(1L, "Alice");
        User u2 = new User(1L, "Alice");  // 逻辑上相等的另一个对象

        System.out.println(u1.equals(u2));  // true(equals 判断相等)

        Map<User, String> map = new HashMap<>();
        map.put(u1, "VIP");

        System.out.println(map.get(u1));  // "VIP"(能取到,u1 是同一个对象)
        System.out.println(map.get(u2));  // null ❌(取不到!)
    }
}

运行结果:

true
VIP
null

u1.equals(u2) == true,但 map.get(u2) 返回 null —— 这就是不重写 hashCode 的典型灾难。


二、踩坑现场

场景 1:用自定义对象做 Map 的 Key

// ❌ 常见错误:用 DTO 做 key,但没重写 hashCode
class OrderQuery {
    private Long userId;
    private Date startDate;
    private Date endDate;
    // 只重写了 equals,没重写 hashCode
}

Map<OrderQuery, List<Order>> cache = new HashMap<>();
OrderQuery query = new OrderQuery(1L, start, end);
cache.put(query, orders);

// 换个新的 query 对象(字段相同),取不出来
OrderQuery sameQuery = new OrderQuery(1L, start, end);
cache.get(sameQuery);  // null ❌

场景 2:HashSet "重复"添加元素

Set<User> users = new HashSet<>();
users.add(new User(1L, "Alice"));
users.add(new User(1L, "Alice"));  // equals 相等,但 hashCode 不同

System.out.println(users.size());  // 2 ❌ 应该是 1

三、原理解析

3.1 hashCode 的约定(Object 规范)

Java 语言规范对 hashCode 有明确约定:

  1. 同一个对象(未修改)多次调用 hashCode,必须返回相同的值
  2. 如果两个对象 equals 返回 true,它们的 hashCode 必须相等
  3. (建议)如果两个对象 equals 返回 false,它们的 hashCode 尽量不同(减少 hash 冲突)

第 2 条是核心equals 相等 → hashCode 必须相等。

3.2 HashMap 的查找流程

map.get(key)
  │
  ▼
先计算 key.hashCode() → 找到桶(bucket)位置
  │
  ▼
遍历桶内的所有元素,用 equals() 判断是否相等
  │
  ├── 找到 → 返回值
  └── 没找到 → 返回 null

问题所在

u1.hashCode() = 101  // 假设在桶 101
u2.hashCode() = 205  // 假设在桶 205(因为没重写,默认是对象地址算出来的)

map.get(u2):
  计算 hashCode = 205
  去桶 205 找 → 桶 205 是空的(u1 在桶 101)
  返回 null

3.3 默认 hashCode 的实现

Object 的默认 hashCode基于对象内存地址计算的(JVM 实现相关),不同对象的 hashCode 几乎一定不同。

// Object 的 hashCode 是 native 方法
public native int hashCode();

3.4 equalshashCode 的不变量

equals 相等  →  hashCode 必须相等  ✅(必须遵守)
equals 不等  →  hashCode 可以相等  ✅(允许 hash 冲突)
hashCode 相等 →  equals 可以不等    ✅(允许 hash 冲突)
hashCode 不等 →  equals 必须不等    ❌(不会 happened,因为 HashMap 先比 hashCode)

四、正确写法

4.1 同时重写 equalshashCode

class User {
    private Long id;
    private String name;

    // 构造方法省略...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) &&
               Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name);  // ✅ 用 Objects.hash 自动处理 null
    }
}

4.2 用 Lombok 自动生成(推荐)

import lombok.EqualsAndHashCode;

@EqualsAndHashCode
class User {
    private Long id;
    private String name;
}

或只生成特定字段:

@EqualsAndHashCode(of = {"id"})  // 只用 id 判断相等
class User {
    private Long id;
    private String name;
    private Integer age;  // 不参与 equals/hashCode
}

4.3 用 IDE 自动生成

IDEA右键 → Generate → equals() and hashCode()

生成效果(推荐选择 Objects.equals / Objects.hash 模板):

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    User user = (User) o;
    return Objects.equals(id, user.id) && Objects.equals(name, user.name);
}

@Override
public int hashCode() {
    return Objects.hash(id, name);
}

4.4 只读对象用 record(Java 16+)

// ✅ record 自动生成 equals + hashCode + toString
public record User(Long id, String name) {}

// 不需要手动重写任何方法

五、最佳实践

✅ 5 条铁律

  1. 只要重写了 equals,必须同时重写 hashCode
  2. equals 用到的字段,hashCode 也必须用到(保持一致)**
  3. 不可变对象的 hashCode 可以缓存(见下方优化)**
  4. 用 Lombok @EqualsAndHashCode 或让 IDE 生成,不要手写
  5. hashCode 返回 int,要注意哈希冲突概率

🔍 性能优化:hashCode 缓存

class User {
    private final Long id;
    private final String name;
    private int hashCode;  // 缓存 hashCode

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) && Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        if (hashCode == 0) {  // 懒加载缓存
            hashCode = Objects.hash(id, name);
        }
        return hashCode;
    }
}

前提:对象是不可变的(字段用 final),否则 hashCode 缓存会失效。

🛠️ 阿里巴巴 Java 开发手册规约

【强制】 因为 Set 存储的是不重复的对象,并且依据 hashCodeequals 进行判断,所以 Set 存储的对象必须重写这两个方法。

【强制】 如果自定义对象作为 Map 的键,那么必须重写 equalshashCode


六、小结

  • equals 相等 → hashCode 必须相等,这是 HashMap/HashSet 正确工作的前提
  • 不重写 hashCode 会导致"逻辑相等"的对象在 HashMap 里找不到
  • 永远同时重写 equalshashCode,用 Objects.equals + Objects.hash
  • Lombok 的 @EqualsAndHashCode 是最省心的方案
  • Java 16+ 的 record 天然正确,适合作为 DTO/值对象

下一篇预告:try-finally 里的 return,到底返回谁? —— finally 块里的 return 会覆盖 try 里的返回值,这个坑比你想的更隐蔽。

更多推荐