重写 equals 时必须同时重写 hashcode,且二者须基于相同不可变业务字段;需防御 null、类型、继承问题;测试必须用 hashset 验证去重行为。

重写 equals 时必须同时重写 hashCode
不重写 hashCode 是最常见、最致命的错误。Set(尤其是 HashSet)依赖 hashCode 快速分桶,再用 equals 精确比对;如果两个逻辑相等的对象 hashCode 不同,它们根本不会被拉到同一个桶里,equals 根本没机会被调用——结果就是重复元素无法去重。
实操建议:
-
hashCode必须基于equals中用到的**相同字段**计算,且这些字段在对象存入 Set 后不能被修改(否则哈希桶位置失效) - 别手写哈希算法,用
Objects.hash(f1, f2, f3)(Java)或 IDE 自动生成,它已处理 null 和类型差异 - 如果类有继承关系,且父类已重写
equals/hashCode,子类必须显式调用super.equals()和super.hashCode(),否则自反性直接崩
字段选择决定自反性与传递性的生死线
自反性(a.equals(a) 必为 true)和传递性(a.equals(b) && b.equals(c) ⇒ a.equals(c))不是靠“写对逻辑”就能保住的,而是被你选进 equals 的字段天然约束着。
常见错误现象:
- 用可变字段(如
lastAccessTime、retryCount)参与比较 → 同一对象两次调用equals可能返回不同结果,违反自反性 - 用浮点数字段(
double price)直接==比较 →0.1 + 0.2 != 0.3,导致 a==b 为 true、b==c 为 true,但 a==c 为 false,破坏传递性 - 混用不同精度字段(如一个用
BigDecimal存金额,另一个用int cents)→ 相同业务值可能算出不同equals结果
使用场景:只应选择**业务上定义该对象“身份”的不可变字段**,比如 id、skuCode、userId + resourceId 组合,而不是状态快照。
空值、类型、继承三连坑必须手动防御
默认生成的 equals 往往漏掉这三处,运行时抛 NullPointerException 或静默返回 false,让 Set 行为变得不可预测。
实操建议:
- 开头必须判
if (this == obj) return true;—— 这是自反性的底层保障,也是性能捷径 - 紧接着判
if (obj == null || getClass() != obj.getClass()) return false;—— 用getClass()而非instanceof,避免子类实例误等于父类实例(否则传递性易破) - 所有参与比较的引用字段,必须用
Objects.equals(field1, other.field1),它内部安全处理 null - 如果字段是数组,用
Arrays.equals(arr1, arr2);如果是集合,确保其equals语义符合你的预期(比如LinkedHashSet和TreeSet的equals行为不同)
测试 Set 行为比单测 equals 更能暴露问题
光跑 equals 单元测试过不了真场景。Set 的去重逻辑是 hashCode + equals 的组合行为,必须用真实容器验证。
实操建议:
- 构造两个字段完全相同的对象
a和b,放进HashSet,断言set.size() == 1 - 构造三个对象:
a和b相等,b和c相等,放进HashSet,断言set.size() == 1(验证传递性落地效果) - 把对象加入
HashSet后,修改其参与equals的字段(如果意外允许),再查set.contains(modifiedObj)—— 应该返回 false(哈希桶错位),这是你设计的预期,不是 bug
最容易被忽略的是:字段语义一致性。比如一个类用 String name 比较,但数据库里 name 允许大小写混合存储,而业务要求“name 不区分大小写相等”,这时 equals 就得用 String.equalsIgnoreCase,且 hashCode 必须用 name.toLowerCase().hashCode() 对齐——差一个 toLowerCase,Set 就会漏去重。







