2384 字
约 7 分钟
14
S04-X09 · equals 与 hashCode 契约:集合去重的隐形地基

两个"明明相等"的对象塞进 HashSet,size() 给出 2;map.get(key) 返回 null,可这个 key 刚 put 进去。问题不在集合实现里:HashMap 的取数路径是先算哈希定位桶,再在桶里用 equals 确认,对契约每一条都有硬依赖。下面逐条配反例,再落到六个真实失效现场。


1. 契约四条:每条都有对应的失效现场

条款 形式化要求 被破坏后的表现
自反 x.equals(x) 恒为 true contains(x) 为 false
对称 x.equals(y) 与 y.equals(x) 一致 List 出现"重复"元素
传递 x=y 且 y=z 则 x=z 分组结果随输入顺序变化
一致 参与比较的字段不变时结果不变 对象放进 Set 后"消失"

先看"一致性"塌掉的样子——参与哈希的字段可变:

public final class Counter {
    private int hits;
    public void hit() { hits++; }

    @Override public boolean equals(Object o) {
        return o instanceof Counter c && hits == c.hits;
    }
    @Override public int hashCode() { return Integer.hashCode(hits); }  // 哈希依赖可变字段
}
Set<Counter> set = new HashSet<>();
Counter a = new Counter();
set.add(a);
System.out.println(set.contains(a));   // true
a.hit();                               // 改掉参与哈希的字段
System.out.println(set.contains(a));   // 典型输出:false
System.out.println(set.remove(a));     // false,删除同样失效

元素没消失,只是哈希值与落桶时不同了。这就是"内存泄漏式去重":集合越积越多,contains 永远 false。

传递性最容易被"容差判等"破坏——这类 equals 不该出现在值对象上:

Point2D a = new Point2D(0, 0), b = new Point2D(0.4, 0), c = new Point2D(0.8, 0);
// 若 equals 判 |Δx| < 0.5、hashCode 折叠为常量:a=b、b=c 成立,a=c 却不成立
System.out.println(a.equals(b) + ", " + b.equals(c) + ", " + a.equals(c));  // true, true, false

规则很硬:参与 equals 的字段集合必须是 hashCode 字段集合的超集,而这些字段在对象生命周期内必须不变。


2. hashCode 与 equals 必须同步:两个"相等"的 key 同时驻留

一致性要求可以写成两句话:相等必有相同哈希,相同哈希不必相等。

情形 equals hashCode HashMap 里的实际行为
契约要求 true 必须相同 落同桶,桶内比较后视为同一 key
允许(碰撞) false 相同 同桶多 entry,桶内顺序比较
破坏契约 true 不相同 两个"相等"的 key 同时驻留,get 只命中一个
正常情况 false 不同 无影响,这是最常见的情形

只重写 equals 落在第三行:

public final class OrderKey {
    private final String tenant;
    private final long id;
    public OrderKey(String tenant, long id) { this.tenant = tenant; this.id = id; }

    @Override public boolean equals(Object o) {
        return o instanceof OrderKey k && k.id == id && Objects.equals(tenant, k.tenant);
    }
    // 故意不写 hashCode:默认走 Object 的 identityHashCode
}
Map<OrderKey, String> map = new HashMap<>();
map.put(new OrderKey("t1", 1001L), "A");

OrderKey probe = new OrderKey("t1", 1001L);
System.out.println(probe.equals(new OrderKey("t1", 1001L)));  // true
System.out.println(map.get(probe));                           // 典型输出:null
map.put(probe, "B");                                          // 不会覆盖
System.out.println(map.size());                               // 2
System.out.println(map.containsValue("A"));                   // true,"A" 成了孤儿值

size() 变成 2 是关键证据:HashMap 认为这是两个互不相干的 key,哈希落在不同桶,桶内 equals 根本没机会被调用。这不是"覆盖失败",是查找路径在第一阶段就分叉了。HashSet 用同一机制,于是同一 bug 在 Set 上表现为重复元素共存,在 Map 上表现为 get 返回 null 加内存持续增长。


3. 工具方法、继承体系与 Lombok:三个高频破坏点

3.1 Objects.equals 与 Objects.hash 的正确用法

写法 语义 风险
a == b 引用比较(基本类型是值比较) 包装类超出缓存范围即 false
a.equals(b) 调用方非空才安全 a 为 null 时 NPE
Objects.equals(a, b) 先 ==,再双向判 null 无(推荐)
Objects.hash(f1, f2) 变参数组 + 乘加合成 装箱与数组分配
@Override public boolean equals(Object o) {
    if (this == o) return true;                       // 快路径,也顺带排除 null
    if (!(o instanceof OrderKey k)) return false;     // JDK 16+ 模式匹配
    return id == k.id && Objects.equals(tenant, k.tenant);
}
@Override public int hashCode() { return Objects.hash(tenant, id); }  // 字段与 equals 一致

hashCode 的字段少一个,等于宣布"两个不同对象哈希相同";多一个,等于宣布"两个相等对象哈希不同"。

