「Java 进阶之路」系列 Day20
写在前面
这是一个几乎每个人都踩过的坑:给一个类重写了 equals,自信满满地觉得"两个属性一样的对象现在应该被判定为相等了",结果一放进 HashSet,contains 查一个属性完全一样的新对象,返回的却是 false。问题不在 equals 写错了,而是漏了另一半——hashCode。这篇就把这两个方法为什么必须绑在一起讲清楚。
一、是什么:一个判断"相等",一个决定"放哪"
equals 定义的是"逻辑相等":两个对象即使不是同一个引用,只要关心的属性都一样,就应该被判定为相等。Object 类默认的 equals 比较的是引用地址,等价于 ==——不重写的话,两个属性完全一样的不同对象永远不相等。
hashCode 返回一个 int,本意是给"基于哈希表的容器"(HashMap、HashSet、Hashtable)用来快速定位数据该放进哪个桶(bucket)。Object 默认的 hashCode 是跟对象内存地址相关的一个整数,同一个对象只要没被移动,多次调用返回值不变,但和 equals 判定的"逻辑相等"完全没关系。
二、为什么:HashMap 靠这两个方法配合才能定位到对象
hashCode 的官方契约里有一条硬性规定:两个对象用 equals 判定相等,hashCode 就必须相等(反过来不要求——hashCode 相同的两个对象完全可以不相等,这叫哈希冲突,哈希表本来就要处理这种情况)。
这条契约不是凭空定的,而是 HashMap.get(key) 的查找过程决定的:先算 key.hashCode(),用这个值定位到某个桶,再在这个桶里逐个用 equals 比较,找到真正相等的那一个:
flowchart LR
k[key对象] --> h[计算hashCode]
h --> idx[定位到某个桶]
idx --> bucket[桶内可能有多个entry]
bucket --> eq[逐个用equals比较]
eq --> hit[找到匹配返回]
eq --> miss[没有匹配判定<br/>不存在]
如果只重写了 equals、没重写 hashCode,两个逻辑相等的对象会因为默认的 hashCode(基于地址)不同,从一开始就被分到了不同的桶——equals 再准确也没用,压根不会走到"逐个比较"这一步。这就是为什么这两个方法必须成对重写:equals 负责终点的精确比较,hashCode 负责能不能先走到这个终点。
三、怎么用:三个容易踩的坑
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
// 没有重写 hashCode!
}
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
System.out.println(points.contains(new Point(1, 2))); // false!逻辑相等的对象却查不到
正确做法是让 hashCode 用和 equals 完全一致的字段来计算:
@Override
public int hashCode() {
return Objects.hash(x, y);
}
Objects.hash 内部就是把每个字段的 hashCode 按 31 做累乘累加,IDE 自动生成、Lombok 的 @EqualsAndHashCode 走的也是同一套思路。
一个更隐蔽的坑:用可变字段参与 hashCode 计算。 对象放进 HashSet 之后,如果再去修改参与 hashCode 计算的字段,这个对象的 hashCode 会变,但它在哈希表里的桶位置是插入那一刻就定死的、不会跟着重新调整——于是这个对象实际上"迷失"了,后续用 contains/remove 都定位不到它原来所在的桶,看起来就像对象凭空消失。参与 equals/hashCode 计算的字段最好是不可变的,或者整个对象干脆设计成不可变对象。
另一个容易忽略的坑:类型检查该用 instanceof 还是 getClass()。 上面 Point 的 equals 用的是 instanceof,如果 Point 被继承出一个 ColorPoint(多了一个颜色字段),instanceof 会认为父类的 Point(1, 2) 和子类的 ColorPoint(1, 2, 红色) 只要坐标一样就相等——但反过来 ColorPoint 的 equals 通常会比较颜色,判定两者不相等。这样就出现了 p.equals(cp) 为 true、cp.equals(p) 为 false,破坏了 equals 契约里的对称性(a.equals(b) 和 b.equals(a) 结果必须一致)。改用 o.getClass() != this.getClass() 判断类型能规避这个问题(要求类型完全一致,父子类之间永远不相等),代价是丧失了"父类引用可以和子类对象比较"的灵活性——这也是为什么《Effective Java》建议优先用组合代替继承来复用带 equals 的类,从设计上绕开这个两难。
四、面试追问
Q1:为什么重写 equals 一定要重写 hashCode?
因为 HashMap/HashSet 这类基于哈希表的容器,查找时先用 hashCode() 定位桶,再在桶内用 equals() 精确比较。只重写 equals 不重写 hashCode,两个 equals 判定相等的对象会因为默认 hashCode(基于地址)不同而被分到不同桶,导致 contains/get 永远找不到本该相等的元素,违反了"equals 相等则 hashCode 必须相等"这条硬性契约。
Q2:hashCode 相等的两个对象,equals 一定相等吗?
不一定。hashCode 允许出现哈希冲突,equals 不相等的对象完全可以有相同的 hashCode;反过来 equals 相等则 hashCode 必须相等,这是单向约束,不能反过来推。
Q3:用可变字段参与 hashCode 计算会有什么风险?
对象放入哈希表之后,如果修改了参与 hashCode 计算的字段,对象的 hashCode 会变,但它在哈希表里的桶位置是插入时就定死的,不会跟着重新调整,于是这个对象实际上"丢失"了——后续用 get/contains/remove 都找不到它。所以参与计算的字段最好是不可变的。
Q4:Object 类默认的 equals 和 hashCode 分别是什么行为?
默认 equals 比较的是引用地址,等价于 ==;默认 hashCode 返回一个跟对象内存地址相关的整数,同一个对象多次调用结果不变,但和"逻辑相等"没有关系。不重写的话,两个属性完全一样的不同对象永远被判定为不相等。
Q5:用 Lombok 的 @EqualsAndHashCode 或 IDE 自动生成时要注意什么?
要确认参与计算的字段集合和业务定义的"相等"语义一致——比如继承关系下父类字段有没有正确纳入;集合类字段参与计算时要保证不可变、不会在放入哈希表之后被修改;如果手动排除了部分字段,要保证 equals 和 hashCode 排除的是同一批字段,不能两边字段集合对不上,否则照样违反契约。
Q6:equals 里用 instanceof 判断类型,可能有什么问题?
如果这个类被继承,子类通常会在 equals 里多比较自己新增的字段。这样一来,父类对象和子类对象比较时,父类的 equals(用 instanceof)可能判定两者相等,但子类的 equals 会因为多出来的字段判定不相等,导致 a.equals(b) 和 b.equals(a) 结果不一致,破坏了 equals 契约里的对称性。改用 getClass() 做严格的类型比较能规避这个问题,代价是父类和子类对象之间永远不可能相等。
下一篇预告
模块二到这里就收尾了,Day21 开始进入模块三"集合框架":ArrayList 和 LinkedList 到底该怎么选,从源码层面把两者的性能差异掰开揉碎讲清楚。