两个"明明相等"的对象塞进 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. 自测
- 只重写
equals不重写hashCode,在HashMap里的表现是"覆盖失败"还是"两个 key 共存"?为什么? - 父类
equals用instanceof、子类加字段,对称性怎么被破坏?给一个contains/indexOf结果反常的例子。 new BigDecimal("1.0").equals(new BigDecimal("1.00"))为什么是false?比较数值该用哪个方法?- 对象放进
HashSet后改了它一个字段,remove为什么删不掉?元素真的丢了吗? - Lombok 的
callSuper默认值是什么?继承体系里不写它会漏掉什么? - 什么情况下我们应该故意不重写
equals/hashCode?