3.2 继承体系里对称性被破坏

public class Point {
    private final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override public boolean equals(Object o) {   // 父类用 instanceof
        return o instanceof Point p && p.x == x && p.y == y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override public boolean equals(Object o) {   // 子类想加字段
        return o instanceof ColorPoint c && super.equals(c) && color.equals(c.color);
    }
    @Override public int hashCode() { return Objects.hash(super.hashCode(), color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");

System.out.println(p.equals(cp));   // true —— 父类只看 x, y
System.out.println(cp.equals(p));   // false —— 子类还要看 color

List<Point> list = new ArrayList<>(List.of(p, cp));
System.out.println(list.contains(cp));   // true,因为第一个元素 p 主动说"我等于 cp"
System.out.println(list.indexOf(cp));    // 0 —— 返回的是 p 的位置

父子之间的 equals 策略只有几种活法:

父类实现 子类策略 对称性 适用
instanceof 加字段并复用 super.equals 破坏 不推荐
getClass() == 加字段 保住 值对象,不允许跨类比较
父类 equals 声明 final 子类不能改 保住 带主键实体
组合而非继承 各自独立 保住 优先

3.3 Lombok @EqualsAndHashCode 的默认行为

场景 默认行为 后果
子类不写 callSuper 只比较子类声明的字段 父类字段被忽略
子类写 callSuper = true 父类字段也参与 父类用 instanceof 时对称性问题照样继承
类里有 List / Map / 数组字段 默认递归比较 可变字段进哈希,入 Set 后丢索引
时间戳、缓存等可变字段 默认参与 加 @EqualsAndHashCode.Exclude
@EqualsAndHashCode(callSuper = true)   // 继承体系里必须显式写
public class AdminUser extends User {
    private String role;               // 参与 equals/hashCode
    @EqualsAndHashCode.Exclude
    private long lastLoginAt;          // 可变且与身份无关,排除
}

callSuper 默认 false,Lombok 会警告 Generating equals/hashCode implementation but without a call to superclass。把这条警告当编译错误处理。


4. BigDecimal 与包装类:值看着相等,语义却不相等

BigDecimal.equals 比的是值 + 标度(scale),compareTo 只比数值:

| 比较方式 | 比较内容 | 1.0 vs 1.00 | 用途 |
|---|---|---|
| equals | 值 + 标度 | false | 慎用于等值判断 |
| compareTo | 只比数值 | 0 | 金额比较的正解 |
| hashCode | 与 equals 对齐,也区分标度 | 不同 | 与 equals 一致 |

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));              // false —— scale 1 vs 2
System.out.println(a.compareTo(b) == 0);      // true
System.out.println(Set.of(a).contains(b));    // 典型输出:false

包装类型的值比较永远用 equals 或 Objects.equals。 Integer x = 1000, y = 1000 时 x == y 为 false(超出 -128..127 缓存)。


5. 排错:六种"看着相等却不相等"的现场

现象 根因 处理
HashSet 里出现两个"相等"对象 只重写 equals 未重写 hashCode 成对生成,字段对齐
put 后 get 返回 null 参与 hashCode 的字段被改过 哈希字段设 final
contains(x) 为 false 但肉眼能找到 equals 不对称或漏比字段 检查 instanceof、子类新增字段
remove(obj) 删不掉 自反性被破坏或哈希漂移 临时用 removeIf 按业务键删,根治靠修契约
Set<BigDecimal> 数量偏多 equals 区分标度 统一 setScale,或改 TreeSet
Lombok 生成的 equals 漏字段 子类没写 callSuper = true 显式加注解,或改组合关系

6. 边界:什么时候不必死守这对契约

场景 建议 理由
带数据库主键的实体 按主键比较,或不重写 未落库时 id 为 null,比较会漂移
只当临时容器元素 用 record 自动生成两方法,字段不可变
用数组或集合当 key 换 key 类型 数组是 identity 语义,List 可变
哈希在热路径上 手写 31 * 展开 Objects.hash 有装箱
需要"按业务键"去重 专用 key 类型或 Collectors.toMap 对象相等 ≠ 业务唯一

一句话:相等的定义要和"什么算同一个东西"对齐,哈希只能依赖一辈子不变的字段。


7. 自测

  1. 只重写 equals 不重写 hashCode,在 HashMap 里的表现是"覆盖失败"还是"两个 key 共存"?为什么?
  2. 父类 equals 用 instanceof、子类加字段,对称性怎么被破坏?给一个 contains / indexOf 结果反常的例子。
  3. new BigDecimal("1.0").equals(new BigDecimal("1.00")) 为什么是 false?比较数值该用哪个方法?
  4. 对象放进 HashSet 后改了它一个字段,remove 为什么删不掉?元素真的丢了吗?
  5. Lombok 的 callSuper 默认值是什么?继承体系里不写它会漏掉什么?
  6. 什么情况下我们应该故意不重写 equals / hashCode?
S04-X09 · equals 与 hashCode 契约:集合去重的隐形地基
https://www.clxhxhhr.top/posts/5755/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。