Java中equals方法比了个寂寞?原来这才是正确的重写姿势

文章来源声明: 原文作者:阿橙的百宝箱; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689714944501317647; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

厘清equals/hashCode重写要点,结合性能与集合行为,适合Java后端排查HashSet/HashMap异常及优化值对象。

去年在金融项目里处理一笔对账数据时,我们突然发现系统漏掉了300多条记录——明明两个`Transaction`对象的金额和账户ID完全一致,`HashSet.contains()`却始终返回`false`。排查到最后,问题竟然出在一个看似简单的`equals`方法上。你是不是也以为重写`equals`就是比较几个字段?今天咱们就聊聊那些年`equals`方法里藏过的坑。

当equals遇上HashSet:一个对账系统的惨案

我们的对账模块需要比对两个数据源的交易记录,逻辑很简单:用HashSet存储基准数据,然后遍历待核对数据调用contains()比对。在测试环境一切正常,但生产环境跑完总有漏网之鱼。
通过日志最终锁定问题:某些Transaction对象虽然业务字段值相同,但hashCode()返回值却不一样——因为有人偷懒只重写了equals却忘了hashCode。这里有个硬规则:如果两个对象equals返回true,它们的hashCode必须相同。否则,当对象作为键存入HashMap或HashSet时,会存在哈希桶定位错误的风险。

<span>// 错误示范:只重写equals</span>
<span>@Override</span>
<span>public</span> <span>boolean</span> <span>equals</span><span>(Object o)</span> {
    <span>if</span> (<span>this</span> == o) <span>return</span> <span>true</span>;
    <span>if</span> (o == <span>null</span> || getClass() != o.getClass()) <span>return</span> <span>false</span>;
    <span>Transaction</span> <span>that</span> <span>=</span> (Transaction) o;
    <span>return</span> amount.equals(that.amount) && 
           accountId.equals(that.accountId);
}

<span>// 正确姿势:必须配套重写hashCode</span>
<span>@Override</span>
<span>public</span> <span>int</span> <span>hashCode</span><span>()</span> {
    <span>return</span> Objects.hash(amount, accountId); <span>// 使用相同字段</span>
}

为什么Lombok的@EqualsAndHashCode也翻过车

你可能会说:"我用Lombok自动生成不就完了?"别急,去年另一个坑正出在这里。某次上线后,监控突然显示某个核心服务CPU飙到90%,堆栈显示卡在HashMap.get()——因为自动生成的hashCode()包含了全部字段,而其中一个字段是BLOB类型的大文本!

  • 关键点:hashCode()必须满足一致性(对象不变时返回值不变),但不要求所有字段参与运算*。对于重型字段,应该手动排除:
<span>@EqualsAndHashCode(onlyExplicitlyIncluded = true)</span>
<span>public</span> <span>class</span> <span>Order</span> {
    <span>@EqualsAndHashCode</span>.Include
    <span>private</span> Long id;    <span>// 只使用轻量级字段</span>
    <span>private</span> <span>byte</span>[] pdfContent; <span>// 排除大字段</span>
}

实测对比:包含10KB文本字段的类,调用hashCode()耗时从1200ns降至60ns(JMH基准测试,预热后结果)。

谁杀死了你的equals性能?

假设你给用户权限系统写了这样的equals:

<span>// 性能杀手:无谓的字符串比对</span>
<span>@Override</span>
<span>public</span> <span>boolean</span> <span>equals</span><span>(Object o)</span> {
    <span>//...省略判空</span>
    <span>User</span> <span>user</span> <span>=</span> (User) o;
    <span>return</span> username.equals(user.username) && 
           permissions.stream()
                     .sorted()
                     .collect(joining(<span>","</span>))
                     .equals(user.permissions.stream() 
                                           .sorted()
                                           .collect(joining(<span>","</span>)));
}

问题出在每次比较都要对流进行排序和拼接。如果权限列表有20项,单次equals耗时直接突破1ms(实测数据)。对于高频调用的场景,这种写法就是自杀。优化原则:优先比较大概率不等的字段,避免深层嵌套结构的完全遍历:

<span>return</span> username.equals(user.username) && 
       permissions.size() == user.permissions.size() && 
       permissions.containsAll(user.permissions); <span>// 前提是Set实现</span>

避坑清单:equals重写的军规

  1. hashCode必须和equals同步重写:违反这条直接导致HashMap/HashSet行为异常
  2. 避免可变字段参与计算:如果参与equals的字段会被修改,对象作为Map键时将"失踪"
  3. 数组字段用Arrays.equals比较:直接调用array.equals()比较的是引用
  4. 浮点数字段用Double.compare:处理NaN和±0.0等特殊情况
  5. 子类equals必须满足里氏替换原则:子类新增字段会导致对称性被破坏

终极答案:什么时候才该重写equals?

经过这些年踩坑,我的判断标准只有两条:

  1. 需要对象逻辑相等而非引用相等时(比如值对象)
  2. 确定该对象会作为集合元素或Map键使用时

否则,直接使用Object的默认实现反而是最安全的选择。你在项目里遇到过哪些equals的奇葩坑?欢迎分享你的血泪史。